Repository: julioz/FloorPlan
Pull request #21 (2020-05-19 13:00:01)

Add dbml parser module
Adds module to allow for creating an in-memory model of DBML schemas.

To be done separately:
- Add support for Index parsing (currently hack in place)
- Parse column default values
- Include table aliases and table notes in modeling
- Pull out DBML modeling classes into shared module that Room translator can depend on (and remove duplication)


Patch:
diff --git a/dbml-parser/build.gradle b/dbml-parser/build.gradle
new file mode 100644
index 0000000..ce06f61
--- /dev/null
+++ b/dbml-parser/build.gradle
@@ -0,0 +1,23 @@
+plugins {
+    id 'java'
+    id 'org.jetbrains.kotlin.jvm'
+}
+
+sourceCompatibility = 1.8
+
+repositories {
+    mavenCentral()
+    jcenter()
+}
+
+dependencies {
+    implementation "org.jetbrains.kotlin:kotlin-stdlib-jdk8"
+    testImplementation "junit:junit:4.12"
+}
+
+compileKotlin {
+    kotlinOptions.jvmTarget = "1.8"
+}
+compileTestKotlin {
+    kotlinOptions.jvmTarget = "1.8"
+}
diff --git a/dbml-parser/src/main/kotlin/com/zynger/floorplan/Column.kt b/dbml-parser/src/main/kotlin/com/zynger/floorplan/Column.kt
new file mode 100644
index 0000000..6b0a046
--- /dev/null
+++ b/dbml-parser/src/main/kotlin/com/zynger/floorplan/Column.kt
@@ -0,0 +1,14 @@
+package com.zynger.floorplan
+
+data class Column(
+    val rawValue: String,
+    val name: String,
+    val type: String,
+    val note: String? = null,
+    val primaryKey: Boolean = false,
+    val notNull: Boolean = false,
+    val increment: Boolean = false,
+    val reference: Reference? = null // not null when this column references another through a column attribute
+) {
+    // example column: address varchar(255) [unique, not null, note: 'to include unit number']
+}
\ No newline at end of file
diff --git a/dbml-parser/src/main/kotlin/com/zynger/floorplan/Index.kt b/dbml-parser/src/main/kotlin/com/zynger/floorplan/Index.kt
new file mode 100644
index 0000000..c0ca2a1
--- /dev/null
+++ b/dbml-parser/src/main/kotlin/com/zynger/floorplan/Index.kt
@@ -0,0 +1,6 @@
+package com.zynger.floorplan
+
+data class Index(val name: String, val columnNames: List<String>? = null, val unique: Boolean = false){
+    // TODO should we enforce that indexes must have column names? as to make the property not nullable
+    //(urn) [name:'index_TimeToLives_urn', unique]
+}
diff --git a/dbml-parser/src/main/kotlin/com/zynger/floorplan/Parser.kt b/dbml-parser/src/main/kotlin/com/zynger/floorplan/Parser.kt
new file mode 100644
index 0000000..cd873b8
--- /dev/null
+++ b/dbml-parser/src/main/kotlin/com/zynger/floorplan/Parser.kt
@@ -0,0 +1,15 @@
+package com.zynger.floorplan
+
+import com.zynger.floorplan.lex.LoneReferenceParser
+import com.zynger.floorplan.lex.TableParser
+
+object Parser {
+
+    fun parse(dbmlInput: String): Project {
+        val tables = TableParser.parseTables(dbmlInput)
+        val columnReferences = tables.map { it.columns }.flatten().mapNotNull { it.reference }
+        val references = LoneReferenceParser.parseReferences(dbmlInput) + columnReferences
+        return Project(tables, references)
+    }
+
+}
diff --git a/dbml-parser/src/main/kotlin/com/zynger/floorplan/Project.kt b/dbml-parser/src/main/kotlin/com/zynger/floorplan/Project.kt
new file mode 100644
index 0000000..928fef0
--- /dev/null
+++ b/dbml-parser/src/main/kotlin/com/zynger/floorplan/Project.kt
@@ -0,0 +1,6 @@
+package com.zynger.floorplan
+
+data class Project(
+    val tables: List<Table>,
+    val reference: List<Reference>
+)
diff --git a/dbml-parser/src/main/kotlin/com/zynger/floorplan/Reference.kt b/dbml-parser/src/main/kotlin/com/zynger/floorplan/Reference.kt
new file mode 100644
index 0000000..65fbdb4
--- /dev/null
+++ b/dbml-parser/src/main/kotlin/com/zynger/floorplan/Reference.kt
@@ -0,0 +1,27 @@
+package com.zynger.floorplan
+
+import java.lang.IllegalArgumentException
+
+// Ref: trending_shows.show_id - shows.id [delete: cascade, update: cascade]
+data class Reference(
+    val rawValue: String,
+    val fromTable: String,
+    val fromColumn: String,
+    val toTable: String,
+    val toColumn: String,
+    val referenceOrder: ReferenceOrder
+)
+
+enum class ReferenceOrder {
+    OneToOne, OneToMany, ManyToOne;
+    companion object {
+        fun fromString(str: String): ReferenceOrder {
+            return when (str.trim()) {
+                "-" -> OneToOne
+                ">" -> ManyToOne
+                "<" -> OneToMany
+                else -> throw IllegalArgumentException("Could not parse $str as reference order.")
+            }
+        }
+    }
+}
diff --git a/dbml-parser/src/main/kotlin/com/zynger/floorplan/Table.kt b/dbml-parser/src/main/kotlin/com/zynger/floorplan/Table.kt
new file mode 100644
index 0000000..92f440c
--- /dev/null
+++ b/dbml-parser/src/main/kotlin/com/zynger/floorplan/Table.kt
@@ -0,0 +1,8 @@
+package com.zynger.floorplan
+
+data class Table(
+    val rawValue: String,
+    val name: String,
+    val columns: List<Column>,
+    val indexes: List<Index> = emptyList()
+)
diff --git a/dbml-parser/src/main/kotlin/com/zynger/floorplan/lex/ColumnParser.kt b/dbml-parser/src/main/kotlin/com/zynger/floorplan/lex/ColumnParser.kt
new file mode 100644
index 0000000..3edec00
--- /dev/null
+++ b/dbml-parser/src/main/kotlin/com/zynger/floorplan/lex/ColumnParser.kt
@@ -0,0 +1,42 @@
+package com.zynger.floorplan.lex
+
+import com.zynger.floorplan.Column
+import com.zynger.floorplan.Reference
+import org.intellij.lang.annotations.Language
+
+object ColumnParser {
+    @Language("RegExp") private const val WORD = """("\w+"|\w+)"""
+    @Language("RegExp") private const val COLUMN_NAME = """$WORD+"""
+    @Language("RegExp") private const val COLUMN_TYPE = WORD
+    @Language("RegExp") private const val COLUMN_PROPERTIES = """\[[^]]*]"""
+    private val COLUMN_REGEX = Regex("""$COLUMN_NAME\s+$COLUMN_TYPE(\s+$COLUMN_PROPERTIES|)\s*\n""")
+
+    // TODO parse column default values
+
+    fun parseColumns(tableName: String, columnsInput: String): List<Column> {
+        return COLUMN_REGEX.findAll(columnsInput).map {
+            val rawValue = it.groups[0]!!.value
+            val name = it.groups[1]!!.value.removeSurroundQuotes()
+            val type = it.groups[2]!!.value
+            val columnProperties = it.groups[3]!!.value.trim()
+            val notNull = columnProperties.contains("not null")
+            val pk = columnProperties.contains("pk")
+            val increment = columnProperties.contains("increment")
+            val reference: Reference? = ColumnReferenceParser.parse(tableName, name, columnProperties)
+
+            Column(
+                rawValue = rawValue,
+                name = name,
+                type = type,
+                primaryKey = pk,
+                notNull = notNull,
+                increment = increment,
+                reference = reference
+            )
+        }.toList()
+    }
+
+    private fun String.removeSurroundQuotes(): String {
+        return this.removeSurrounding("\"")
+    }
+}
diff --git a/dbml-parser/src/main/kotlin/com/zynger/floorplan/lex/ColumnReferenceParser.kt b/dbml-parser/src/main/kotlin/com/zynger/floorplan/lex/ColumnReferenceParser.kt
new file mode 100644
index 0000000..7b3ae5e
--- /dev/null
+++ b/dbml-parser/src/main/kotlin/com/zynger/floorplan/lex/ColumnReferenceParser.kt
@@ -0,0 +1,29 @@
+package com.zynger.floorplan.lex
+
+import com.zynger.floorplan.Reference
+import com.zynger.floorplan.ReferenceOrder
+import org.intellij.lang.annotations.Language
+
+object ColumnReferenceParser {
+    @Language("RegExp") private const val WORD = """("\w+"|\w+)+"""
+    @Language("RegExp") private const val REFERENCE_ORDER = """([-<>])"""
+    private val COLUMN_REFERENCE_REGEX = Regex("""(([Rr]ef)\s*:\s*$REFERENCE_ORDER\s*$WORD\.$WORD)""")
+
+    fun parse(tableName: String, fromColumn: String, columnProperties: String): Reference? {
+        return if (COLUMN_REFERENCE_REGEX.containsMatchIn(columnProperties)) {
+            val referenceProperties = COLUMN_REFERENCE_REGEX.find(columnProperties)!!
+            Reference(
+                rawValue = referenceProperties.groups[0]!!.value,
+                fromTable = tableName.removeSurroundQuotes(),
+                fromColumn = fromColumn.removeSurroundQuotes(),
+                referenceOrder = ReferenceOrder.fromString(referenceProperties.groups[3]!!.value),
+                toTable = referenceProperties.groups[4]!!.value.removeSurroundQuotes(),
+                toColumn = referenceProperties.groups[5]!!.value.removeSurroundQuotes()
+            )
+        } else null
+    }
+
+    private fun String.removeSurroundQuotes(): String {
+        return this.removeSurrounding("\"")
+    }
+}
diff --git a/dbml-parser/src/main/kotlin/com/zynger/floorplan/lex/LoneReferenceParser.kt b/dbml-parser/src/main/kotlin/com/zynger/floorplan/lex/LoneReferenceParser.kt
new file mode 100644
index 0000000..93a0569
--- /dev/null
+++ b/dbml-parser/src/main/kotlin/com/zynger/floorplan/lex/LoneReferenceParser.kt
@@ -0,0 +1,28 @@
+package com.zynger.floorplan.lex
+
+import com.zynger.floorplan.Reference
+import com.zynger.floorplan.ReferenceOrder
+import org.intellij.lang.annotations.Language
+
+object LoneReferenceParser {
+    @Language("RegExp") private const val WORD = """("\w+"|\w+)+"""
+    @Language("RegExp") private const val REFERENCE_ORDER = """([-<>])"""
+    private val REFERENCE_REGEX = Regex("""[Rr]ef:\s*$WORD\.$WORD\s+$REFERENCE_ORDER\s+$WORD\.$WORD""")
+
+    fun parseReferences(dbmlInput: String): List<Reference> {
+        return REFERENCE_REGEX.findAll(dbmlInput).map {
+            Reference(
+                rawValue = it.groups[0]!!.value,
+                fromTable = it.groups[1]!!.value.removeSurroundQuotes(),
+                fromColumn = it.groups[2]!!.value.removeSurroundQuotes(),
+                referenceOrder = ReferenceOrder.fromString(it.groups[3]!!.value),
+                toTable = it.groups[4]!!.value.removeSurroundQuotes(),
+                toColumn = it.groups[5]!!.value.removeSurroundQuotes()
+            )
+        }.toList()
+    }
+
+    private fun String.removeSurroundQuotes(): String {
+        return this.removeSurrounding("\"")
+    }
+}
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
new file mode 100644
index 0000000..c92ab29
--- /dev/null
+++ b/dbml-parser/src/main/kotlin/com/zynger/floorplan/lex/TableParser.kt
@@ -0,0 +1,35 @@
+package com.zynger.floorplan.lex
+
+import com.zynger.floorplan.Table
+import org.intellij.lang.annotations.Language
+
+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|[^}]*)}"""
+    private val TABLE_REGEX = Regex("""[Tt]able\s+$TABLE_NAME\s*$TABLE_ALIAS$TABLE_NOTES\s*$TABLE_CONTENT""")
+
+    fun parseTables(dbmlInput: String): List<Table> {
+        // TODO: aliases and table notes also get parsed; should we update the modeling to include them?
+
+        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
+                }
+            }
+            Table(
+                rawValue = it.groups[0]!!.value,
+                name = tableName,
+                columns = ColumnParser.parseColumns(tableName, tableContent),
+                indexes = emptyList() // TODO include indexes parsing
+            )
+        }.toList()
+    }
+}
\ No newline at end of file
diff --git a/settings.gradle b/settings.gradle
index 01f1655..47bcca6 100644
--- a/settings.gradle
+++ b/settings.gradle
@@ -1,3 +1,4 @@
 rootProject.name = 'floorplan'
 include 'core'
+include 'dbml-parser'
 include 'floorplan-cli'


Test patch:
diff --git a/dbml-parser/src/test/kotlin/com/zynger/floorplan/lex/ColumnParserTest.kt b/dbml-parser/src/test/kotlin/com/zynger/floorplan/lex/ColumnParserTest.kt
new file mode 100644
index 0000000..8cd040d
--- /dev/null
+++ b/dbml-parser/src/test/kotlin/com/zynger/floorplan/lex/ColumnParserTest.kt
@@ -0,0 +1,171 @@
+package com.zynger.floorplan.lex
+
+import com.zynger.floorplan.Column
+import org.junit.Assert.*
+import org.junit.Test
+
+class ColumnParserTest {
+
+    companion object {
+        private const val TABLE_NAME = "myTable"
+    }
+
+    @Test
+    fun `empty columns`() {
+        val columnsInput = """
+        """.trimIndent()
+
+        val columns = ColumnParser.parseColumns(TABLE_NAME, columnsInput)
+
+        assertEquals(emptyList<Column>(), columns)
+    }
+
+    @Test
+    fun `parses name and type`() {
+        val columnsInput = """
+            id int
+            name varchar
+            age int
+            customer bool
+            image blob
+
+        """.trimIndent()
+
+        val columns = ColumnParser.parseColumns(TABLE_NAME, columnsInput)
+
+        assertEquals("id", columns[0].name)
+        assertEquals("int", columns[0].type)
+
+        assertEquals("name", columns[1].name)
+        assertEquals("varchar", columns[1].type)
+
+        assertEquals("age", columns[2].name)
+        assertEquals("int", columns[2].type)
+
+        assertEquals("customer", columns[3].name)
+        assertEquals("bool", columns[3].type)
+
+        assertEquals("image", columns[4].name)
+        assertEquals("blob", columns[4].type)
+    }
+
+    @Test
+    fun `parses name without surrounding quotes`() {
+        val columnsInput = """
+            "id" int
+
+        """.trimIndent()
+
+        val columns = ColumnParser.parseColumns(TABLE_NAME, columnsInput)
+
+        assertEquals("id", columns[0].name)
+        assertEquals("int", columns[0].type)
+    }
+
+    @Test
+    fun `parses primary key`() {
+        val columnsInput = """
+            id int [pk]
+
+        """.trimIndent()
+
+        val columns = ColumnParser.parseColumns(TABLE_NAME, columnsInput)
+
+        assertEquals(true, columns.first().primaryKey)
+    }
+
+    @Test
+    fun `parses not null`() {
+        val columnsInput = """
+            age int [not null]
+
+        """.trimIndent()
+
+        val columns = ColumnParser.parseColumns(TABLE_NAME, columnsInput)
+
+        assertEquals(true, columns.first().notNull)
+    }
+
+    @Test
+    fun `parses primary key, not null and increment`() {
+        val columnsInput = """
+            id int [pk, increment, not null]
+
+        """.trimIndent()
+
+        val columns = ColumnParser.parseColumns(TABLE_NAME, columnsInput)
+
+        assertEquals(true, columns.first().primaryKey)
+        assertEquals(true, columns.first().notNull)
+        assertEquals(true, columns.first().increment)
+    }
+
+    @Test
+    fun `parses primary key, not null and increment ignoring spaces`() {
+        val columnsInput = """
+            id int [pk,increment,         not null]
+
+        """.trimIndent()
+
+        val columns = ColumnParser.parseColumns(TABLE_NAME, columnsInput)
+
+        assertEquals(true, columns.first().primaryKey)
+        assertEquals(true, columns.first().notNull)
+        assertEquals(true, columns.first().increment)
+    }
+
+    @Test
+    fun `parses auto increment`() {
+        val columnsInput = """
+            age int [increment]
+
+        """.trimIndent()
+
+        val columns = ColumnParser.parseColumns(TABLE_NAME, columnsInput)
+
+        assertEquals(true, columns.first().increment)
+    }
+
+    @Test
+    fun `ignores column notes`() {
+        val columnsInput = """
+            age int [increment, note: 'overage above 18']
+
+        """.trimIndent()
+
+        val columns = ColumnParser.parseColumns(TABLE_NAME, columnsInput)
+
+        assertEquals("age", columns.first().name)
+        assertEquals("int", columns.first().type)
+        assertEquals(true, columns.first().increment)
+    }
+
+    @Test
+    fun `ignores multiple spaces after properties`() {
+        val spaces = "      "
+        val columnsInput = """
+            age int [increment]$spaces
+
+        """.trimIndent()
+
+        val columns = ColumnParser.parseColumns(TABLE_NAME, columnsInput)
+
+        assertEquals("age", columns.first().name)
+        assertEquals("int", columns.first().type)
+        assertEquals(true, columns.first().increment)
+    }
+
+    @Test
+    fun `parses multiple columns`() {
+        val columnsInput = """
+            id int [pk, increment, not null]
+            urn varchar [note: 'not null']
+            monetizable int [default: `0`, note: 'nullable']
+  
+        """.trimIndent()
+
+        val columns = ColumnParser.parseColumns(TABLE_NAME, columnsInput)
+
+        assertEquals(3, columns.size)
+    }
+}
\ No newline at end of file
diff --git a/dbml-parser/src/test/kotlin/com/zynger/floorplan/lex/ColumnReferenceParserTest.kt b/dbml-parser/src/test/kotlin/com/zynger/floorplan/lex/ColumnReferenceParserTest.kt
new file mode 100644
index 0000000..6aba668
--- /dev/null
+++ b/dbml-parser/src/test/kotlin/com/zynger/floorplan/lex/ColumnReferenceParserTest.kt
@@ -0,0 +1,61 @@
+package com.zynger.floorplan.lex
+
+import com.zynger.floorplan.Reference
+import com.zynger.floorplan.ReferenceOrder
+import org.junit.Assert.assertEquals
+import org.junit.Test
+
+class ColumnReferenceParserTest {
+
+    companion object {
+        private const val TABLE_NAME = "myTable"
+        private const val FROM_COLUMN_NAME = "myColumn"
+    }
+
+    @Test
+    fun `empty column notes` () {
+        val columnProperties = ""
+        val reference: Reference? = ColumnReferenceParser.parse(TABLE_NAME, FROM_COLUMN_NAME, columnProperties)
+        assertEquals(null, reference)
+    }
+
+    @Test
+    fun `no reference column properties` () {
+        val columnProperties = "[note: 'not null']"
+        val reference: Reference? = ColumnReferenceParser.parse(TABLE_NAME, FROM_COLUMN_NAME, columnProperties)
+        assertEquals(null, reference)
+    }
+
+    @Test
+    fun `irrelevant column properties` () {
+        val columnProperties = "[pk, increment, note: 'ref: something else']"
+        val reference: Reference? = ColumnReferenceParser.parse(TABLE_NAME, FROM_COLUMN_NAME, columnProperties)
+        assertEquals(null, reference)
+    }
+
+    @Test
+    fun `parses reference`() {
+        val columnProperties = "post_id int [not null, Ref: - posts.id]"
+        val reference: Reference? = ColumnReferenceParser.parse(TABLE_NAME, FROM_COLUMN_NAME, columnProperties)
+
+        reference!!
+        assertEquals(TABLE_NAME, reference.fromTable)
+        assertEquals(FROM_COLUMN_NAME, reference.fromColumn)
+        assertEquals("posts", reference.toTable)
+        assertEquals("id", reference.toColumn)
+        assertEquals(ReferenceOrder.OneToOne, reference.referenceOrder)
+    }
+
+    @Test
+    fun `parses reference with quotes surronding names`() {
+        val columnProperties = "post_id int [not null, ref: > \"posts\".\"id\"]"
+        val reference: Reference? = ColumnReferenceParser.parse(TABLE_NAME, FROM_COLUMN_NAME, columnProperties)
+
+        reference!!
+        assertEquals(TABLE_NAME, reference.fromTable)
+        assertEquals(FROM_COLUMN_NAME, reference.fromColumn)
+        assertEquals("posts", reference.toTable)
+        assertEquals("id", reference.toColumn)
+        assertEquals(ReferenceOrder.ManyToOne, reference.referenceOrder)
+    }
+}
diff --git a/dbml-parser/src/test/kotlin/com/zynger/floorplan/lex/LoneReferenceParserTest.kt b/dbml-parser/src/test/kotlin/com/zynger/floorplan/lex/LoneReferenceParserTest.kt
new file mode 100644
index 0000000..b84906c
--- /dev/null
+++ b/dbml-parser/src/test/kotlin/com/zynger/floorplan/lex/LoneReferenceParserTest.kt
@@ -0,0 +1,184 @@
+package com.zynger.floorplan.lex
+
+import com.zynger.floorplan.Reference
+import com.zynger.floorplan.ReferenceOrder
+import org.junit.Assert.assertEquals
+import org.junit.Test
+
+class LoneReferenceParserTest {
+
+    @Test
+    fun `when no references then empty list`() {
+        val input = """
+            Table users {
+              id int [pk, increment]
+            }
+        """.trimIndent()
+        val references = LoneReferenceParser.parseReferences(input)
+
+        assertEquals(
+            emptyList<Reference>(),
+            references
+        )
+    }
+
+    @Test
+    fun `parses single lone reference with lowercase ref`() {
+        val input = """
+            Table posts {
+              id int [pk, increment]
+            }
+            
+            Table tags {
+              id int [pk, increment] 
+              post_id int 
+            }
+            
+            ref: posts.id - tags.post_id
+        """.trimIndent()
+        val references = LoneReferenceParser.parseReferences(input)
+
+        assertEquals(1, references.size)
+        val reference = references.first()
+        assertEquals("posts", reference.fromTable)
+        assertEquals("id", reference.fromColumn)
+        assertEquals("tags", reference.toTable)
+        assertEquals("post_id", reference.toColumn)
+        assertEquals(ReferenceOrder.OneToOne, reference.referenceOrder)
+    }
+
+    @Test
+    fun `parses single lone reference with quotes surronding names`() {
+        val input = """
+            Table posts {
+              id int [pk, increment]
+            }
+            
+            Table tags {
+              id int [pk, increment] 
+              post_id int 
+            }
+            
+            Ref: "posts".id - tags."post_id"
+        """.trimIndent()
+        val references = LoneReferenceParser.parseReferences(input)
+
+        assertEquals(1, references.size)
+        val reference = references.first()
+        assertEquals("posts", reference.fromTable)
+        assertEquals("id", reference.fromColumn)
+        assertEquals("tags", reference.toTable)
+        assertEquals("post_id", reference.toColumn)
+        assertEquals(ReferenceOrder.OneToOne, reference.referenceOrder)
+    }
+
+    @Test
+    fun `parses single lone reference with one to one order`() {
+        val input = """
+            Table posts {
+              id int [pk, increment]
+            }
+            
+            Table tags {
+              id int [pk, increment] 
+              post_id int 
+            }
+            
+            Ref: posts.id - tags.post_id
+        """.trimIndent()
+
+        val references = LoneReferenceParser.parseReferences(input)
+
+        assertEquals(1, references.size)
+        val reference = references.first()
+        assertEquals(ReferenceOrder.OneToOne, reference.referenceOrder)
+    }
+
+    @Test
+    fun `parses single lone reference with one to many order`() {
+        val input = """
+            Table posts {
+              id int [pk, increment]
+            }
+            
+            Table tags {
+              id int [pk, increment] 
+              post_id int 
+            }
+            
+            Ref: posts.id < tags.post_id
+        """.trimIndent()
+
+        val references = LoneReferenceParser.parseReferences(input)
+
+        assertEquals(1, references.size)
+        val reference = references.first()
+        assertEquals(ReferenceOrder.OneToMany, reference.referenceOrder)
+    }
+
+    @Test
+    fun `parses single lone reference with many to one order`() {
+        val input = """
+            Table posts {
+              id int [pk, increment]
+            }
+            
+            Table tags {
+              id int [pk, increment] 
+              post_id int 
+            }
+            
+            Ref: posts.id > tags.post_id
+        """.trimIndent()
+
+        val references = LoneReferenceParser.parseReferences(input)
+
+        assertEquals(1, references.size)
+        val reference = references.first()
+        assertEquals(ReferenceOrder.ManyToOne, reference.referenceOrder)
+    }
+
+    @Test
+    fun `parses multiple lone references`() {
+        val input = """
+            Table posts {
+              id int [pk, increment]
+            }
+            
+            Table tags {
+              id int [pk, increment] 
+              post_id int 
+            }
+            
+            Table users {
+              id int [pk, increment]
+            }
+            
+            Ref: posts.id - tags.post_id
+            Ref: users.id - posts.id
+        """.trimIndent()
+        val references = LoneReferenceParser.parseReferences(input)
+
+        assertEquals(2, references.size)
+    }
+
+    @Test
+    fun `ignores reference notes`() {
+        val input = "Ref: trending_shows.show_id - shows.id [delete: cascade, update: cascade]"
+        val references = LoneReferenceParser.parseReferences(input)
+
+        assertEquals(
+            listOf(
+                Reference(
+                    "Ref: trending_shows.show_id - shows.id",
+                    "trending_shows",
+                    "show_id",
+                    "shows",
+                    "id",
+                    ReferenceOrder.OneToOne
+                )
+            ),
+            references
+        )
+    }
+}
\ 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
new file mode 100644
index 0000000..ed3c144
--- /dev/null
+++ b/dbml-parser/src/test/kotlin/com/zynger/floorplan/lex/TableParserTest.kt
@@ -0,0 +1,97 @@
+package com.zynger.floorplan.lex
+
+import com.zynger.floorplan.Index
+import com.zynger.floorplan.Table
+import org.junit.Assert.*
+import org.junit.Test
+
+class TableParserTest {
+
+    @Test
+    fun `no tables`() {
+        val input = """
+            
+        """.trimIndent()
+
+        val tables = TableParser.parseTables(input)
+
+        assertEquals(emptyList<Table>(), tables)
+    }
+
+    @Test
+    fun `parses name of table`() {
+        val input = """
+            Table posts {
+              id int [pk, increment]
+              title varchar [not null]
+            }
+        """.trimIndent()
+
+        val tables = TableParser.parseTables(input)
+
+        assertEquals("posts", tables[0].name)
+    }
+
+    @Test
+    fun `parses name of multiple tables`() {
+        val input = """
+            Table posts {
+              id int [pk, increment]
+              title varchar [not null]
+            }
+            
+            table comments {
+              id int [pk,increment]
+              comment varchar 
+              post_id int [not null,ref: > posts.id]
+            }
+            table tags as aliasForTagsTable{
+              id int [pk, increment, not null]
+              title varchar [not null]
+            }
+
+        """.trimIndent()
+
+        val tables = TableParser.parseTables(input)
+
+        assertEquals("posts", tables[0].name)
+        assertEquals("comments", tables[1].name)
+        assertEquals("tags", tables[2].name)
+    }
+
+    @Test
+    fun `passes column content to be parsed by delegation`() {
+        val input = """
+            table post_tags [note: 'hey table note']{
+              id int [pk]
+              post_id int
+              tag_id int
+            }
+
+
+        """.trimIndent()
+
+        val tables = TableParser.parseTables(input)
+
+        assertEquals(3, tables.first().columns.size)
+    }
+
+    @Test
+    fun `ignores indexes`() {
+        val input = """
+            table post_tags [note: 'hey table note']{
+              id int [pk]
+              post_id int
+              tag_id int
+
+              Indexes  {
+                (post_id) [name:'index_post_tags_post_id', unique]
+              }
+            }
+        """.trimIndent()
+
+        val tables = TableParser.parseTables(input)
+
+        assertEquals(emptyList<Index>(), tables[0].indexes)
+    }
+}
\ No newline at end of file