Repository: julioz/FloorPlan (kotlin)

Issue:
Add support for Index parsing
So far, `dbml-parser` doesn't properly handle indexes defined in DBML tables, as such:
```
Table bookings {
  id integer
  country varchar
  booking_date date
  created_at timestamp

  indexes {
      (id, country) [pk] // composite primary key
      created_at [note: 'Date']
      booking_date
      (country, booking_date) [unique]
      booking_date [type: hash]
      (`id*2`)
      (`id*3`,`getdate()`)
      (`id*3`,id)
  }
}
```

There are 3 types of index definitions:

Index with single field (with index name): CREATE INDEX on users (created_at)
Index with multiple fields (composite index): CREATE INDEX on users (created_at, country)
Index with an expression: CREATE INDEX ON films ( first_name + last_name )
(bonus) Composite index with expression: CREATE INDEX ON users ( country, (lower(name)) )

Pull request description:
Fixes #22 

This adds support for basic definition of indexes in DBML `Table` constructs:
```
Indexes {
  (<column names>) [name: '<index name>', <uniqueness>]
}
```

More advanced use-cases (i.e: no name indexes, expression-based indexes) are unsupported.

Patch:
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, "")
+    }
 }


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)
     }
 }