├── .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 | 
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 | $TableName |
21 | <% if FieldList %>
22 | <% loop FieldList %>
23 |
24 | $FieldName |
25 | $DataType |
26 |
27 | <% end_loop %>
28 | <% end_if %>
29 |
30 | >
31 | ];
32 | <% loop ManyManyList %>
33 | <% if ExtraFields %>
34 | "$Name" [
35 | label = <
36 |
37 | $Name (many_many) |
38 | <% loop ExtraFields %>
39 |
40 | $FieldName |
41 | $DataType |
42 |
43 | <% end_loop %>
44 |
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 |
--------------------------------------------------------------------------------