Repository: julioz/FloorPlan
Pull request #40 (2020-06-19 14:05:03)

Add SQLite schema consumer
SQLDelight is a popular multiplatform ORM that is able to expose its schemas via a Gradle task. On #19, we discuss the possibility of ingesting them to generate ER diagrams similarly to how we do for Android's Room.

Interestingly, they export `.db` files, standard SQLite database definitions, that can be connected to and queried. Here, we rely heavily on the `PRAGMA` functions to introspect into the database schema and pull out the interesting properties to build our `DBML` memory model.

This PR, then, wires up the new `SqliteConsumer`  (with the help of a input file format sniffer heuristic) to allow not only for integration between FloorPlan and SQLDelight, but with every other ORM that can expose `.db` files (potentially even [iOS' CoreData](https://stackoverflow.com/a/15119886/12511149)).


Patch:
diff --git a/consumer-sniffer/build.gradle b/consumer-sniffer/build.gradle
new file mode 100644
index 0000000..33d55ee
--- /dev/null
+++ b/consumer-sniffer/build.gradle
@@ -0,0 +1,25 @@
+plugins {
+    id 'java'
+    id 'org.jetbrains.kotlin.jvm'
+}
+
+sourceCompatibility = 1.8
+
+repositories {
+    mavenCentral()
+    jcenter()
+}
+
+dependencies {
+    api project(":consumer")
+    api project(":room-consumer")
+    api project(":sqlite-consumer")
+    implementation "org.jetbrains.kotlin:kotlin-stdlib-jdk8"
+}
+
+compileKotlin {
+    kotlinOptions.jvmTarget = "1.8"
+}
+compileTestKotlin {
+    kotlinOptions.jvmTarget = "1.8"
+}
diff --git a/consumer-sniffer/src/main/kotlin/com/zynger/floorplan/FloorPlanConsumerSniffer.kt b/consumer-sniffer/src/main/kotlin/com/zynger/floorplan/FloorPlanConsumerSniffer.kt
new file mode 100644
index 0000000..adc196f
--- /dev/null
+++ b/consumer-sniffer/src/main/kotlin/com/zynger/floorplan/FloorPlanConsumerSniffer.kt
@@ -0,0 +1,20 @@
+package com.zynger.floorplan
+
+import com.zynger.floorplan.room.RoomConsumer
+import com.zynger.floorplan.sqlite.SqliteConsumer
+import java.io.File
+
+object FloorPlanConsumerSniffer {
+
+    fun sniff(inputSourceFile: File): Consumer {
+        return when (inputSourceFile.extension) {
+            "json" -> RoomConsumer
+            "db" -> SqliteConsumer
+            else -> throw IllegalArgumentException("Unknown file extension: ${inputSourceFile.extension}")
+        }
+    }
+
+    fun isConsumable(inputSourceFile: File): Boolean {
+        return inputSourceFile.extension == "json" || inputSourceFile.extension == "db"
+    }
+}
diff --git a/consumer/build.gradle b/consumer/build.gradle
new file mode 100644
index 0000000..6f47f7c
--- /dev/null
+++ b/consumer/build.gradle
@@ -0,0 +1,23 @@
+plugins {
+    id 'java'
+    id 'org.jetbrains.kotlin.jvm'
+}
+
+sourceCompatibility = 1.8
+
+repositories {
+    mavenCentral()
+    jcenter()
+}
+
+dependencies {
+    implementation project(":dbml")
+    implementation "org.jetbrains.kotlin:kotlin-stdlib-jdk8"
+}
+
+compileKotlin {
+    kotlinOptions.jvmTarget = "1.8"
+}
+compileTestKotlin {
+    kotlinOptions.jvmTarget = "1.8"
+}
diff --git a/consumer/src/main/kotlin/com/zynger/floorplan/Consumer.kt b/consumer/src/main/kotlin/com/zynger/floorplan/Consumer.kt
new file mode 100644
index 0000000..72a24f6
--- /dev/null
+++ b/consumer/src/main/kotlin/com/zynger/floorplan/Consumer.kt
@@ -0,0 +1,8 @@
+package com.zynger.floorplan
+
+import com.zynger.floorplan.dbml.Project
+import java.io.File
+
+interface Consumer {
+    fun read(src: File): Project
+}
diff --git a/dbml-parser/src/main/kotlin/com/zynger/floorplan/lex/IndexParser.kt b/dbml-parser/src/main/kotlin/com/zynger/floorplan/lex/IndexParser.kt
new file mode 100644
index 0000000..98513da
--- /dev/null
+++ b/dbml-parser/src/main/kotlin/com/zynger/floorplan/lex/IndexParser.kt
@@ -0,0 +1,37 @@
+package com.zynger.floorplan.lex
+
+import com.zynger.floorplan.dbml.Index
+import org.intellij.lang.annotations.Language
+
+object IndexParser {
+
+    val INDEXES_BLOCK_REGEX = Regex("""[Ii]ndexes\s+\{(\s|\n|[^}]+)}""")
+
+    @Language("RegExp") private const val INDEX_NAME = """\((.+)\)"""
+    @Language("RegExp") private const val INDEX_PROPERTIES = """\[([^\]]+)]"""
+
+    private val INDEX_REGEX = Regex("""$INDEX_NAME.*$INDEX_PROPERTIES\s*\n""")
+    private val INDEX_NAME_REGEX = Regex("""name:\s*['"](\w+)['"]""")
+    private const val UNNAMED_INDEX = "unnamed_index"
+
+    fun parseIndexes(indexContent: String): List<Index> {
+        return INDEXES_BLOCK_REGEX.find(indexContent)?.let {
+            INDEX_REGEX.findAll(it.groups[1]!!.value).toList().map { indexMatch ->
+                val indexColumns = indexMatch.groups[1]!!.value.split(",").map { columnName -> columnName.trim() }
+                val indexProperties = indexMatch.groups[2]!!.value
+
+                val indexNameMatch = INDEX_NAME_REGEX.find(indexProperties)
+                val indexName = if (indexNameMatch != null) {
+                    indexNameMatch.groups[1]!!.value
+                } else {
+                    UNNAMED_INDEX
+                }
+                Index(
+                    name = indexName,
+                    columnNames = indexColumns,
+                    unique = indexProperties.contains("unique", ignoreCase = true)
+                )
+            }
+        } ?: emptyList()
+    }
+}
diff --git a/dbml-parser/src/main/kotlin/com/zynger/floorplan/lex/TableParser.kt b/dbml-parser/src/main/kotlin/com/zynger/floorplan/lex/TableParser.kt
index 4ea1aff..43b950c 100644
--- a/dbml-parser/src/main/kotlin/com/zynger/floorplan/lex/TableParser.kt
+++ b/dbml-parser/src/main/kotlin/com/zynger/floorplan/lex/TableParser.kt
@@ -7,7 +7,7 @@ object TableParser {
     @Language("RegExp") private const val TABLE_NAME = """(."\w+"|\w+.)"""
     @Language("RegExp") private const val TABLE_ALIAS = """(\s*as\s+[\w]+|\s*as\s+"[\w]+"|)"""
     @Language("RegExp") private const val TABLE_NOTES = """(\s\[.*]|)"""
-    @Language("RegExp") private const val TABLE_CONTENT = """\{(\s|\n|[^}]*)}"""
+    @Language("RegExp") private const val TABLE_CONTENT = """\{(\s|\n|[^}]*}*)\s*}"""
     private val TABLE_REGEX = Regex("""[Tt]able\s+$TABLE_NAME\s*$TABLE_ALIAS$TABLE_NOTES\s*$TABLE_CONTENT""")
 
     fun parseTables(dbmlInput: String): List<Table> {
@@ -15,21 +15,27 @@ object TableParser {
 
         return TABLE_REGEX.findAll(dbmlInput).map {
             val tableName = it.groups[1]!!.value.trim()
-            val tableContent = it.groups[4]!!.value.run {
-                // TODO BUG, hack: the TABLE_REGEX doesn't take in account the Indexes block properly
-                val indexesMatchResult = Regex("""Indexes\s+\{""").find(this)
-                if (indexesMatchResult != null) {
-                    this.substringBefore(indexesMatchResult.value)
-                } else {
-                    this
-                }
-            }
+            val tableContent = it.groups[4]!!.value
+
             Table(
                 rawValue = it.groups[0]!!.value,
                 name = tableName,
-                columns = ColumnParser.parseColumns(tableName, tableContent),
-                indexes = emptyList() // TODO include indexes parsing
+                columns = ColumnParser.parseColumns(tableName, removeIndexes(tableContent)),
+                indexes = IndexParser.parseIndexes(tableContent)
             )
         }.toList()
     }
+
+    private fun removeIndexes(tableContent: String): String {
+        val indexesGroup = IndexParser.INDEXES_BLOCK_REGEX.find(tableContent)
+        return if (indexesGroup != null) {
+            tableContent.remove(indexesGroup.groups.first()!!.value)
+        } else {
+            tableContent
+        }
+    }
+
+    private fun String.remove(substring: String): String {
+        return replace(substring, "")
+    }
 }
diff --git a/docs/recipes/android-room.md b/docs/recipes/android-room.md
new file mode 100644
index 0000000..54c8a2f
--- /dev/null
+++ b/docs/recipes/android-room.md
@@ -0,0 +1,30 @@
+# FloorPlan & Room
+
+The [Room](https://developer.android.com/topic/libraries/architecture/room) persistence library provides an abstraction layer over SQLite to allow for more robust database access while harnessing the full power of SQLite.
+
+Room can export your database's schema information into a `JSON` file at compile time.
+To export the schema, set the `room.schemaLocation` annotation processor property (or `kapt`, if you use Kotlin) in your `build.gradle` file.
+
+The `JSON` schemas can, then, be passed to FloorPlan as an input parameter for its rendering generation. Here we see an example with [FloorPlan's Gradle Plugin](../gradle-plugin.md):
+```
+android {
+    ...
+    defaultConfig {
+        ...
+        javaCompileOptions {
+            annotationProcessorOptions {
+                arguments += ["room.schemaLocation":
+                             "$projectDir/schemas".toString()]
+            }
+        }
+    }
+}
+
+...
+
+floorPlan {
+  schemaLocation = "$projectDir/schemas".toString()
+  ...
+}
+
+```
diff --git a/docs/recipes/sqldelight.md b/docs/recipes/sqldelight.md
new file mode 100644
index 0000000..a0f150b
--- /dev/null
+++ b/docs/recipes/sqldelight.md
@@ -0,0 +1,25 @@
+# FloorPlan & SQLDelight
+
+In order to use [SQLDelight](https://github.com/cashapp/sqldelight) schemas as input to FloorPlan, make sure you are on a recent version of library (>=1.0).
+
+When applying SQLDelight's Gradle plugin, specify the following gradle extension to configure it to output `.db` files.
+
+```
+sqldelight {
+    <databaseName> {
+        packageName = "<fully qualified package name to where the database is generated>"
+        schemaOutputDirectory = file("<output directory for the db file>")
+    }
+}
+```
+
+Once done, running `./gradlew <module>:tasks` will uncover a [generateSchema](https://github.com/cashapp/sqldelight/blob/master/sqldelight-gradle-plugin/src/main/kotlin/com/squareup/sqldelight/gradle/GenerateSchemaTask.kt) gradle task per build type (usually `debug`, `release`, etc.).
+
+```
+./gradlew generate<buildType><projectName>Schema
+``` 
+
+will output a `.db` file can be used as an input to `FloorPlan`, via its [CLI](../run.md) or [Gradle Plugin](../gradle-plugin.md) interfaces.
+
+!!! note
+    Read more about [SQLDelight's](https://github.com/cashapp/sqldelight) configuration [on the project's website](https://cashapp.github.io/sqldelight/gradle/).
\ No newline at end of file
diff --git a/floorplan-cli/build.gradle b/floorplan-cli/build.gradle
index b604788..054ad34 100644
--- a/floorplan-cli/build.gradle
+++ b/floorplan-cli/build.gradle
@@ -18,7 +18,7 @@ application {
 dependencies {
     implementation project(":dbml")
     implementation project(":dbml2viz")
-    implementation project(":room-consumer")
+    implementation project(":consumer-sniffer")
     implementation "org.jetbrains.kotlin:kotlin-stdlib-jdk8"
     testImplementation "junit:junit:4.12"
 }
diff --git a/floorplan-cli/src/main/kotlin/com/zynger/floorplan/App.kt b/floorplan-cli/src/main/kotlin/com/zynger/floorplan/App.kt
index df17ae4..20a4cd9 100644
--- a/floorplan-cli/src/main/kotlin/com/zynger/floorplan/App.kt
+++ b/floorplan-cli/src/main/kotlin/com/zynger/floorplan/App.kt
@@ -1,7 +1,6 @@
 package com.zynger.floorplan
 
 import com.zynger.floorplan.dbml.Project
-import com.zynger.floorplan.room.RoomConsumer
 import java.io.File
 import java.lang.IllegalArgumentException
 
@@ -9,7 +8,10 @@ fun main(args: Array<String>) {
     val input = InputParser.parse(args)
 
     val src = File(input.schemaPath)
-    val project: Project = RoomConsumer.read(src)
+
+    val project: Project = FloorPlanConsumerSniffer
+        .sniff(src)
+        .read(src)
 
     FloorPlan.render(
         project = project,
diff --git a/floorplan-gradle-plugin/build.gradle b/floorplan-gradle-plugin/build.gradle
index 7e216fc..fc0603b 100644
--- a/floorplan-gradle-plugin/build.gradle
+++ b/floorplan-gradle-plugin/build.gradle
@@ -130,7 +130,7 @@ dependencies {
     compileOnly gradleApi()
     implementation project(":dbml")
     implementation project(":dbml2viz")
-    implementation project(":room-consumer")
+    implementation project(":consumer-sniffer")
 
     testImplementation gradleTestKit()
     testImplementation "junit:junit:4.12"
diff --git a/floorplan-gradle-plugin/src/main/kotlin/com/zynger/floorplan/gradle/FloorPlanTask.kt b/floorplan-gradle-plugin/src/main/kotlin/com/zynger/floorplan/gradle/FloorPlanTask.kt
index 35fba55..3b8d2e1 100644
--- a/floorplan-gradle-plugin/src/main/kotlin/com/zynger/floorplan/gradle/FloorPlanTask.kt
+++ b/floorplan-gradle-plugin/src/main/kotlin/com/zynger/floorplan/gradle/FloorPlanTask.kt
@@ -3,7 +3,6 @@ package com.zynger.floorplan.gradle
 import com.zynger.floorplan.*
 import com.zynger.floorplan.dbml.Project
 import com.zynger.floorplan.gradle.model.OutputFormat
-import com.zynger.floorplan.room.RoomConsumer
 import org.gradle.api.DefaultTask
 import org.gradle.api.tasks.*
 import java.io.File
@@ -21,13 +20,20 @@ open class FloorPlanTask : DefaultTask() {
 
     @TaskAction
     fun generateFloorPlan() {
-        val schemas: List<File> = schemaLocation.findRoomSchemas()
+        val schemas: List<File> = schemaLocation.findSchemas()
+        if (schemas.isEmpty()) {
+            logger.info("FloorPlan could not find any schema in specified location.")
+        }
+
         schemas.forEach { schema ->
-            val project: Project = RoomConsumer.read(schema)
             val outputHandler = OutputParameterHandler(schema, schemaLocation)
             val outputFormat: Format = outputHandler.format(outputFormat)
             val outputFile: File = outputHandler.file(outputLocation, outputFormat)
 
+            val project: Project = FloorPlanConsumerSniffer
+                .sniff(schema)
+                .read(schema)
+
             FloorPlan.render(
                 project = project,
                 output = Output(outputFormat, Destination.Disk(outputFile))
@@ -35,7 +41,7 @@ open class FloorPlanTask : DefaultTask() {
         }
     }
 
-    private fun File.findRoomSchemas(): List<File> {
-        return walk().filter { it.extension == "json" }.toList()
+    private fun File.findSchemas(): List<File> {
+        return walk().filter { FloorPlanConsumerSniffer.isConsumable(it) }.toList()
     }
 }
diff --git a/mkdocs.yml b/mkdocs.yml
index 51867ec..bb09260 100644
--- a/mkdocs.yml
+++ b/mkdocs.yml
@@ -22,6 +22,9 @@ nav:
   - 'Usage':
       - 'Command-line': 'run.md'
       - 'Gradle Plugin': 'gradle-plugin.md'
+      - 'Recipes':
+        - 'Android Room': 'recipes/android-room.md'
+        - 'SQLDelight': 'recipes/sqldelight.md'
   - 'Contributing': 'contributing.md'
   - 'Architecture': 'architecture.md'
   - 'Changelog': 'changelog.md'
diff --git a/room-consumer/build.gradle b/room-consumer/build.gradle
index 149c8f2..da7f058 100644
--- a/room-consumer/build.gradle
+++ b/room-consumer/build.gradle
@@ -12,6 +12,7 @@ repositories {
 }
 
 dependencies {
+    implementation project(":consumer")
     implementation project(":dbml")
     implementation "org.jetbrains.kotlin:kotlin-stdlib-jdk8"
     implementation "org.jetbrains.kotlinx:kotlinx-serialization-runtime:0.20.0" // JVM dependency
diff --git a/room-consumer/src/main/kotlin/com/zynger/floorplan/room/RoomConsumer.kt b/room-consumer/src/main/kotlin/com/zynger/floorplan/room/RoomConsumer.kt
index 288ab73..7f23a4c 100644
--- a/room-consumer/src/main/kotlin/com/zynger/floorplan/room/RoomConsumer.kt
+++ b/room-consumer/src/main/kotlin/com/zynger/floorplan/room/RoomConsumer.kt
@@ -1,15 +1,16 @@
 package com.zynger.floorplan.room
 
+import com.zynger.floorplan.Consumer
 import com.zynger.floorplan.dbml.*
 import kotlinx.serialization.json.Json
 import kotlinx.serialization.json.JsonConfiguration
 import java.io.File
 import com.zynger.floorplan.room.Index as RoomIndex
 
-object RoomConsumer {
+object RoomConsumer: Consumer {
     private val json = Json(JsonConfiguration.Stable.copy(ignoreUnknownKeys = true, isLenient = true))
 
-    fun read(src: File): Project {
+    override fun read(src: File): Project {
         val roomDatabase = json
             .parse(Schema.serializer(), src.readText())
             .database
diff --git a/settings.gradle b/settings.gradle
index 94bd9d6..99dc3d0 100644
--- a/settings.gradle
+++ b/settings.gradle
@@ -6,7 +6,10 @@ include 'dbml'
 include 'dbml-parser'
 include 'dbml2viz'
 include 'floorplan-cli'
+include 'consumer'
+include 'consumer-sniffer'
 include 'room-consumer'
+include 'sqlite-consumer'
 include 'floorplan-gradle-plugin'
 
 if (includeSampleApp) {
diff --git a/sqlite-consumer/build.gradle b/sqlite-consumer/build.gradle
new file mode 100644
index 0000000..12486cc
--- /dev/null
+++ b/sqlite-consumer/build.gradle
@@ -0,0 +1,27 @@
+plugins {
+    id 'java'
+    id 'org.jetbrains.kotlin.jvm'
+}
+
+sourceCompatibility = 1.8
+
+repositories {
+    mavenCentral()
+    jcenter()
+}
+
+dependencies {
+    implementation project(":consumer")
+    implementation project(":dbml")
+    implementation "org.jetbrains.kotlin:kotlin-stdlib-jdk8"
+    implementation 'org.xerial:sqlite-jdbc:3.31.1'
+
+    testImplementation "junit:junit:4.12"
+}
+
+compileKotlin {
+    kotlinOptions.jvmTarget = "1.8"
+}
+compileTestKotlin {
+    kotlinOptions.jvmTarget = "1.8"
+}
diff --git a/sqlite-consumer/src/main/kotlin/com/zynger/floorplan/sqlite/SqliteConsumer.kt b/sqlite-consumer/src/main/kotlin/com/zynger/floorplan/sqlite/SqliteConsumer.kt
new file mode 100644
index 0000000..c51c18e
--- /dev/null
+++ b/sqlite-consumer/src/main/kotlin/com/zynger/floorplan/sqlite/SqliteConsumer.kt
@@ -0,0 +1,134 @@
+package com.zynger.floorplan.sqlite
+
+import com.zynger.floorplan.Consumer
+import com.zynger.floorplan.dbml.*
+import java.io.File
+import java.sql.Connection
+import java.sql.DriverManager
+import java.sql.ResultSet
+
+object SqliteConsumer: Consumer {
+
+    override fun read(src: File): Project {
+        return DriverManager.getConnection("jdbc:sqlite:${src.path}").use {
+            val allTables = mutableListOf<Table>()
+            val allReferences = mutableListOf<Reference>()
+
+            it.getTables()
+                .filterNot { table -> table.isSystemTable() }
+                .forEach { table ->
+                    val tableName = table.name
+
+                    allTables += Table(
+                        rawValue = table.sql,
+                        name = tableName,
+                        columns = it.getColumns(tableName),
+                        indexes = it.getIndexes(tableName)
+                    )
+                    allReferences += it.getReferences(tableName)
+                }
+
+            Project(
+                tables = allTables,
+                references = allReferences
+            )
+        }
+    }
+
+    private fun Connection.getTables(): List<SQLiteTable> {
+        val tablesResultSet: ResultSet = createStatement()
+            .executeQuery(
+                """
+            SELECT name, sql FROM sqlite_master
+            WHERE type='table'
+            ORDER BY name;
+            """.trimIndent()
+            )
+
+        val tables = mutableListOf<SQLiteTable>()
+        while (tablesResultSet.next()) {
+            tables += SQLiteTable(
+                name = tablesResultSet.getString("name"),
+                sql = tablesResultSet.getString("sql")
+            )
+        }
+        return tables
+    }
+
+    private fun Connection.getColumns(tableName: String): List<Column> {
+        val columns = mutableListOf<Column>()
+        val columnsResultSet: ResultSet = createStatement().executeQuery("PRAGMA table_info($tableName);")
+        while (columnsResultSet.next()) {
+            val primaryKey = columnsResultSet.getBoolean("Pk")
+            columns += Column(
+                name = columnsResultSet.getString("name"),
+                type = columnsResultSet.getString("type"),
+                defaultValue = columnsResultSet.getString("Dflt_value"),
+                primaryKey = primaryKey,
+                notNull = columnsResultSet.getBoolean("Notnull"),
+                increment = if (primaryKey) isAutoIncrement(tableName) else false
+            )
+        }
+        return columns
+    }
+
+    private fun Connection.isAutoIncrement(tableName: String): Boolean {
+        val autoIncrementResultSet =
+            createStatement().executeQuery("SELECT * FROM sqlite_master WHERE tbl_name=\"$tableName\" AND sql LIKE \"%AUTOINCREMENT%\"")
+        return autoIncrementResultSet.next()
+    }
+
+    private fun Connection.getReferences(tableName: String): List<Reference> {
+        val references = mutableListOf<Reference>()
+        val foreignKeys = createStatement().executeQuery("PRAGMA foreign_key_list($tableName);")
+
+        while (foreignKeys.next()) {
+            references += Reference(
+                fromTable = tableName,
+                toTable = foreignKeys.getString("table"),
+                fromColumn = foreignKeys.getString("from"),
+                toColumn = foreignKeys.getString("to"),
+                referenceOrder = ReferenceOrder.OneToOne, // FIXME: how to precise the reference order?
+                updateAction = foreignKeys.getString("on_update"),
+                deleteAction = foreignKeys.getString("on_delete")
+            )
+        }
+        return references
+    }
+
+    private fun Connection.getIndexes(tableName: String): List<Index> {
+        val indexes = mutableListOf<Index>()
+        val indexesResultSet: ResultSet = createStatement().executeQuery("PRAGMA index_list($tableName);")
+        while (indexesResultSet.next()) {
+            val indexName: String = indexesResultSet.getString("name")
+            val unique: Boolean = indexesResultSet.getBoolean("unique")
+
+            indexes += Index(
+                name = indexName,
+                columnNames = getIndexTargetColumns(indexName),
+                unique = unique
+            )
+        }
+        return indexes
+    }
+
+    private fun Connection.getIndexTargetColumns(indexName: String): List<String> {
+        val indexInfos: ResultSet = createStatement().executeQuery("PRAGMA index_xinfo($indexName);")
+
+        val columnTargets = mutableListOf<String>()
+        while (indexInfos.next()) {
+            val columnName: String? = indexInfos.getString("name")
+            if (columnName != null) {
+                columnTargets += columnName
+            }
+        }
+        return columnTargets
+    }
+
+    private data class SQLiteTable(val name: String, val sql: String)
+
+    private fun SQLiteTable.isSystemTable(): Boolean {
+        // https://www.techonthenet.com/sqlite/sys_tables/index.php
+        return name.startsWith("sqlite_")
+    }
+}


Test patch:
diff --git a/dbml-parser/src/test/kotlin/com/zynger/floorplan/lex/IndexParserTest.kt b/dbml-parser/src/test/kotlin/com/zynger/floorplan/lex/IndexParserTest.kt
new file mode 100644
index 0000000..3a56153
--- /dev/null
+++ b/dbml-parser/src/test/kotlin/com/zynger/floorplan/lex/IndexParserTest.kt
@@ -0,0 +1,188 @@
+package com.zynger.floorplan.lex
+
+import org.junit.Assert.*
+import org.junit.Ignore
+import org.junit.Test
+
+class IndexParserTest {
+    @Test
+    fun `no indexes`() {
+        val input = """
+          id int [pk]
+          post_id int
+          tag_id int
+        """.trimIndent()
+
+        val indexes = IndexParser.parseIndexes(input)
+
+        assertEquals(0, indexes.size)
+    }
+
+    @Test
+    fun `no indexes in block`() {
+        val input = """
+          id int [pk]
+          post_id int
+          tag_id int
+        
+          Indexes  {
+          }
+        """.trimIndent()
+
+        val indexes = IndexParser.parseIndexes(input)
+
+        assertEquals(0, indexes.size)
+    }
+
+    @Ignore(value = "Unsupported: advanced usage of index.")
+    @Test
+    fun `index with no explicit name`() {
+        val input = """
+          id int [pk]
+          post_id int
+          tag_id int
+        
+          indexes  {
+            post_id
+          }
+        """.trimIndent()
+
+        val indexes = IndexParser.parseIndexes(input)
+
+        assertEquals(1, indexes.size)
+        assertEquals("post_id", indexes[0].name)
+    }
+
+    @Test
+    fun `unique index with no explicit name`() {
+        val input = """
+          id int [pk]
+          post_id int
+          tag_id int
+        
+          indexes  {
+            (id, post_id) [unique]
+          }
+        """.trimIndent()
+
+        val indexes = IndexParser.parseIndexes(input)
+
+        assertEquals(1, indexes.size)
+        assertEquals("unnamed_index", indexes[0].name)
+        assertEquals(true, indexes[0].unique)
+    }
+
+    @Ignore(value = "Unsupported: advanced usage of index.")
+    @Test
+    fun `index as expression`() {
+        val input = """
+          id int [pk]
+          post_id int
+          tag_id int
+        
+          indexes  {
+            (`id*3`,`getdate()`)
+          }
+        """.trimIndent()
+
+        val indexes = IndexParser.parseIndexes(input)
+
+        assertEquals(1, indexes.size)
+        assertEquals(listOf("`id*3`", "`getdate()`"), indexes[0].columnNames)
+    }
+
+    @Test
+    fun `parses name of index`() {
+        val input = """
+          id int [pk]
+          post_id int
+          tag_id int
+        
+          Indexes  {
+            (post_id) [name:'index_post_tags_post_id', unique]
+            (tag_id) [name:'index_post_tags_tag_id']
+          }
+        """.trimIndent()
+
+        val indexes = IndexParser.parseIndexes(input)
+
+        assertEquals("index_post_tags_post_id", indexes[0].name)
+        assertEquals("index_post_tags_tag_id", indexes[1].name)
+    }
+
+    @Ignore(value = "Unsupported: advanced usage of index.")
+    @Test
+    fun `generates name of index when not explicitly specified`() {
+        val input = """
+          id int [pk]
+          post_id int
+          tag_id int
+        
+          Indexes  {
+            (post_id)
+            (tag_id, post_id)
+          }
+        """.trimIndent()
+
+        val indexes = IndexParser.parseIndexes(input)
+
+        assertEquals("unnamed_index", indexes[0].name)
+        assertEquals("unnamed_index", indexes[1].name)
+    }
+
+    @Test
+    fun `parses uniqueness of index`() {
+        val input = """
+          id int [pk]
+          post_id int
+          tag_id int
+        
+          Indexes  {
+            (post_id) [name:'index_post_tags_post_id', unique]
+            (tag_id) [name:'index_post_tags_tag_id']
+          }
+        """.trimIndent()
+
+        val indexes = IndexParser.parseIndexes(input)
+
+        assertEquals(true, indexes[0].unique)
+        assertEquals(false, indexes[1].unique)
+    }
+
+    @Test
+    fun `parses multiple of indexes`() {
+        val input = """
+          id int [pk]
+          post_id int
+          tag_id int
+        
+          Indexes  {
+            (post_id) [name:'index_post_tags_post_id', unique]
+            (tag_id) [name:'index_post_tags_tag_id']
+          }
+        """.trimIndent()
+
+        val indexes = IndexParser.parseIndexes(input)
+
+        assertEquals(2, indexes.size)
+    }
+
+    @Test
+    fun `parses column names of indexes`() {
+        val input = """
+          id int [pk]
+          post_id int
+          tag_id int
+        
+          Indexes  {
+            (post_id) [name:'index_post_tags_post_id', unique]
+            (tag_id, post_id) [name:'index_post_tags_tag_id']
+          }
+        """.trimIndent()
+
+        val indexes = IndexParser.parseIndexes(input)
+
+        assertEquals(listOf("post_id"), indexes[0].columnNames)
+        assertEquals(listOf("tag_id", "post_id"), indexes[1].columnNames)
+    }
+}
\ No newline at end of file
diff --git a/dbml-parser/src/test/kotlin/com/zynger/floorplan/lex/TableParserTest.kt b/dbml-parser/src/test/kotlin/com/zynger/floorplan/lex/TableParserTest.kt
index d3ed5ea..2ca1340 100644
--- a/dbml-parser/src/test/kotlin/com/zynger/floorplan/lex/TableParserTest.kt
+++ b/dbml-parser/src/test/kotlin/com/zynger/floorplan/lex/TableParserTest.kt
@@ -1,6 +1,5 @@
 package com.zynger.floorplan.lex
 
-import com.zynger.floorplan.dbml.Index
 import com.zynger.floorplan.dbml.Table
 import org.junit.Assert.*
 import org.junit.Test
@@ -77,7 +76,7 @@ class TableParserTest {
     }
 
     @Test
-    fun `ignores indexes`() {
+    fun `passes index content to be parsed by delegation`() {
         val input = """
             table post_tags [note: 'hey table note']{
               id int [pk]
@@ -86,12 +85,13 @@ class TableParserTest {
 
               Indexes  {
                 (post_id) [name:'index_post_tags_post_id', unique]
+                (tag_id) [name:'index_post_tags_tag_id']
               }
             }
         """.trimIndent()
 
         val tables = TableParser.parseTables(input)
 
-        assertEquals(emptyList<Index>(), tables[0].indexes)
+        assertEquals(2, tables.first().indexes.size)
     }
 }