[_id:…, cid:…, cid:…], …] */ public $rowUpdates = []; /** * New rows in the full Row::data format, including GRIDs. * Values are identified by GCIDs from previously defined, or new columns. * * @var array|null - [_id -> [_id:…, cid:…, cid:…], …] */ public $newRows = []; /** * Rows to be removed * * @var int[]|null - GRIDs */ public $removedRows = []; /** * Values changed in column specifications, such as name, title, etc. * This does not affect the table rows in any way. * * Key'd by id * * @var array[] - column specification objects, with GCIDs, key'd by CID */ public $columnUpdates = []; /** * New columns in the full format, including GCIDs * * @var array|null - [id -> [id:…, …], …] */ public $newColumns = []; /** * When reordering columns, here is the column IDs array * in the new order. Columns meanwhile removed from the table * or added to it are to be ignored or appended at the end. * * This shall not be filled if merely editing or adding columns, * unless the order is explicitly adjusted by the user. * * @var string[]|null - GCIDs */ public $columnOrder = []; /** * Columns to be removed * * The data associated to those columns may or may not be removed from the Rows. * It does not matter, other than in regard to the table size. * * @var int[]|null - GCIDs */ public $removedColumns = []; private function walkProps() { $properties = (new ReflectionClass($this))->getProperties(); foreach ($properties as $property) { if (in_array($property->name, self::object_excluded_properties)) { continue; } yield $property->name; } } /** * Reconstruct from a object, such as one found in Proposal. * Note that the fromProposal() method should be used when the * proposal is available, as it populates additional fields. * * @param \stdClass $changes * @return Changeset */ public static function fromObject($changes) { $changeset = new Changeset(); foreach ($changeset->walkProps() as $prop) { if (isset($changes->$prop)) { $changeset->$prop = $changes->$prop; } } return $changeset; } /** * Serialize to an object format that may be stored in a Proposal. * * @return \stdClass */ public function toObject() { $object = new \stdClass(); foreach ($this->walkProps() as $prop) { $object->$prop = $this->$prop; } return $object; } /** * Check if there is any change in this changeset * * @return bool - any found */ public function hasAnyChanges() { foreach ($this->walkProps() as $prop) { if (!empty($this->$prop)) { return true; } } return false; } /** * Decorate / transform a single row for the editor view * * @param object|\DecoratedRow $row - row, must be key'd by column ids * @param bool $decorate - to add extra underscored info for the editor * @return \DecoratedRow|object|null - null if not decorating and the row was removed */ public function transformRow($row, $decorate) { if ($row instanceof Row) $row = (object)$row->getAttributes(); if ($decorate) { $row->_remove = false; $row->_changed = []; $row->_orig = []; } if ($this->isNewRow($row->_id)) { if ($decorate) { $row->_new = true; } return $row; } // Removed rows - return as null if (in_array($row->_id, $this->removedRows)) { if ($decorate) { $row->_remove = true; } else { return null; } } // if marked for removal, hide changes if (!$row->_remove) { // Changed values if (isset($this->rowUpdates[$row->_id])) { $newVals = $this->rowUpdates[$row->_id]; if ($decorate) { $row->_changed = array_keys($newVals); $row->_orig = array_only((array)$row, $row->_changed); } $row = (object)array_merge((array)$row, $newVals); } } // Drop deleted columns if (!$decorate) { foreach ($this->removedColumns as $colId) { unset($row->$colId); } } unset($row->_row_pivot); return $row; } /** * Decorate / transform columns (loaded from the source revision) * * @return Column[] */ public function fetchAndTransformColumns() { /** @var Column[] - loaded and transformed columns, cached from previous call to transformColumns() */ static $cachedColumns = []; if ($cachedColumns) return $cachedColumns; $columns = Column::columnsFromJson($this->revision->columns); // Modify columns foreach ($columns as $column) { if (isset($this->columnUpdates[$column->id])) { $column->modifyByChangeset($this->columnUpdates[$column->id]); } if (in_array($column->id, $this->removedColumns)) { $column->markForRemoval(); } } // Append new columns foreach ($this->newColumns as $newColumn) { $columns[] = $c = new Column($newColumn); $c->markAsNew(); } // Reorder $colsById = collect($columns)->keyBy('id')->all(); $newOrder = []; foreach ($this->columnOrder as $id) { $newOrder[] = $colsById[$id]; } $leftover_keys = array_diff(array_keys($colsById), $this->columnOrder); foreach ($leftover_keys as $id) { $newOrder[] = $colsById[$id]; } return $cachedColumns = $newOrder; } public function rowRemove(int $id) { if ($this->isNewRow($id)) { unset($this->newRows[$id]); } else { $this->removedRows[] = $id; } } public function rowRestore(int $id) { $this->removedRows = array_diff($this->removedRows, [$id]); } public function isNewRow(int $id) { return isset($this->newRows[$id]); } public function fetchAndTransformRow(int $id) { $r = $this->fetchRow($id); $transformed = $this->transformRow($r, true); return $transformed; } public function fetchColumns() { return Column::columnsFromJson($this->revision->columns); } /** * Fetch an existing row from DB, or a new row. * * @param $id * @return object */ public function fetchRow(int $id) { if ($this->isNewRow($id)) { return (object)$this->newRows[$id]; } $r = $this->revision->rowsData($this->fetchColumns(), true, false) ->whereRaw("data->>'_id' = ?", $id)->first(); if (!$r) throw new NotExistException("No such row _id = $id in this revision."); // remove junk unset($r->pivot_revision_id); unset($r->pivot_row_id); return (object)$r->getAttributes(); } /** * Apply a row update, adding the row to the list of changes, or removing it * if all differences were undone. * * @param array|object $newVals - values of the new row */ public function rowUpdate($newVals) { $newVals = (object)$newVals; $_id = $newVals->_id; $origRow = $this->fetchRow($_id); /** @var Column[]|Collection $cols */ $cols = collect($this->fetchAndTransformColumns())->keyBy('id'); $updateObj = []; foreach ($newVals as $colId => $value) { if (starts_with($colId, '_')) continue; // internals $col = $cols[$colId]; $value = $col->cast($value); $origValue = $col->cast(isset($origRow->$colId) ? $origRow->$colId : null); if ($value !== $origValue) { $updateObj[$colId] = $value; } } if ($this->isNewRow($_id)) { $this->newRows[$_id] = array_merge($this->newRows[$_id], $updateObj); } else { if (!empty($updateObj)) { $this->rowUpdates[$_id] = $updateObj; } else { // remove possible old update record for this row, if nothing changes now unset($this->rowUpdates[$_id]); } } } /** * Get a page of added rows for display in the editor * * @param int $perPage * @return \Illuminate\Pagination\LengthAwarePaginator|Collection|array */ public function getAddedRows($perPage = 25) { return collection_paginate($this->newRows, $perPage); } /** * @param Column[] $columns * @param array $csvArray * @param bool $forTableInsert * @return \array[][]|Collection */ public static function csvToRowsArray($columns, $csvArray, $forTableInsert) { /** @var Collection|array[][] $rows */ $rows = collect($csvArray)->map(function ($row) use ($columns, $forTableInsert) { if (count($row) == 0 || count($row) == 1 && $row[0] == '') return null; // discard empty lines if (count($row) != count($columns)) { throw new NotApplicableException("All rows must have " . count($columns) . " fields."); } $data = []; foreach ($row as $i => $val) { $col = $columns[$i]; if (strlen($val) > 1000) { // try to stop people inserting unstructured crap / malformed CSV throw new NotApplicableException("Value for column {$col->name} too long."); } $data[$col->id] = $col->cast($val); } if ($forTableInsert) { return ['data' => $data]; } else { return $data; } })->filter(); $rowNumerator = new RowNumerator(count($csvArray)); if ($forTableInsert) { return $rows->map(function ($row) use (&$rowNumerator) { $row['data']['_id'] = $rowNumerator->next(); return $row; }); } else { return $rows->map(function ($row) use (&$rowNumerator) { $row['_id'] = $rowNumerator->next(); return $row; }); } } public function addBlankRows(int $count) { $numerator = new RowNumerator($count); $columns = $this->fetchAndTransformColumns(); $template = []; foreach ($columns as $column) { $template[$column->id] = $column->cast(null); } foreach ($numerator->generate() as $_id) { $row = $template; $row['_id'] = $_id; $this->newRows[$_id] = $row; } } public function addFilledRows($csvArray) { /** @var Column[] $columns */ $columns = array_values($this->fetchAndTransformColumns()); $rows = self::csvToRowsArray($columns, $csvArray, false) ->keyBy('_id'); $this->newRows = array_merge($this->newRows, $rows->all()); } }