diff --git a/scripts/model_definitions_to_gv.js b/scripts/model_definitions_to_gv.js new file mode 100644 index 0000000..3bfec3b --- /dev/null +++ b/scripts/model_definitions_to_gv.js @@ -0,0 +1,269 @@ +/** + * ----------------------------------------------------------------------- + * model_definitions_to_gv.js + * + * DESCRIPTION + * utility node script to create a dot readable graphviz (.gv) file, representing a graph visual representation of models found in a data model definitions folder. + * The .gv output will be written to STDOUT + * + * USAGE + * node model_definitions_to_gv path_to_model_folder > out.gv + * + * ARGUMENTS + * argv[0] - path to folder + * argv[1] - set to "printFK" to print the foreignKeys. Everything else won't print the foreign keys. + * argv[2] - optional String representation of a desired order in the graph output. By default the order is depending on the folder itself. + * for example: + * node model_definitions_to_gv path_to_model_folder "model2,model1,model3,..." + * + * CONVERT TO PDF + * make sure to install graphviz (https://graphviz.org/download/) + * + * dot out.gv -Tpdf -o out.pdf + * + */ + +const fs = require("fs"); +const path = require("path"); + +const argv = process.argv.slice(2); + +/** + * Configuration of the graph + */ +const graphConfig = + "digraph hierarchy {\n \ +node[shape=record,style=filled,fillcolor=gray95, fontname=Courier, fontsize=15]\n \ +graph [splines=ortho]\n \ +edge[arrowsize=1.5, style=bold]\n \ +ranksep=0.5\n \ +nodesep=1\n \ +esep=0.1\n"; + +// globals +let associations = {}; +let parsedAssociations = []; +let attributes = {}; +let fKattributes = {}; +let idattributes = {}; +let longestAttribute = {}; + +/** + * run + * + * main function + */ +function run() { + if (argv.length < 1) { + console.error( + "Please provide the path to your data model definitions folder" + ); + process.exit(1); + } + const printFK = argv[1] === "printFK" ? true : false; + + fs.readdirSync(argv[0]) + .filter(function (file) { + return ( + file.indexOf(".") !== 0 && + file.slice(-5) === ".json" && + !file.includes("_to_") + ); + }) + .forEach(function (file) { + let json = require(path.relative(__dirname, path.join(argv[0], file))); + parseModel(json, printFK); + }); + + process.stdout.write(graphConfig); + createNodes(attributes, printFK); + createEdges(parsedAssociations); + process.stdout.write("}"); + + // console.log(JSON.stringify(fKattributes,null,2)); +} + +/** + * parse the json input of a model and write to the global variables + * @param {JSON} json the json input for a model definition + * @param {boolean} printFK + */ +function parseModel(json, printFK) { + attributes[json["model"]] = {}; + associations[json["model"]] = {}; + fKattributes[json["model"]] = []; + + Object.assign(attributes[json["model"]], json["attributes"]); + Object.assign(associations[json["model"]], json["associations"]); + + idattributes[json["model"]] = json["internalId"] ? json["internalId"] : "id"; + longestAttribute[json["model"]] = json["model"].length; + + if (!json["internalId"]) { + attributes[json["model"]].id = "Int"; + } + + if (json["associations"] !== undefined) { + Object.keys(json["associations"]).forEach((assocName) => { + let association = json["associations"][assocName]; + + if (association["keysIn"] === json["model"]) { + if ( + association["type"] === "many_to_many" && + association["implementation"] === "foreignkeys" + ) { + fKattributes[json["model"]].push(association["sourceKey"]); + } else { + fKattributes[json["model"]].push(association["targetKey"]); + } + } + if ( + parsedAssociations.find( + (assoc) => + association.reverseAssociation === assoc.name && + association.target === assoc.model + ) + ) { + return; + } else { + parsedAssociations.push({ + model: json["model"], + name: assocName, + target: association["target"], + type: association["type"], + }); + } + }); + } + + Object.keys(attributes[json["model"]]).forEach((attr) => { + const validAttr = printFK + ? true + : !fKattributes[json["model"]].includes(attr); + if ( + validAttr && + attr.length + attributes[json["model"]][attr].length > + longestAttribute[json["model"]] + ) { + longestAttribute[json["model"]] = + attr.length + attributes[json["model"]][attr].length; + } + }); +} + +/** + * create the .gv Nodes for the output graph + * @param {object} attributes + * @param {boolean} printFK + */ +function createNodes(attributes, printFK) { + const order = argv[2] + ? argv[2].split(",").reduce((a, c) => ((a[c] = ""), a), {}) + : attributes; + Object.keys(order).forEach((model) => { + let sortedAttributes = [idattributes[model]]; + process.stdout.write( + ` ${model} [label = < {${model[0].toUpperCase()}${model.slice(1)}|` + ); + + Object.keys(attributes[model]).forEach((attr) => { + if (idattributes[model] !== attr && !fKattributes[model].includes(attr)) { + sortedAttributes.push(attr); + } + }); + + sortedAttributes.forEach((attr) => { + const typeLength = attributes[model][attr].length; + let spaces = calculateSpaces( + attr.length + typeLength, + longestAttribute[model] + ); + + if (idattributes[model] === attr) { + process.stdout.write(`${attr}`); + process.stdout.write(`${" ".repeat(spaces)}`); + process.stdout.write( + `${attributes[model][attr]}
` + ); + } else { + process.stdout.write(`${attr}`); + process.stdout.write(`${" ".repeat(spaces)}`); + process.stdout.write( + `${attributes[model][attr]}
` + ); + } + }); + + if (printFK) { + fKattributes[model].forEach((fK) => { + let spaces = calculateSpaces( + fK.length + attributes[model][fK].length, + longestAttribute[model] + ); + + process.stdout.write(`${fK}`); + process.stdout.write(`${" ".repeat(spaces)}`); + process.stdout.write( + `${attributes[model][fK]}
` + ); + }); + } + process.stdout.write(`}>]\n`); + }); +} + +/** + * create the .gv edges for the output graph + * @param {object} associations + */ +function createEdges(parsedAssociations) { + parsedAssociations.forEach((assoc) => { + const relation = translateRelation(assoc); + process.stdout.write( + ` ${assoc.model} -> ${assoc.target} [minlen=2 color=navy headlabel=${relation[1]} taillabel=${relation[0]} labeldistance=2 arrowhead=none lp=5]\n` + // ` ${assoc.model} -> ${assoc.target} [dir=both minlen=2 color=navy arrowhead=${relation[1]} arrowtail=${relation[0]}]\n` + ); + }); +} + +function translateRelation(relation) { + switch (relation.type) { + case "one_to_one": + return ["1", "1"]; + case "one_to_many": + return ["1", "n"]; + case "many_to_one": + return ["n", "1"]; + case "many_to_many": + return ["n", "m"]; + default: + break; + } +} + +function translateRelation2(relation) { + switch (relation.type) { + case "one_to_one": + return ["dot", "dot"]; + case "one_to_many": + return ["dot", "inv"]; + case "many_to_one": + return ["inv", "dot"]; + case "many_to_many": + return ["inv", "inv"]; + default: + break; + } +} + +/** + * calculate the number of spaces needed for each attribute + type to be lined up correctly in the graph + * @param {int} length + * @param {int} maxLength + */ +function calculateSpaces(length, maxLength) { + let longestSpace = maxLength + 4; + return longestSpace - length; +} + +run();