Repository: julioz/FloorPlan (kotlin)

Issue:
Optionally render nullable type
Instead of relying on the `field note` feature in DBML, we can also allow for static rendering of the ER diagrams in combination with nullable types by appending a marker to the type definition.

[According to DBML's schema](https://www.dbml.org/docs/#table-definition), we can append a `(?)` text suffix to the data types when they are nullable.

So, for example, when a command line argument is passed, FloorPlan can render
```
varchar(?)
```
for a nullable `TEXT` Room field.

<img width="320" alt="Screenshot 2020-04-07 at 13 45 05" src="https://user-images.githubusercontent.com/1541701/78665400-0aef2280-78d6-11ea-9bde-19c2db448af8.png">

Pull request:
Fixes #12 

See tivi's complex schema with the option enabled:
<img width="1015" alt="Screenshot 2020-04-11 at 18 26 11" src="https://user-images.githubusercontent.com/1541701/79049191-567d3580-7c22-11ea-9cd8-670cd713863d.png">

Patch:
diff --git a/README.md b/README.md
index f79aeb5..d2a4c98 100644
--- a/README.md
+++ b/README.md
@@ -31,6 +31,11 @@ gradlew run --args="<path-to-schema-file> --output=<path-to-output-file>"
 gradlew run --args="<path-to-schema-file> --creation-sql-as-table-note"
 ```
 
+- render nullable fields by [appending `(?)` to their data type](https://github.com/julioz/FloorPlan/issues/12). Disabled by default, this option can be enabled by specifying:
+```
+gradlew run --args="<path-to-schema-file> --render-nullable-fields"
+```
+
 ### Tests
 
 The project is unit tested. These can be run via Gradle with:
diff --git a/src/main/kotlin/com/zynger/floorplan/App.kt b/src/main/kotlin/com/zynger/floorplan/App.kt
index 47efd52..214ccc6 100644
--- a/src/main/kotlin/com/zynger/floorplan/App.kt
+++ b/src/main/kotlin/com/zynger/floorplan/App.kt
@@ -11,7 +11,7 @@ fun main(args: Array<String>) {
     val src = File(input.schemaPath)
     val json = Json(JsonConfiguration.Stable.copy(ignoreUnknownKeys = true, isLenient = true))
 
-    val settings = Settings(input.creationSqlAsTableNote)
+    val settings = Settings(input.creationSqlAsTableNote, input.renderNullableFields)
     val dbml = json
         .parse(Schema.serializer(), src.readText())
         .database
diff --git a/src/main/kotlin/com/zynger/floorplan/InputParser.kt b/src/main/kotlin/com/zynger/floorplan/InputParser.kt
index 8f95c54..39a656a 100644
--- a/src/main/kotlin/com/zynger/floorplan/InputParser.kt
+++ b/src/main/kotlin/com/zynger/floorplan/InputParser.kt
@@ -7,11 +7,13 @@ object InputParser {
     data class Input(
         val schemaPath: String,
         val outputPath: String?,
-        val creationSqlAsTableNote: Boolean
+        val creationSqlAsTableNote: Boolean,
+        val renderNullableFields: Boolean
     )
 
     private const val OUTPUT_ARG_KEY: String = "output"
     private const val CREATION_SQL_AS_TABLE_NOTES_ARG_KEY: String = "creation-sql-as-table-note"
+    private const val RENDER_NULLABLE_FIELDS_ARG_KEY: String = "render-nullable-fields"
 
     fun parse(args: Array<String>): Input {
         require(args.isNotEmpty()) {
@@ -22,15 +24,16 @@ object InputParser {
             * output: specify an output file for the DBML representation.
             * creation-sql-as-table-note: adds the SQL used to create tables as notes in their DBML representation.
             
-            e.g.: gradlew run --args="<path-to-schema-file> [--output=<output-file-path>] [--creation-sql-as-table-note]"
+            e.g.: gradlew run --args="<path-to-schema-file> [--output=<output-file-path>] [--creation-sql-as-table-note] [--render-nullable-fields]"
         """.trimIndent()
         }
 
         val inputFilePath: String = sanitizeFilePath(args.first())
         val outputFilePath: String? = args.getArgumentValue(OUTPUT_ARG_KEY)?.let { sanitizeFilePath(it) }
         val noteCreationSql = args.argumentExists(CREATION_SQL_AS_TABLE_NOTES_ARG_KEY)
+        val renderNullableFields = args.argumentExists(RENDER_NULLABLE_FIELDS_ARG_KEY)
 
-        return Input(inputFilePath, outputFilePath, noteCreationSql)
+        return Input(inputFilePath, outputFilePath, noteCreationSql, renderNullableFields)
     }
 
     private fun Array<String>.getArgumentValue(argKey: String): String? {
diff --git a/src/main/kotlin/com/zynger/floorplan/Settings.kt b/src/main/kotlin/com/zynger/floorplan/Settings.kt
index 6e75678..ee547d4 100644
--- a/src/main/kotlin/com/zynger/floorplan/Settings.kt
+++ b/src/main/kotlin/com/zynger/floorplan/Settings.kt
@@ -1,5 +1,6 @@
 package com.zynger.floorplan
 
 data class Settings(
-    val creationSqlAsTableNote: Boolean
+    val creationSqlAsTableNote: Boolean = false,
+    val renderNullableFields: Boolean = false
 )
\ No newline at end of file
diff --git a/src/main/kotlin/com/zynger/floorplan/dbml/Field.kt b/src/main/kotlin/com/zynger/floorplan/dbml/Field.kt
index 03a2467..98678f9 100644
--- a/src/main/kotlin/com/zynger/floorplan/dbml/Field.kt
+++ b/src/main/kotlin/com/zynger/floorplan/dbml/Field.kt
@@ -1,13 +1,13 @@
 package com.zynger.floorplan.dbml
 
-import com.zynger.floorplan.model.Field as DbField
+import com.zynger.floorplan.Settings
 import com.zynger.floorplan.model.PrimaryKey
-import java.lang.IllegalArgumentException
-import java.lang.StringBuilder
+import com.zynger.floorplan.model.Field as DbField
 
 class Field(
     private val field: DbField,
-    private val tablePrimaryKey: PrimaryKey
+    private val tablePrimaryKey: PrimaryKey,
+    private val settings: Settings
 ) {
     private val isPrimaryKey: Boolean
         get() = tablePrimaryKey.columnNames.any { it == this.field.columnName }
@@ -19,6 +19,11 @@ class Field(
         return StringBuilder(field.columnName)
             .append(" ")
             .append(type)
+            .apply {
+                if (settings.renderNullableFields && field.nullable) {
+                    append("(?)")
+                }
+            }
             .apply {
                 append(" ")
                 append("[")
@@ -65,4 +70,7 @@ class Field(
             }
         }
     }
+
+    private val DbField.nullable: Boolean
+        get() = !notNull
 }
diff --git a/src/main/kotlin/com/zynger/floorplan/dbml/Table.kt b/src/main/kotlin/com/zynger/floorplan/dbml/Table.kt
index 6442a41..061f7f7 100644
--- a/src/main/kotlin/com/zynger/floorplan/dbml/Table.kt
+++ b/src/main/kotlin/com/zynger/floorplan/dbml/Table.kt
@@ -8,7 +8,7 @@ class Table(
     private val settings: Settings
 ) {
     private val tableName: String = entity.tableName
-    private val fields: List<Field> = entity.fields.map { Field(it, entity.primaryKey) }
+    private val fields: List<Field> = entity.fields.map { Field(it, entity.primaryKey, settings) }
     private val indices: List<Index> = entity.indices.map { Index(it) }
     private val references: List<Reference> = entity.foreignKeys.map { Reference(tableName, it) }
     private val createSql: String = entity.createSql


Test patch:
diff --git a/src/test/kotlin/com/zynger/floorplan/InputParserTest.kt b/src/test/kotlin/com/zynger/floorplan/InputParserTest.kt
index 21a9494..4350152 100644
--- a/src/test/kotlin/com/zynger/floorplan/InputParserTest.kt
+++ b/src/test/kotlin/com/zynger/floorplan/InputParserTest.kt
@@ -118,4 +118,27 @@ class InputParserTest {
 
         assertTrue(input.creationSqlAsTableNote)
     }
+
+    @Test
+    fun `nullable fields aren't rendered when argument isn't specified`() {
+        val input = InputParser.parse(
+            arrayOf(
+                "samples/db.json"
+            )
+        )
+
+        assertFalse(input.renderNullableFields)
+    }
+
+    @Test
+    fun `nullable fields are rendered when argument is specified`() {
+        val input = InputParser.parse(
+            arrayOf(
+                "samples/db.json",
+                "--render-nullable-fields"
+            )
+        )
+
+        assertTrue(input.renderNullableFields)
+    }
 }
diff --git a/src/test/kotlin/com/zynger/floorplan/dbml/FieldTest.kt b/src/test/kotlin/com/zynger/floorplan/dbml/FieldTest.kt
index 3114c4f..fb3b4ca 100644
--- a/src/test/kotlin/com/zynger/floorplan/dbml/FieldTest.kt
+++ b/src/test/kotlin/com/zynger/floorplan/dbml/FieldTest.kt
@@ -1,5 +1,6 @@
 package com.zynger.floorplan.dbml
 
+import com.zynger.floorplan.Settings
 import com.zynger.floorplan.model.PrimaryKey
 import org.junit.Assert.assertEquals
 import com.zynger.floorplan.model.Field as DbField
@@ -9,6 +10,7 @@ class FieldTest {
     companion object {
         private const val FIELD_PATH = "fieldPath"
         private const val COLUMN_NAME = "username"
+        private val DEFAULT_SETTINGS = Settings()
     }
 
     @Test
@@ -20,7 +22,7 @@ class FieldTest {
             """
                 $COLUMN_NAME varchar [note: 'nullable']
             """.trimIndent(),
-            Field(textField, tablePrimaryKey).toString()
+            Field(textField, tablePrimaryKey, DEFAULT_SETTINGS).toString()
         )
     }
 
@@ -33,7 +35,7 @@ class FieldTest {
             """
                 $COLUMN_NAME varchar [note: 'not null']
             """.trimIndent(),
-            Field(textField, tablePrimaryKey).toString()
+            Field(textField, tablePrimaryKey, DEFAULT_SETTINGS).toString()
         )
     }
 
@@ -47,7 +49,7 @@ class FieldTest {
             """
                 $COLUMN_NAME varchar [default: `$defaultValue`, note: 'not null']
             """.trimIndent(),
-            Field(textField, tablePrimaryKey).toString()
+            Field(textField, tablePrimaryKey, DEFAULT_SETTINGS).toString()
         )
     }
 
@@ -61,7 +63,7 @@ class FieldTest {
             """
                 $COLUMN_NAME varchar [default: ``, note: 'not null']
             """.trimIndent(),
-            Field(textField, tablePrimaryKey).toString()
+            Field(textField, tablePrimaryKey, DEFAULT_SETTINGS).toString()
         )
     }
 
@@ -75,7 +77,7 @@ class FieldTest {
             """
                 $COLUMN_NAME int [default: `$defaultValue`, pk]
             """.trimIndent(),
-            Field(textField, tablePrimaryKey).toString()
+            Field(textField, tablePrimaryKey, DEFAULT_SETTINGS).toString()
         )
     }
 
@@ -89,7 +91,7 @@ class FieldTest {
             """
                 $COLUMN_NAME varchar [default: `$defaultValue`, note: 'not null']
             """.trimIndent(),
-            Field(textField, tablePrimaryKey).toString()
+            Field(textField, tablePrimaryKey, DEFAULT_SETTINGS).toString()
         )
     }
 
@@ -102,7 +104,7 @@ class FieldTest {
             """
                 $COLUMN_NAME int [pk]
             """.trimIndent(),
-            Field(textField, tablePrimaryKey).toString()
+            Field(textField, tablePrimaryKey, DEFAULT_SETTINGS).toString()
         )
     }
 
@@ -115,7 +117,7 @@ class FieldTest {
             """
                 $COLUMN_NAME varchar [pk]
             """.trimIndent(),
-            Field(textField, tablePrimaryKey).toString()
+            Field(textField, tablePrimaryKey, DEFAULT_SETTINGS).toString()
         )
     }
 
@@ -128,7 +130,7 @@ class FieldTest {
             """
                 $COLUMN_NAME real [pk]
             """.trimIndent(),
-            Field(textField, tablePrimaryKey).toString()
+            Field(textField, tablePrimaryKey, DEFAULT_SETTINGS).toString()
         )
     }
 
@@ -141,7 +143,7 @@ class FieldTest {
             """
                 $COLUMN_NAME blob [pk]
             """.trimIndent(),
-            Field(textField, tablePrimaryKey).toString()
+            Field(textField, tablePrimaryKey, DEFAULT_SETTINGS).toString()
         )
     }
 
@@ -154,7 +156,33 @@ class FieldTest {
             """
                 $COLUMN_NAME decimal(1,2) [pk]
             """.trimIndent(),
-            Field(textField, tablePrimaryKey).toString()
+            Field(textField, tablePrimaryKey, DEFAULT_SETTINGS).toString()
+        )
+    }
+
+    @Test
+    fun `field with INTEGER type becomes int(?) when nullable should be rendered`() {
+        val textField = DbField(FIELD_PATH, COLUMN_NAME, "INTEGER", false)
+        val tablePrimaryKey = PrimaryKey(listOf(COLUMN_NAME), false)
+
+        assertEquals(
+            """
+                $COLUMN_NAME int(?) [pk]
+            """.trimIndent(),
+            Field(textField, tablePrimaryKey, Settings(renderNullableFields = true)).toString()
+        )
+    }
+
+    @Test
+    fun `field with unrecognized type follows DBML single-word requirement even when nullable should be rendered`() {
+        val textField = DbField(FIELD_PATH, COLUMN_NAME, "Decimal (1, 2)", false)
+        val tablePrimaryKey = PrimaryKey(listOf(COLUMN_NAME), false)
+
+        assertEquals(
+            """
+                $COLUMN_NAME decimal(1,2)(?) [pk]
+            """.trimIndent(),
+            Field(textField, tablePrimaryKey, Settings(renderNullableFields = true)).toString()
         )
     }
 
@@ -167,7 +195,7 @@ class FieldTest {
             """
                 $COLUMN_NAME varchar [pk, increment]
             """.trimIndent(),
-            Field(textField, tablePrimaryKey).toString()
+            Field(textField, tablePrimaryKey, DEFAULT_SETTINGS).toString()
         )
     }
 }
\ No newline at end of file
diff --git a/src/test/kotlin/com/zynger/floorplan/dbml/TableTest.kt b/src/test/kotlin/com/zynger/floorplan/dbml/TableTest.kt
index 9df3c0e..b39e03c 100644
--- a/src/test/kotlin/com/zynger/floorplan/dbml/TableTest.kt
+++ b/src/test/kotlin/com/zynger/floorplan/dbml/TableTest.kt
@@ -23,7 +23,7 @@ class TableTest {
         private val ON_DELETE = ForeignKeyAction.NO_ACTION
         private val ON_UPDATE = ForeignKeyAction.NO_ACTION
 
-        private val DEFAULT_SETTINGS = Settings(creationSqlAsTableNote = false)
+        private val DEFAULT_SETTINGS = Settings()
     }
 
     @Test