├── .editorconfig ├── .scrutinizer.yml ├── CHANGELOG.md ├── CONTRIBUTING.md ├── README.md ├── _config └── config.yml ├── code └── Silvergraph.php ├── composer.json ├── doc └── SilverGraph_example__location=cms,framework,mysite.png └── templates └── Silvergraph.ss /.editorconfig: -------------------------------------------------------------------------------- 1 | # For more information about the properties used in this file, 2 | # please see the EditorConfig documentation: 3 | # http://editorconfig.org 4 | 5 | [*] 6 | charset = utf-8 7 | end_of_line = lf 8 | indent_size = 4 9 | indent_style = space 10 | insert_final_newline = true 11 | trim_trailing_whitespace = true 12 | 13 | [{*.yml,package.json}] 14 | indent_size = 2 15 | 16 | # The indent size used in the package.json file cannot be changed: 17 | # https://github.com/npm/npm/pull/3180#issuecomment-16336516 18 | -------------------------------------------------------------------------------- /.scrutinizer.yml: -------------------------------------------------------------------------------- 1 | inherit: true 2 | 3 | checks: 4 | php: 5 | verify_property_names: true 6 | verify_argument_usable_as_reference: true 7 | verify_access_scope_valid: true 8 | useless_calls: true 9 | use_statement_alias_conflict: true 10 | variable_existence: true 11 | unused_variables: true 12 | unused_properties: true 13 | unused_parameters: true 14 | unused_methods: true 15 | unreachable_code: true 16 | too_many_arguments: true 17 | sql_injection_vulnerabilities: true 18 | simplify_boolean_return: true 19 | side_effects_or_types: true 20 | security_vulnerabilities: true 21 | return_doc_comments: true 22 | return_doc_comment_if_not_inferrable: true 23 | require_scope_for_properties: true 24 | require_scope_for_methods: true 25 | require_php_tag_first: true 26 | psr2_switch_declaration: true 27 | psr2_class_declaration: true 28 | property_assignments: true 29 | prefer_while_loop_over_for_loop: true 30 | precedence_mistakes: true 31 | precedence_in_conditions: true 32 | phpunit_assertions: true 33 | php5_style_constructor: true 34 | parse_doc_comments: true 35 | parameter_non_unique: true 36 | parameter_doc_comments: true 37 | param_doc_comment_if_not_inferrable: true 38 | optional_parameters_at_the_end: true 39 | one_class_per_file: true 40 | no_unnecessary_if: true 41 | no_trailing_whitespace: true 42 | no_property_on_interface: true 43 | no_non_implemented_abstract_methods: true 44 | no_error_suppression: true 45 | no_duplicate_arguments: true 46 | no_commented_out_code: true 47 | newline_at_end_of_file: true 48 | missing_arguments: true 49 | method_calls_on_non_object: true 50 | instanceof_class_exists: true 51 | foreach_traversable: true 52 | fix_line_ending: true 53 | fix_doc_comments: true 54 | duplication: true 55 | deprecated_code_usage: true 56 | deadlock_detection_in_loops: true 57 | code_rating: true 58 | closure_use_not_conflicting: true 59 | catch_class_exists: true 60 | blank_line_after_namespace_declaration: false 61 | avoid_multiple_statements_on_same_line: true 62 | avoid_duplicate_types: true 63 | avoid_conflicting_incrementers: true 64 | avoid_closing_tag: true 65 | assignment_of_null_return: true 66 | argument_type_checks: true 67 | 68 | filter: 69 | paths: [code/*, tests/*] 70 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | 0.0.1 2 | * Initial version 3 | 4 | 0.0.2 5 | * Added both png and svg support 6 | * Cocumentation improvements 7 | 8 | 0.0.3 9 | * Better many_many, has_many support 10 | * Added rankdir to allow specififying how graph is laid out 11 | * Bugfix for source file comments (oddnoc) 12 | * Show many_many_extraFields as an intermediary table (edlinklater) 13 | * Better docs for install graphviz on osx (joshkosmala, anselmdk) 14 | 15 | 0.0.4 16 | * Final SilverStripe 3.x compability version 17 | * Docs updated -------------------------------------------------------------------------------- /CONTRIBUTING.md: -------------------------------------------------------------------------------- 1 | ## Contributing 2 | 3 | Fork on Github and use pull requests, that is all! :) 4 | 5 | ## TO DO 6 | 7 | * Convert to PSR-2 8 | * Better default styling/colours of the graph 9 | * Less verbose option for relations, eg; combining has_one, has_many paths on the same path 10 | * Better error handling from dot -> png, if error in dot format -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | SilverGraph 2 | =========== 3 | 4 | Creates data model visualisations of SilverStripe DataObjects, showing fields, relations and ancestry. 5 | Can output images in .png, .svg and raw GraphViz "dot" format. 6 | Flexible configuration options and can be called from command line and URL. 7 | 8 | ![SilverGraph example](https://raw.github.com/froog/SilverGraph/master/doc/SilverGraph_example__location=cms,framework,mysite.png) 9 | 10 | _Example call: http://example.com/Silvergraph/png?location=cms,framework,mysite_ 11 | 12 | ## Installation 13 | * Composer/Packagist: Install composer, run `composer require froog/silvergraph` (* for version) and visit `?flush=1` to update the routing table. 14 | * Manual: Download and extract silvergraph as `SilverGraph` folder in the top level of your site and visit `?flush=1` to update the routing table. 15 | 16 | ### Installation on OSX 17 | 18 | * Install Graphviz via Homebrew: `brew install graphviz` and note down the location 19 | * Add the location to your `_ss_environment.php` file, postpending 'dot' to the end of the path, e.g.: 20 | `define('SILVERGRAPH_GRAPHVIZ_PATH', '/usr/local/Cellar/graphviz/2.38.0/bin/dot');` 21 | * Visit `?flush=1` to update the routing table. 22 | 23 | ## Requirements 24 | * SilverStripe 3.0.0+ 25 | * To create images: GraphViz (latest version) http://www.graphviz.org/ 26 | * To install (Debian/Ubuntu): `apt-get install graphviz` 27 | 28 | ## Usage 29 | 30 | ### Command line: (in site root) 31 | 32 | * Default png image: `sake Silvergraph/png > datamodel.png` 33 | * Parameters: `sake Silvergraph/png location=mysite,cms inherited=1 exclude=SiteTree > datamodel.png` 34 | * Default dot file: `sake Silvergraph/dot > datamodel.dot` 35 | 36 | ### Browser: (logged in as admin) 37 | 38 | * Default png image: http://example.com/Silvergraph/png 39 | * Parameters: http://example.com/Silvergraph/png?location=mysite,cms&inherited=1&exclude=SiteTree 40 | * Default dot file: http://example.com/Silvergraph/dot 41 | 42 | ### Parameters 43 | 44 | #### Specify the folder to look for classes under 45 | * `location=mysite` _(default)_ Only graph classes under the /mysite folder 46 | * `location=/` Graph ALL classes in every module (warning - may take a long time and could generate a large .png) 47 | * `location=mysite,mymodule` Only graph classes under /mysite and /mymodule folders 48 | 49 | #### Remove specific classes from the graph 50 | * `exclude=SiteTree` 51 | * `exclude=SiteTree,File` 52 | 53 | #### How verbosely to show relations 54 | * `relations=0` Don't show any relations 55 | * `relations=1` _(default)_ Don't show inherited relations 56 | * `relations=2` Show inherited relations (verbose) 57 | 58 | #### How verbosely to show fields 59 | * `fields=0` Don't show any fields 60 | * `fields=1` _(default)_ Show only fields defined on self 61 | * `fields=2` Show inherited fields (verbose) 62 | 63 | #### How verbosely to show ancestors 64 | * `ancestry=0` Don't show any ancestry relations 65 | * `ancestry=1` _(default)_ Show ancestry relations 66 | 67 | #### Include DataObject on the graph 68 | * `include-root=0` _(default)_ Don't graph DataObject 69 | * `include-root=1` Graph DataObject 70 | 71 | #### Group classes by modules 72 | * `group=0` _(default)_ Don't group by modules 73 | * `group=1` Group the modules into their own container 74 | 75 | #### Specify direction graph is laid out 76 | * `rankdir=x` Where x is `TB` _(default)_ ,`LR`,`RL`, or `BT` (top-bottom, left-right, right-left, bottom-top) 77 | -------------------------------------------------------------------------------- /_config/config.yml: -------------------------------------------------------------------------------- 1 | SilverStripe\Control\Director: 2 | rules: 3 | 'Silvergraph//$Action' : 'Silvergraph' 4 | -------------------------------------------------------------------------------- /code/Silvergraph.php: -------------------------------------------------------------------------------- 1 | request->getVar($param); 34 | if (($type == "string" && empty($value)) || 35 | ($type == "numeric" && !is_numeric($value))) { 36 | $value= $default; 37 | } 38 | return $value; 39 | } 40 | 41 | /** 42 | * Generates a GraphViz dot template 43 | * 44 | * @return String a dot compatible data format 45 | */ 46 | public function dot(){ 47 | 48 | $opt = array(); 49 | 50 | $opt['location'] = $this->paramDefault('location', 'mysite'); 51 | $opt['ancestry'] = $this->paramDefault('ancestry', 1, 'numeric'); 52 | $opt['relations'] = $this->paramDefault('relations', 1, 'numeric'); 53 | $opt['fields'] = $this->paramDefault('fields', 1, 'numeric'); 54 | $opt['include_root'] = $this->paramDefault('include-root', 0, 'numeric'); 55 | $opt['exclude'] = $this->paramDefault('exclude'); 56 | $opt['group'] = $this->paramDefault('group', 0, 'numeric'); 57 | $opt['rankdir'] = $this->paramDefault('rankdir'); 58 | 59 | if (!in_array($opt['rankdir'], array('LR', 'TB', 'BT', 'RL'))) { 60 | $opt['rankdir'] = 'TB'; 61 | } 62 | 63 | $renderClasses = array(); 64 | 65 | //Get all DataObject subclasses 66 | $dataClasses = ClassInfo::subclassesFor(DataObject::class); 67 | 68 | //Remove DataObject itself 69 | array_shift($dataClasses); 70 | 71 | //Get all classes in a specific folder(s) 72 | $folders = explode(",", $opt['location']); 73 | $folderClasses = array(); 74 | foreach($folders as $folder) { 75 | if (!empty($folder)) { 76 | $folderClasses[$folder] = ClassInfo::classes_for_folder($folder); 77 | } 78 | } 79 | 80 | $excludeArray = explode(",", $opt['exclude']); 81 | 82 | //Get the intersection of the two - grouped by the folder 83 | foreach($dataClasses as $key => $dataClass) { 84 | foreach($folderClasses as $folder => $classList) { 85 | foreach($classList as $folderClass) { 86 | if (strtolower($dataClass) == strtolower($folderClass)) {; 87 | //Remove all excluded classes 88 | if (!in_array($dataClass, $excludeArray)) { 89 | $renderClasses[$folder][$dataClass] = $dataClass; 90 | } 91 | } 92 | } 93 | } 94 | } 95 | if (count($renderClasses) == 0) { 96 | user_error("No classes that extend DataObject found in location: " . Convert::raw2xml($opt['location'])); 97 | } 98 | 99 | $folders = new ArrayList(); 100 | 101 | foreach($renderClasses as $folderName => $classList) { 102 | 103 | $folder = new ArrayData(); 104 | $folder->Name = $folderName; 105 | $folder->Group = ($opt['group'] == 1); 106 | $classes = new ArrayList(); 107 | $schema = DataObject::getSchema(); 108 | 109 | foreach ($classList as $className) { 110 | $class = new ArrayData(); 111 | $class->ClassName = addslashes($className); 112 | $class->TableName = addslashes($schema->tableName($className)); 113 | 114 | //Get all the data fields for the class 115 | //fields = 0 - No fields 116 | //fields = 1 - only uninherited fields 117 | //fields = 2 - inherited fields 118 | 119 | if ($opt['fields'] > 0) { 120 | $dataFields = $schema->databaseFields($className, $opt['fields'] > 1); 121 | $class->FieldList = self::formatDataFields($dataFields); 122 | } 123 | 124 | if ($opt['relations'] > 1) { 125 | $config = Config::INHERITED; 126 | } else { 127 | $config = Config::UNINHERITED; 128 | } 129 | 130 | $hasOneArray = Config::inst()->get($className, 'has_one', $config); 131 | $hasManyArray = Config::inst()->get($className, 'has_many', $config); 132 | $manyManyArray = Config::inst()->get($className, 'many_many', $config); 133 | 134 | //TODO - what's the difference between: 135 | /* 136 | $hasOneArray = Config::inst()->get($className, 'has_one'); 137 | $hasManyArray = Config::inst()->get($className, 'has_many'); 138 | $manyManyArray = Config::inst()->get($className, 'many_many'); 139 | 140 | //and 141 | 142 | $hasOneArray = $singleton->has_one(); 143 | $hasManyArray = $singleton->has_many(); 144 | $manyManyArray = $singleton->many_many(); 145 | //Note - has_() calls are verbose - they retrieve relations all the way down to base class 146 | // ?? eg; for SiteTree, BackLinkTracking is a belongs_many_many 147 | */ 148 | 149 | //$belongsToArray = $singleton->belongs_to(); 150 | //print_r(ClassInfo::ancestry($className)); 151 | //print_r($singleton->getClassAncestry()); 152 | 153 | 154 | //Add parent class to HasOne 155 | //Remove the default "Parent" because thats the final parent, rather than the immediate parent 156 | unset($hasOneArray["Parent"]); 157 | $classAncestry = ClassInfo::ancestry($className); 158 | //getClassAncestry returns an array ordered from root to called class - to get parent, reverse and remove top element (called class) 159 | $classAncestry = array_reverse($classAncestry); 160 | array_shift($classAncestry); 161 | $parentClass = reset($classAncestry); 162 | $hasOneArray["Parent"] = $parentClass; 163 | 164 | //Ensure DataObject is not shown if include-root = 0 165 | if ($opt['include_root'] == 0 && $parentClass == DataObject::class) { 166 | unset($hasOneArray["Parent"]); 167 | } 168 | 169 | //if ancestry = 0, remove the "Parent" relation in has_one 170 | if ($opt['ancestry'] == 0 && isset($hasOneArray["Parent"])) { 171 | unset($hasOneArray["Parent"]); 172 | } 173 | 174 | //if relations = 0, remove all but the parent relation 175 | if ($opt['relations'] == 0) { 176 | $parent = isset($hasOneArray["Parent"]) ? $hasOneArray["Parent"] : null; 177 | if ($parent) { 178 | $hasOneArray = array(); 179 | $hasOneArray["Parent"] = $parent; 180 | } else { 181 | $hasOneArray = null; 182 | } 183 | 184 | $hasManyArray = null; 185 | $manyManyArray = null; 186 | } 187 | 188 | $class->HasOneList = self::relationObject($className, $hasOneArray, $excludeArray); 189 | $class->HasManyList = self::relationObject($className, $hasManyArray, $excludeArray); 190 | $class->ManyManyList = self::relationObject($className, $manyManyArray, $excludeArray); 191 | 192 | $classes->push($class); 193 | } 194 | 195 | $folder->Classes = $classes; 196 | $folders->push($folder); 197 | } 198 | 199 | $this->customise(array( 200 | "Rankdir" => $opt['rankdir'], 201 | "Folders" => $folders 202 | )); 203 | 204 | // Defend against source_file_comments 205 | Config::nest(); 206 | Config::inst()->update(SSViewer::class, 'source_file_comments', false); 207 | 208 | // Render the output 209 | $output = $this->renderWith("Silvergraph"); 210 | 211 | // Restore the original configuration 212 | Config::unnest(); 213 | 214 | //Set output as plain text, and strip excess empty lines 215 | $this->response->addHeader("Content-type", "text/plain"); 216 | $output= preg_replace("/(^[\r\n]*|[\r\n]+)[\s\t]*[\r\n]+/", "\n", $output); 217 | 218 | return $output; 219 | } 220 | 221 | public static function relationObject($className, $relationArray, $excludeArray) { 222 | $schema = DataObject::getSchema(); 223 | $relationList = new ArrayList(); 224 | if (is_array($relationArray)) { 225 | foreach($relationArray as $name => $remoteClass) { 226 | //Strip everything after a dot (polymorphic relations) 227 | $remoteClass = explode('.', $remoteClass)[0]; 228 | //Only add the relation if it's not in the exclusion array 229 | if (!in_array($remoteClass, $excludeArray)) { 230 | $relation = new ArrayData(); 231 | $relation->Name = $name; 232 | $relation->RemoteClass = addslashes($remoteClass); 233 | $manyMany = $schema->manyManyComponent($className, $name); 234 | if ($manyMany) { 235 | $extra = $schema->manyManyExtraFieldsForComponent($className, $name); 236 | $relation->Name = addslashes($manyMany['join']); 237 | $relation->ExtraFields = self::formatDataFields($extra); 238 | } 239 | $relationList->push($relation); 240 | } 241 | } 242 | } 243 | return $relationList; 244 | } 245 | 246 | public static function formatDataFields($dataFields) { 247 | $fields = new ArrayList(); 248 | 249 | if(!is_array($dataFields)) { 250 | return $fields; 251 | } 252 | 253 | foreach($dataFields as $fieldName => $dataType) { 254 | $field = new ArrayData(); 255 | $field->FieldName = $fieldName; 256 | 257 | //special case - Enums are too long - put new lines on commas 258 | if (strpos($dataType, "Enum") === 0) { 259 | $dataType = str_replace(",", ",\n", $dataType); 260 | } 261 | 262 | $field->DataType = $dataType; 263 | $fields->push($field); 264 | } 265 | 266 | return $fields; 267 | } 268 | 269 | /** Generate a png file from the dot template 270 | * 271 | */ 272 | public function png() { 273 | $dot = $this->dot(); 274 | $output = $this->execute("-Tpng", $dot); 275 | 276 | //Return the content as a png 277 | header('Content-type: image/png'); 278 | echo $output; 279 | } 280 | 281 | /** Generate a svg file from the dot template 282 | * 283 | */ 284 | public function svg() { 285 | $dot = $this->dot(); 286 | $output = $this->execute("-Tsvg", $dot); 287 | 288 | //Return the content as a svg 289 | header('Content-type: image/svg+xml'); 290 | echo $output; 291 | } 292 | 293 | /** Execute the dot command wih $parameters, passing in $input to stdin. Returns stdout as $output 294 | * NOTE: Requires graphviz & dot to be installed locally 295 | * (eg; apt-get install graphviz) 296 | * 297 | */ 298 | private function execute($parameters, $input) { 299 | // Prepend the path to the dot command, if explicitely defined 300 | $cmd = Environment::getEnv('SILVERGRAPH_GRAPHVIZ_PATH'); 301 | if ($cmd === false) { 302 | $cmd = ''; 303 | } 304 | $cmd .= 'dot ' . $parameters; 305 | 306 | //Execute the dot command on the local machine. 307 | //Using pipes as per the example here: http://php.net/manual/en/function.proc-open.php 308 | $descriptorspec = array( 309 | 0 => array("pipe", "r"), // stdin is a pipe that the child will read from 310 | 1 => array("pipe", "w"), // stdout is a pipe that the child will write to 311 | 2 => array("pipe", "w") // stdout is a pipe that the child will write to 312 | ); 313 | 314 | $process = proc_open($cmd, $descriptorspec, $pipes); 315 | 316 | if (is_resource($process)) { 317 | // $pipes now looks like this: 318 | // 0 => writeable handle connected to child stdin 319 | // 1 => readable handle connected to child stdout 320 | 321 | fwrite($pipes[0], $input); 322 | fclose($pipes[0]); 323 | 324 | $output = stream_get_contents($pipes[1]); 325 | $error = stream_get_contents($pipes[2]); 326 | fclose($pipes[1]); 327 | 328 | // It is important that you close any pipes before calling 329 | // proc_close in order to avoid a deadlock 330 | $return_value = proc_close($process); 331 | 332 | if (!empty($error)) { 333 | user_error("Couldn't execute dot command, ensure graphviz is installed and 'dot' postpended to your graphviz path. See README.md. Shell error: $error"); 334 | } 335 | 336 | return $output; 337 | } 338 | } 339 | } 340 | -------------------------------------------------------------------------------- /composer.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "froog/silvergraph", 3 | "description": "Generates data model visualisations from SilverSripe DataObjects, displaying database fields, relations and ancestry", 4 | "type": "silverstripe-vendormodule", 5 | "keywords": ["silverstripe", "permissions", "member"], 6 | "license": "BSD-3-Clause", 7 | "authors": [ 8 | { 9 | "name": "Daniel Pickering", 10 | "email": "froogalicious@gmail.com" 11 | } 12 | ], 13 | 14 | "require": 15 | { 16 | "silverstripe/framework": "^4" 17 | } 18 | } 19 | -------------------------------------------------------------------------------- /doc/SilverGraph_example__location=cms,framework,mysite.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/froog/SilverGraph/97cca129c45e4681140a88b401b00fe359d2a7b8/doc/SilverGraph_example__location=cms,framework,mysite.png -------------------------------------------------------------------------------- /templates/Silvergraph.ss: -------------------------------------------------------------------------------- 1 | digraph g { 2 | graph [ 3 | rankdir = "$Rankdir" 4 | ]; 5 | node [ 6 | fontsize = "16" 7 | shape = "none" 8 | margin = "0" 9 | ]; 10 | edge [ 11 | ]; 12 | <% loop Folders %> 13 | <% if Group %>subgraph cluster_$Name {<% end_if %> 14 | color=blue; 15 | label = "$Name"; 16 | <% loop Classes %> 17 | "$ClassName" [ 18 | label = < 19 | 20 | 21 | <% if FieldList %> 22 | <% loop FieldList %> 23 | 24 | 25 | 26 | 27 | <% end_loop %> 28 | <% end_if %> 29 |
$TableName
$FieldName$DataType
30 | > 31 | ]; 32 | <% loop ManyManyList %> 33 | <% if ExtraFields %> 34 | "$Name" [ 35 | label = < 36 | 37 | 38 | <% loop ExtraFields %> 39 | 40 | 41 | 42 | 43 | <% end_loop %> 44 |
$Name (many_many)
$FieldName$DataType
45 | > 46 | ]; 47 | <% end_if %> 48 | <% end_loop %> 49 | <% end_loop %> 50 | <% if Group %>}<% end_if %> 51 | <% end_loop %> 52 | 53 | <% loop $Folders %> 54 | <% loop $Classes %> 55 | <% if $HasOneList %> 56 | <% loop $HasOneList %> 57 | <%-- special case for Parent, replace with "extends" --%> 58 | <% if $Name == "Parent" %> 59 | "$Up.ClassName" -> "$RemoteClass"[label="extends" style="dotted"]; 60 | <% else %> 61 | "$Up.ClassName" -> "$RemoteClass"[label="$Name (has_one)"]; 62 | <% end_if %> 63 | <% end_loop %> 64 | <% end_if %> 65 | 66 | <% if $HasManyList %> 67 | <% loop $HasManyList %> 68 | "$Up.ClassName" -> "$RemoteClass"[label="$Name (has_many)"]; 69 | <% end_loop %> 70 | <% end_if %> 71 | 72 | <% if $ManyManyList %> 73 | <% loop $ManyManyList %> 74 | <% if $ExtraFields %> 75 | "$Name" -> "$RemoteClass"; 76 | "$Name" -> "$Up.ClassName"; 77 | <% else %> 78 | "$Up.ClassName" -> "$RemoteClass"[label="$Name (many_many)" dir=both]; 79 | <% end_if %> 80 | <% end_loop %> 81 | <% end_if %> 82 | <% end_loop %> 83 | <% end_loop %> 84 | } 85 | --------------------------------------------------------------------------------