diff --git a/_config.php b/_config.php index b2175384c..e950cc0d7 100644 --- a/_config.php +++ b/_config.php @@ -81,7 +81,7 @@ Director::addRules(10, array( 'images/$Action/$Class/$ID/$Field' => 'Image_Uploader', '' => 'RootURLController', 'sitemap.xml' => 'GoogleSitemap', - 'api/v1/$ClassName/$ID' => 'RestfulServer', + 'api/v1/$ClassName/$ID/$Relation' => 'RestfulServer', 'dev/$Action/$NestedAction' => 'DevelopmentAdmin' )); diff --git a/api/RestfulServer.php b/api/RestfulServer.php index 25fd6d019..a8495bcbb 100644 --- a/api/RestfulServer.php +++ b/api/RestfulServer.php @@ -42,16 +42,33 @@ class RestfulServer extends Controller { if(!isset($this->urlParams['ClassName'])) return $this->notFound(); $className = $this->urlParams['ClassName']; $id = (isset($this->urlParams['ID'])) ? $this->urlParams['ID'] : null; + $relation = (isset($this->urlParams['Relation'])) ? $this->urlParams['Relation'] : null; + + // This is a little clumsy and should be improved with the new TokenisedURL that's coming + if(strpos($relation,'.') !== false) list($relation, $extension) = explode('.', $relation, 2); + else if(strpos($id,'.') !== false) list($id, $extension) = explode('.', $id, 2); + else if(strpos($className,'.') !== false) list($className, $extension) = explode('.', $className, 2); + else $extension = null; + + // Determine mime-type from extension + $contentMap = array( + 'xml' => 'text/xml', + 'json' => 'text/json', + 'js' => 'text/json', + 'xhtml' => 'text/html', + 'html' => 'text/html', + ); + $contentType = isset($contentMap[$extension]) ? $contentMap[$extension] : 'text/xml'; switch($requestMethod) { case 'GET': - return $this->getHandler($className, $id); + return $this->getHandler($className, $id, $relation, $contentType); case 'PUT': - return $this->putHandler($className, $id); + return $this->putHandler($className, $id, $relation, $contentType); case 'DELETE': - return $this->deleteHandler($className, $id); + return $this->deleteHandler($className, $id, $relation, $contentType); case 'POST': } @@ -83,46 +100,67 @@ class RestfulServer extends Controller { * - static $api_access must be set. This enables the API on a class by class basis * - $obj->canView() must return true. This lets you implement record-level security */ - protected function getHandler($className, $id) { - $obj = DataObject::get_by_id($className, $id); - if(!$obj) { - return $this->notFound(); - } - - // TO DO - inspect that Accept header as well. $_GET['accept'] can still be checked, as it's handy for debugging - $contentType = isset($_GET['accept']) ? $_GET['accept'] : 'text/xml'; - - if($obj->stat('api_access') && $obj->canView()) { - switch($contentType) { - case "text/xml": - $this->getResponse()->addHeader("Content-type", "text/xml"); - return $this->dataObjectAsXML($obj); - - case "text/json": - $this->getResponse()->addHeader("Content-type", "text/json"); - return $this->dataObjectAsJSON($obj); - - case "text/html": - case "application/xhtml+xml": - $this->getResponse()->addHeader("Content-type", "text/json"); - return $this->dataObjectAsXHTML($obj); + protected function getHandler($className, $id, $relation, $contentType) { + if($id) { + $obj = DataObject::get_by_id($className, $id); + if(!$obj) { + return $this->notFound(); } + + if(!$obj->stat('api_access') || !$obj->canView()) { + return $this->permissionFailure(); + } + + if($relation) { + if($obj->hasMethod($relation)) $obj = $obj->$relation(); + else return $this->notFound(); + } + } else { - return $this->permissionFailure(); + $obj = DataObject::get($className, ""); + if(!singleton($className)->stat('api_access')) { + return $this->permissionFailure(); + } + } + + // TO DO - inspect that Accept header as well. $_GET['accept'] can still be checked, as it's handy for debugging + switch($contentType) { + case "text/xml": + $this->getResponse()->addHeader("Content-type", "text/xml"); + if($obj instanceof DataObjectSet) return $this->dataObjectSetAsXML($obj); + else return $this->dataObjectAsXML($obj); + + case "text/json": + //$this->getResponse()->addHeader("Content-type", "text/json"); + if($obj instanceof DataObjectSet) return $this->dataObjectSetAsJSON($obj); + else return $this->dataObjectAsJSON($obj); + + case "text/html": + case "application/xhtml+xml": + if($obj instanceof DataObjectSet) return $this->dataObjectSetAsXHTML($obj); + else return $this->dataObjectAsXHTML($obj); } } /** * Generate an XML representation of the given DataObject. */ - protected function dataObjectAsXML(DataObject $obj) { + protected function dataObjectAsXML(DataObject $obj, $includeHeader = true) { $className = $obj->class; $id = $obj->ID; + $objHref = Director::absoluteURL(self::$api_base . "$obj->class/$obj->ID"); - $json = "\n<$className>\n"; + $json = ""; + if($includeHeader) $json .= "\n"; + $json .= "<$className href=\"$objHref.xml\">\n"; foreach($obj->db() as $fieldName => $fieldType) { - $json .= "<$fieldName>" . Convert::raw2xml($obj->$fieldName) . "\n"; + if(is_object($obj->$fieldName)) { + $json .= $obj->$fieldName->toXML(); + } else { + $json .= "<$fieldName>" . Convert::raw2xml($obj->$fieldName) . "\n"; + } } + foreach($obj->has_one() as $relName => $relClass) { $fieldName = $relName . 'ID'; @@ -131,27 +169,26 @@ class RestfulServer extends Controller { } else { $href = Director::absoluteURL(self::$api_base . "$className/$id/$relName"); } - $json .= "<$relName linktype=\"has_one\" href=\"$href\" id=\"{$obj->$fieldName}\" />\n"; + $json .= "<$relName linktype=\"has_one\" href=\"$href.xml\" id=\"{$obj->$fieldName}\" />\n"; } foreach($obj->has_many() as $relName => $relClass) { - $json .= "<$relName linktype=\"has_many\">\n"; + $json .= "<$relName linktype=\"has_many\" href=\"$objHref/$relName.xml\">\n"; $items = $obj->$relName(); foreach($items as $item) { //$href = Director::absoluteURL(self::$api_base . "$className/$id/$relName/$item->ID"); $href = Director::absoluteURL(self::$api_base . "$relClass/$item->ID"); - $json .= "<$relClass href=\"$href\" id=\"{$item->ID}\" />\n"; + $json .= "<$relClass href=\"$href.xml\" id=\"{$item->ID}\" />\n"; } $json .= "\n"; } foreach($obj->many_many() as $relName => $relClass) { - $json .= "<$relName linktype=\"many_many\">\n"; + $json .= "<$relName linktype=\"many_many\" href=\"$objHref/$relName.xml\">\n"; $items = $obj->$relName(); foreach($items as $item) { - //$href = Director::absoluteURL(self::$api_base . "$className/$id/$relName/$item->ID"); $href = Director::absoluteURL(self::$api_base . "$relClass/$item->ID"); - $json .= "<$relClass href=\"$href\" id=\"{$item->ID}\" />\n"; + $json .= "<$relClass href=\"$href.xml\" id=\"{$item->ID}\" />\n"; } $json .= "\n"; } @@ -161,6 +198,20 @@ class RestfulServer extends Controller { return $json; } + /** + * Generate an XML representation of the given DataObject. + */ + protected function dataObjectSetAsXML(DataObjectSet $set) { + $className = $set->class; + + $json = "\n<$className>\n"; + foreach($set as $item) { + if($item->canView()) $json .= $this->dataObjectAsXML($item, false); + } + $json .= ""; + + return $json; + } /** * Generate an XML representation of the given DataObject. @@ -171,7 +222,11 @@ class RestfulServer extends Controller { $json = "{\n className : \"$className\",\n"; foreach($obj->db() as $fieldName => $fieldType) { - $jsonParts[] = "$fieldName : \"" . Convert::raw2js($obj->$fieldName) . "\""; + if(is_object($obj->$fieldName)) { + $jsonParts[] = "$fieldName : " . $obj->$fieldName->toJSON(); + } else { + $jsonParts[] = "$fieldName : \"" . Convert::raw2js($obj->$fieldName) . "\""; + } } foreach($obj->has_one() as $relName => $relClass) { @@ -181,7 +236,7 @@ class RestfulServer extends Controller { } else { $href = Director::absoluteURL(self::$api_base . "$className/$id/$relName"); } - $jsonParts[] = "$relName : { className : \"$relClass\", href : \"$href\", id : \"{$obj->$fieldName}\" }"; + $jsonParts[] = "$relName : { className : \"$relClass\", href : \"$href.json\", id : \"{$obj->$fieldName}\" }"; } foreach($obj->has_many() as $relName => $relClass) { @@ -190,7 +245,7 @@ class RestfulServer extends Controller { foreach($items as $item) { //$href = Director::absoluteURL(self::$api_base . "$className/$id/$relName/$item->ID"); $href = Director::absoluteURL(self::$api_base . "$relClass/$item->ID"); - $jsonInnerParts[] = "{ className : \"$relClass\", href : \"$href\", id : \"{$obj->$fieldName}\" }"; + $jsonInnerParts[] = "{ className : \"$relClass\", href : \"$href.json\", id : \"{$obj->$fieldName}\" }"; } $jsonParts[] = "$relName : [\n " . implode(",\n ", $jsonInnerParts) . " \n ]"; } @@ -201,13 +256,26 @@ class RestfulServer extends Controller { foreach($items as $item) { //$href = Director::absoluteURL(self::$api_base . "$className/$id/$relName/$item->ID"); $href = Director::absoluteURL(self::$api_base . "$relClass/$item->ID"); - $jsonInnerParts[] = " { className : \"$relClass\", href : \"$href\", id : \"{$obj->$fieldName}\" }"; + $jsonInnerParts[] = " { className : \"$relClass\", href : \"$href.json\", id : \"{$obj->$fieldName}\" }"; } $jsonParts[] = "$relName : [\n " . implode(",\n ", $jsonInnerParts) . "\n ]"; } return "{\n " . implode(",\n ", $jsonParts) . "\n}"; } + + /** + * Generate an XML representation of the given DataObject. + */ + protected function dataObjectSetAsJSON(DataObjectSet $set) { + $jsonParts = array(); + foreach($set as $item) { + if($item->canView()) $jsonParts[] = $this->dataObjectAsJSON($item); + } + return "[\n" . implode(",\n", $jsonParts) . "\n]"; + } + + /** * Handler for object delete */ diff --git a/core/ClassInfo.php b/core/ClassInfo.php index ead9e7b1a..e212c6ba4 100755 --- a/core/ClassInfo.php +++ b/core/ClassInfo.php @@ -145,5 +145,13 @@ class ClassInfo { global $_ALL_CLASSES; return (isset($_ALL_CLASSES['implementors'][$interfaceName])) ? $_ALL_CLASSES['implementors'][$interfaceName] : false; } + + /** + * Returns true if the given class implements the given interface + */ + static function classImplements($className, $interfaceName) { + global $_ALL_CLASSES; + return isset($_ALL_CLASSES['implementors'][$interfaceName]) ? in_array($className, $_ALL_CLASSES['implementors'][$interfaceName]) : false; + } } ?> diff --git a/core/model/DataObject.php b/core/model/DataObject.php index 1c5ea2f10..d22ba010a 100644 --- a/core/model/DataObject.php +++ b/core/model/DataObject.php @@ -557,6 +557,14 @@ class DataObject extends ViewableData implements DataObjectInterface { if(($this->ID && is_numeric($this->ID)) && !$forceInsert) { $dbCommand = 'update'; + + // Update the changed array with references to changed obj-fields + foreach($this->record as $k => $v) { + if(is_object($v) && $v->isChanged()) { + $this->changed[$k] = true; + } + } + } else{ $dbCommand = 'insert'; @@ -1022,7 +1030,7 @@ class DataObject extends ViewableData implements DataObjectInterface { * * @return array The database fields */ - public function db() { + public function db($component = null) { $classes = ClassInfo::ancestry($this); $good = false; $items = array(); @@ -1035,7 +1043,15 @@ class DataObject extends ViewableData implements DataObjectInterface { } continue; } - eval("\$items = array_merge((array){$class}::\$db, (array)\$items);"); + + if($component) { + $candidate = eval("return isset({$class}::\$db[\$component]) ? {$class}::\$db[\$component] : null;"); + if($candidate) { + return $candidate; + } + } else { + eval("\$items = array_merge((array){$class}::\$db, (array)\$items);"); + } } return $items; @@ -1176,8 +1192,7 @@ class DataObject extends ViewableData implements DataObjectInterface { public function scaffoldFormFields() { $fields = new FieldSet(); - foreach($this->inheritedDatabaseFields() as $fieldName => $fieldType) { - + foreach($this->db() as $fieldName => $fieldType) { // @todo Pass localized title // commented out, to be less of a pain in the ass //$fields->addFieldToTab('Root.Main', $this->dbObject($fieldName)->scaffoldFormField()); @@ -1243,6 +1258,22 @@ class DataObject extends ViewableData implements DataObjectInterface { * @return mixed The field value */ protected function getField($field) { + // If we already have an object in $this->record, then we should just return that + if(isset($this->record[$field]) && is_object($this->record[$field])) return $this->record[$field]; + + // Otherwise, we need to determine if this is a complex field + $fieldClass = $this->db($field); + if($fieldClass && ClassInfo::classImplements($fieldClass, 'CompositeDBField')) { + $helperPair = $this->castingHelperPair($field); + $constructor = $helperPair['castingHelper']; + $fieldName = $field; + $fieldObj = eval($constructor); + if(isset($this->record[$field])) $fieldObj->setValue($this->record[$field], $this->record); + $this->record[$field] = $fieldObj; + + return $this->record[$field]; + } + return isset($this->record[$field]) ? $this->record[$field] : null; } @@ -1293,28 +1324,36 @@ class DataObject extends ViewableData implements DataObjectInterface { * @param mixed $val New field value */ function setField($fieldName, $val) { - $defaults = $this->stat('defaults'); - // if a field is not existing or has strictly changed - if(!isset($this->record[$fieldName]) || $this->record[$fieldName] !== $val) { - // TODO Add check for php-level defaults which are not set in the db - // TODO Add check for hidden input-fields (readonly) which are not set in the db - if( - // Only existing fields - $this->fieldExists($fieldName) - // Catches "0"==NULL - && (isset($this->record[$fieldName]) && (intval($val) != intval($this->record[$fieldName]))) - // Main non type-based check - && (isset($this->record[$fieldName]) && $this->record[$fieldName] != $val) - ) { - // Non-strict check fails, so value really changed, e.g. "abc" != "cde" - $this->changed[$fieldName] = 2; - } else { - // Record change-level 1 if only the type changed, e.g. 0 !== NULL - $this->changed[$fieldName] = 1; - } - - // value is always saved back when strict check succeeds + // Situation 1: Passing a DBField + if($val instanceof DBField) { + $val->Name = $fieldName; $this->record[$fieldName] = $val; + + // Situation 2: Passing a literal + } else { + $defaults = $this->stat('defaults'); + // if a field is not existing or has strictly changed + if(!isset($this->record[$fieldName]) || $this->record[$fieldName] !== $val) { + // TODO Add check for php-level defaults which are not set in the db + // TODO Add check for hidden input-fields (readonly) which are not set in the db + if( + // Only existing fields + $this->fieldExists($fieldName) + // Catches "0"==NULL + && (isset($this->record[$fieldName]) && (intval($val) != intval($this->record[$fieldName]))) + // Main non type-based check + && (isset($this->record[$fieldName]) && $this->record[$fieldName] != $val) + ) { + // Non-strict check fails, so value really changed, e.g. "abc" != "cde" + $this->changed[$fieldName] = 2; + } else { + // Record change-level 1 if only the type changed, e.g. 0 !== NULL + $this->changed[$fieldName] = 1; + } + + // value is always saved back when strict check succeeds + $this->record[$fieldName] = $val; + } } } @@ -1334,7 +1373,7 @@ class DataObject extends ViewableData implements DataObjectInterface { $castingHelper = $this->castingHelper($fieldName); if($castingHelper) { $fieldObj = eval($castingHelper); - $fieldObj->setVal($val); + $fieldObj->setValue($val); $fieldObj->saveInto($this); } else { $this->$fieldName = $val; @@ -1349,7 +1388,7 @@ class DataObject extends ViewableData implements DataObjectInterface { * @return boolean True if the given field exists */ public function hasField($field) { - return array_key_exists($field, $this->record); + return array_key_exists($field, $this->record) || $this->fieldExists($field); } /** @@ -1486,14 +1525,17 @@ class DataObject extends ViewableData implements DataObjectInterface { * @return DBField The field as a DBField object */ public function dbObject($fieldName) { + return $this->obj($fieldName); + /* $helperPair = $this->castingHelperPair($fieldName); $constructor = $helperPair['castingHelper']; if($obj = eval($constructor)) { - $obj->setVal($this->$fieldName, $this->record); + $obj->setValue($this->$fieldName, $this->record); } return $obj; + */ } /** @@ -1560,18 +1602,34 @@ class DataObject extends ViewableData implements DataObjectInterface { // Build our intial query $query = new SQLQuery($select, "`$baseClass`", $filter, $sort); + // Add SQL for multi-value fields on the base table + $databaseFields = $this->databaseFields(); + if($databaseFields) foreach($databaseFields as $k => $v) { + if(!in_array($k, array('ClassName', 'LastEdited', 'Created'))) { + if(ClassInfo::classImplements($v, 'CompositeDBField')) { + $this->obj($k)->addToQuery($query); + } + } + } + // Join all the tables if($tableClasses) { foreach($tableClasses as $tableClass) { $query->from[$tableClass] = "LEFT JOIN `$tableClass` ON `$tableClass`.ID = `$baseClass`.ID"; $query->select[] = "`$tableClass`.*"; - // ask each $db field on the specific table for alterations to the query - $uninheritedDbFields = singleton($tableClass)->uninherited('db',true); - if($uninheritedDbFields) foreach($uninheritedDbFields as $fieldName => $fieldType) { - singleton($tableClass)->obj($fieldName)->addToQuery($query); + + // Add SQL for multi-value fields + $SNG = singleton($tableClass); + foreach($SNG->databaseFields() as $k => $v) { + if(!in_array($k, array('ClassName', 'LastEdited', 'Created'))) { + if(ClassInfo::classImplements($v, 'CompositeDBField')) { + $SNG->obj($k)->addToQuery($query); + } + } } } } + $query->select[] = "`$baseClass`.ID"; $query->select[] = "if(`$baseClass`.ClassName,`$baseClass`.ClassName,'$baseClass') AS RecordClassName"; diff --git a/core/model/fieldtypes/DBField.php b/core/model/fieldtypes/DBField.php index ead6c510e..9d24a7081 100644 --- a/core/model/fieldtypes/DBField.php +++ b/core/model/fieldtypes/DBField.php @@ -1,8 +1,14 @@ Multi-value DBField objects + * Sometimes you will want to make DBField classes that don't have a 1-1 match to database fields. To do this, there are a + * number of fields for you to overload. + * - Overload {@link writeToManipulation} to add the appropriate references to the INSERT or UPDATE command + * - Overload {@link addToQuery} to add the appropriate items to a SELECT query's field list + * - Add appropriate accessor methods * * @package sapphire * @subpackage model @@ -38,8 +44,30 @@ abstract class DBField extends ViewableData { return $dbField; } + /** + * @deprecated + */ function setVal($value, $record = null) { - return $this->setValue($value); + return $this->setValue($value, $record); + } + + /** + * Set the name of this field. + * The name should never be altered, but it if was never given a name in the first place you can set a name. + * If you try an alter the name a warning will be thrown. + */ + function setName($name) { + if($this->name) { + user_error("DBField::setName() shouldn't be called once a DBField already has a name. It's partially immutable - it shouldn't be altered after it's given a value.", E_USER_WARNING); + } + $this->name = $name; + } + + /** + * Returns the name of this field + */ + function getName() { + return $this->name; } /** @@ -92,7 +120,9 @@ abstract class DBField extends ViewableData { * * @param Query $query */ - function addToQuery(&$query) {} + function addToQuery(&$query) { + + } function setTable($tableName) { $this->tableName = $tableName; diff --git a/search/SearchContext.php b/search/SearchContext.php index 2ffec4303..452fe2551 100644 --- a/search/SearchContext.php +++ b/search/SearchContext.php @@ -91,7 +91,6 @@ class SearchContext extends Object { public function getQuery($searchParams) { $q = new SQLQuery("*", $this->modelClass); $this->processFilters($q); - return $q; } @@ -162,7 +161,8 @@ class SearchContext extends Object { $fields = array_filter($fields, array($this,'clearEmptySearchFields')); $length = count($fields); foreach($fields as $key=>$val) { - if ($val != '') { + // Array values come from more complex fields - for now let's just disable searching on them + if (!is_array($val) && $val != '') { $filter .= "`$key`='$val'"; } else { $length--;