silverstripe-framework/dev/BulkLoader.php

413 lines
11 KiB
PHP

<?php
/**
* A base for bulk loaders of content into the SilverStripe database.
* Bulk loaders give SilverStripe authors the ability to do large-scale uploads into their SilverStripe databases.
*
* You can configure column-handling,
*
* @todo Add support for adding/editing has_many relations.
* @todo Add support for deep chaining of relation properties (e.g. Player.Team.Stats.GoalCount)
* @todo Character conversion
*
* @see http://rfc.net/rfc4180.html
* @package framework
* @subpackage bulkloading
* @author Ingo Schommer, Silverstripe Ltd. (<firstname>@silverstripe.com)
*/
abstract class BulkLoader extends ViewableData {
/**
* Each row in the imported dataset should map to one instance
* of this class (with optional property translation
* through {@self::$columnMaps}.
*
* @var string
*/
public $objectClass;
/**
* Override this on subclasses to give the specific functions names.
*
* @var string
*/
public static $title;
/**
* Map columns to DataObject-properties.
* If not specified, we assume the first row
* in the file contains the column headers.
* The order of your array should match the column order.
*
* The column count should match the count of array elements,
* fill with NULL values if you want to skip certain columns.
*
* You can also combine {@link $hasHeaderRow} = true and {@link $columnMap}
* and omit the NULL values in your map.
*
* Supports one-level chaining of has_one relations and properties with dot notation
* (e.g. Team.Title). The first part has to match a has_one relation name
* (not necessarily the classname of the used relation).
*
* <code>
* <?php
* // simple example
* array(
* 'Title',
* 'Birthday'
* )
*
* // complex example
* array(
* 'first name' => 'FirstName', // custom column name
* null, // ignored column
* 'RegionID', // direct has_one/has_many ID setting
* 'OrganisationTitle', // create has_one relation to existing record using $relationCallbacks
* 'street' => 'Organisation.StreetName', // match an existing has_one or create one and write property.
* );
* ?>
* </code>
*
* @var array
*/
public $columnMap = array();
/**
* Find a has_one relation based on a specific column value.
*
* <code>
* <?php
* array(
* 'OrganisationTitle' => array(
* 'relationname' => 'Organisation', // relation accessor name
* 'callback' => 'getOrganisationByTitle',
* );
* );
* ?>
* </code>
*
* @var array
*/
public $relationCallbacks = array();
/**
* Specifies how to determine duplicates based on one or more provided fields
* in the imported data, matching to properties on the used {@link DataObject} class.
* Alternatively the array values can contain a callback method (see example for
* implementation details). The callback method should be defined on the source class.
*
* NOTE: If you're trying to get a unique Member record by a particular field that
* isn't Email, you need to ensure that Member is correctly set to the unique field
* you want, as it will merge any duplicates during {@link Member::onBeforeWrite()}.
*
* {@see Member::set_unique_identifier_field()}.
*
* If multiple checks are specified, the first one "wins".
*
* <code>
* <?php
* array(
* 'customernumber' => 'ID',
* 'phonenumber' => array(
* 'callback' => 'getByImportedPhoneNumber'
* )
* );
* ?>
* </code>
*
* @var array
*/
public $duplicateChecks = array();
/**
* @var Boolean $clearBeforeImport Delete ALL records before importing.
*/
public $deleteExistingRecords = false;
public function __construct($objectClass) {
$this->objectClass = $objectClass;
parent::__construct();
}
/*
* Load the given file via {@link self::processAll()} and {@link self::processRecord()}.
* Optionally truncates (clear) the table before it imports.
*
* @return BulkLoader_Result See {@link self::processAll()}
*/
public function load($filepath) {
increase_time_limit_to(3600);
increase_memory_limit_to('512M');
//get all instances of the to be imported data object
if($this->deleteExistingRecords) {
DataObject::get($this->objectClass)->removeAll();
}
return $this->processAll($filepath);
}
/**
* Preview a file import (don't write anything to the database).
* Useful to analyze the input and give the users a chance to influence
* it through a UI.
*
* @todo Implement preview()
*
* @param string $filepath Absolute path to the file we're importing
* @return array See {@link self::processAll()}
*/
public function preview($filepath) {
user_error("BulkLoader::preview(): Not implemented", E_USER_ERROR);
}
/**
* Process every record in the file
*
* @param string $filepath Absolute path to the file we're importing (with UTF8 content)
* @param boolean $preview If true, we'll just output a summary of changes but not actually do anything
* @return BulkLoader_Result A collection of objects which are either created, updated or deleted.
* 'message': free-text string that can optionally provide some more information about what changes have
*/
abstract protected function processAll($filepath, $preview = false);
/**
* Process a single record from the file.
*
* @param array $record An map of the data, keyed by the header field defined in {@link self::$columnMap}
* @param array $columnMap
* @param $result BulkLoader_Result (passed as reference)
* @param boolean $preview
*/
abstract protected function processRecord($record, $columnMap, &$result, $preview = false);
/**
* Return a FieldList containing all the options for this form; this
* doesn't include the actual upload field itself
*/
public function getOptionFields() {}
/**
* Return a human-readable name for this object.
* It defaults to the class name can be overridden by setting the static variable $title
*
* @return string
*/
public function Title() {
return ($title = $this->stat('title')) ? $title : $this->class;
}
/**
* Get a specification of all available columns and relations on the used model.
* Useful for generation of spec documents for technical end users.
*
* Return Format:
* <code>
* array(
* 'fields' => array('myFieldName'=>'myDescription'),
* 'relations' => array('myRelationName'=>'myDescription'),
* )
* </code>
*
* @todo Mix in custom column mappings
*
* @return array
**/
public function getImportSpec() {
$spec = array();
// get database columns (fieldlabels include fieldname as a key)
// using $$includerelations flag as false, so that it only contain $db fields
$spec['fields'] = (array)singleton($this->objectClass)->fieldLabels(false);
$has_ones = singleton($this->objectClass)->has_one();
$has_manys = singleton($this->objectClass)->has_many();
$many_manys = singleton($this->objectClass)->many_many();
$spec['relations'] = (array)$has_ones + (array)$has_manys + (array)$many_manys;
return $spec;
}
/**
* Determines if a specific field is null.
* Can be useful for unusual "empty" flags in the file,
* e.g. a "(not set)" value.
* The usual {@link DBField::isNull()} checks apply when writing the {@link DataObject},
* so this is mainly a customization method.
*
* @param mixed $val
* @param string $field Name of the field as specified in the array-values for {@link self::$columnMap}.
* @return boolean
*/
protected function isNullValue($val, $fieldName = null) {
return (empty($val) && $val !== '0');
}
}
/**
* Encapsulates the result of a {@link BulkLoader} import
* (usually through the {@link BulkLoader->processAll()} method).
*
* @todo Refactor to support lazy-loaded DataObjectSets once they are implemented.
*
* @package framework
* @subpackage bulkloading
* @author Ingo Schommer, Silverstripe Ltd. (<firstname>@silverstripe.com)
*/
class BulkLoader_Result extends Object {
/**
* @var array Stores a map of ID and ClassNames
* which can be reconstructed to DataObjects.
* As imports can get large we just store enough
* information to reconstruct the objects on demand.
* Optionally includes a status message specific to
* the import of this object. This information is stored
* in a custom object property "_BulkLoaderMessage".
*
* Example:
* <code>
* array(array('ID'=>1, 'ClassName'=>'Member', 'Message'=>'Updated existing record based on ParentID relation'))
* </code>
*/
protected $created = array();
/**
* @var array (see {@link $created})
*/
protected $updated = array();
/**
* @var array (see {@link $created})
*/
protected $deleted = array();
/**
* Stores the last change.
* It is in the same format as {@link $created} but with an additional key, "ChangeType", which will be set to
* one of 3 strings: "created", "updated", or "deleted"
*/
protected $lastChange = array();
/**
* Returns the count of all objects which were
* created or updated.
*
* @return int
*/
public function Count() {
return count($this->created) + count($this->updated);
}
/**
* @return int
*/
public function CreatedCount() {
return count($this->created);
}
/**
* @return int
*/
public function UpdatedCount() {
return count($this->updated);
}
/**
* @return int
*/
public function DeletedCount() {
return count($this->deleted);
}
/**
* Returns all created objects. Each object might
* contain specific importer feedback in the "_BulkLoaderMessage" property.
*
* @return ArrayList
*/
public function Created() {
return $this->mapToArrayList($this->created);
}
/**
* @return ArrayList
*/
public function Updated() {
return $this->mapToArrayList($this->updated);
}
/**
* @return ArrayList
*/
public function Deleted() {
return $this->mapToArrayList($this->deleted);
}
/**
* Returns the last change.
* It is in the same format as {@link $created} but with an additional key, "ChangeType", which will be set to
* one of 3 strings: "created", "updated", or "deleted"
*/
public function LastChange() {
return $this->lastChange;
}
/**
* @param $obj DataObject
* @param $message string
*/
public function addCreated($obj, $message = null) {
$this->created[] = $this->lastChange = array(
'ID' => $obj->ID,
'ClassName' => $obj->class,
'Message' => $message
);
$this->lastChange['ChangeType'] = 'created';
}
/**
* @param $obj DataObject
* @param $message string
*/
public function addUpdated($obj, $message = null) {
$this->updated[] = $this->lastChange = array(
'ID' => $obj->ID,
'ClassName' => $obj->class,
'Message' => $message
);
$this->lastChange['ChangeType'] = 'updated';
}
/**
* @param $obj DataObject
* @param $message string
*/
public function addDeleted($obj, $message = null) {
$this->deleted[] = $this->lastChange = array(
'ID' => $obj->ID,
'ClassName' => $obj->class,
'Message' => $message
);
$this->lastChange['ChangeType'] = 'deleted';
}
/**
* @param $arr Array containing ID and ClassName maps
* @return ArrayList
*/
protected function mapToArrayList($arr) {
$set = new ArrayList();
foreach($arr as $arrItem) {
$obj = DataObject::get_by_id($arrItem['ClassName'], $arrItem['ID']);
$obj->_BulkLoaderMessage = $arrItem['Message'];
if($obj) $set->push($obj);
}
return $set;
}
}