datatable.directory codebase https://datatable.directory/
You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
 
 
 
 
 
 
datatable.directory/app/Tables/Changeset.php

627 lines
17 KiB

<?php
namespace App\Tables;
use App\Models\Revision;
use App\Models\Row;
use App\Models\Table;
use Illuminate\Pagination\Paginator;
use Illuminate\Queue\SerializesModels;
use Illuminate\Support\Collection;
use MightyPork\Exceptions\NotApplicableException;
use MightyPork\Exceptions\NotExistException;
use ReflectionClass;
/**
* Object representing a set of table modifications
*/
class Changeset
{
use SerializesModels;
const object_excluded_properties = [
'revision',
'table',
'note',
];
/**
* @var Revision - base revision this changeset belongs to
*/
public $revision;
/**
* @var Table - table this changeset belongs to
*/
public $table;
/**
* @var string - user's note attached to this changeset (future proposal)
*/
public $note = '';
/**
* Rows whose content changed, identified by _id.
* Only changed values are to be filled. Columns are identified by GCIDs
*
* Key'd by _id
*
* @var array|null - [_id -> [_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 ($decorate) {
$row->_orig = array_diff((array)$row, []);
// remove junk
unset($row->_orig['_id']);
unset($row->_orig['_new']);
unset($row->_orig['_remove']);
unset($row->_orig['_changed']);
unset($row->_orig['_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);
if ($decorate) {
$row->_loadvals = array_diff((array)$row, []);
// remove junk
unset($row->_loadvals['_id']);
unset($row->_loadvals['_new']);
unset($row->_loadvals['_remove']);
unset($row->_loadvals['_changed']);
unset($row->_loadvals['_orig']);
}
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) {
if (isset($colsById[$id])) {
$newOrder[] = $colsById[$id];
}
}
$leftover_keys = array_diff(array_keys($colsById), $this->columnOrder);
foreach ($leftover_keys as $id) {
$newOrder[] = $colsById[$id];
}
return $cachedColumns = $newOrder;
}
/**
* @param string $id
* @return Column
*/
public function fetchColumn(string $id)
{
if ($this->isNewColumn($id)) {
$c = new Column($this->newColumns[$id]);
$c->markAsNew();
return $c;
} else {
$columns = collect($this->revision->columns)->keyBy('id');
return new Column($columns[$id]);
}
}
/**
* @param string $id
* @return Column
*/
public function fetchAndTransformColumn(string $id)
{
$column = $this->fetchColumn($id);
if (isset($this->columnUpdates[$column->id])) {
$column->modifyByChangeset($this->columnUpdates[$column->id]);
}
if (in_array($column->id, $this->removedColumns)) {
$column->markForRemoval();
}
return $column;
}
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 isNewColumn(string $id)
{
return isset($this->newColumns[$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)) {
$nr = (object)$this->newRows[$id];
$nr->_new = true;
return $nr;
}
$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
* @return object - updated column
*/
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]);
}
}
return $this->fetchAndTransformRow($_id);
}
/**
* @param $newVals
* @return Column
*/
public function columnUpdate($newVals)
{
$id = $newVals->id;
$col = $this->fetchColumn($id);
$updateObj = [];
foreach ($newVals as $field => $value) {
if (starts_with($field, '_')) continue; // internals
if ($value !== $col->$field) {
$updateObj[$field] = $value;
}
}
if ($this->isNewColumn($id)) {
$this->newColumns[$id] = array_merge($this->newColumns[$id], $updateObj);
}
else {
if (!empty($updateObj)) {
$this->columnUpdates[$id] = $updateObj;
} else {
// remove possible old update record for this row, if nothing changes now
unset($this->columnUpdates[$id]);
}
}
return $this->fetchAndTransformColumn($id);
}
public function columnRemove(string $id)
{
if ($this->isNewColumn($id)) {
unset($this->newColumns[$id]);
// remove it from order
$this->columnOrder = array_diff($this->columnOrder, [$id]);
}
else {
$this->removedColumns[] = $id;
}
}
public function columnRestore(string $id)
{
$this->removedColumns = array_diff($this->removedColumns, [$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');
// using '+' to avoid renumbering
$this->newRows = $this->newRows + $rows->toArray();
}
public function addBlankCol()
{
$cid = (new ColumnNumerator(1))->next();
$allCols = $this->fetchAndTransformColumns();
$num = count($allCols) + 1;
$col = [
'name' => "col_{$num}",
'type' => "string",
'title' => "Column {$num}",
'id' => $cid,
'_new' => true,
];
$this->newColumns[$cid] = $col;
return $col;
}
public function setColOrder(array $order)
{
$allCols = $this->fetchAndTransformColumns();
$ids = collect($allCols)->pluck('id')->all();
$order = array_intersect($order, $ids);
$missing = array_diff($ids, $order);
$this->columnOrder = array_merge($order, $missing);
}
public function removeEmptyNewRows()
{
$cols = $this->fetchColumns();
$emptyTpl = collect($cols)->keyBy('id')->map(function(Column $c) {
return $c->cast(null);
})->all();
$this->newRows = array_filter($this->newRows, function ($r) use ($emptyTpl) {
$emptyTpl['_id'] = $r['_id'];
return $emptyTpl != $r;
});
}
}