From 7ffbc9419112f04fa113b18fe35368ea20625dd0 Mon Sep 17 00:00:00 2001 From: DualFroz Date: Thu, 27 Aug 2026 14:12:19 +0200 Subject: [PATCH] test: cover lexer and parser --- src/test/kotlin/kestrel/LexerTest.kt | 108 ++++++++++++++++ src/test/kotlin/kestrel/ParserTest.kt | 170 ++++++++++++++++++++++++++ 2 files changed, 278 insertions(+) create mode 100644 src/test/kotlin/kestrel/LexerTest.kt create mode 100644 src/test/kotlin/kestrel/ParserTest.kt diff --git a/src/test/kotlin/kestrel/LexerTest.kt b/src/test/kotlin/kestrel/LexerTest.kt new file mode 100644 index 0000000..702395e --- /dev/null +++ b/src/test/kotlin/kestrel/LexerTest.kt @@ -0,0 +1,108 @@ +package kestrel + +import kestrel.sql.lexer.Lexer +import kestrel.sql.lexer.TokenType +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFailsWith +import kotlin.test.assertTrue + +class LexerTest { + private fun lex(sql: String) = Lexer(sql).tokenize() + + private fun types(sql: String) = lex(sql).map { it.type } + + @Test + fun keywordsAreCaseInsensitive() { + assertEquals( + listOf(TokenType.Select, TokenType.Select, TokenType.Select, TokenType.EndOfInput), + types("SELECT select SeLeCt") + ) + } + + @Test + fun identifiersAreDistinctFromKeywords() { + val tokens = lex("users user_name") + assertEquals(TokenType.Identifier, tokens[0].type) + assertEquals("users", tokens[0].lexeme) + assertEquals(TokenType.Identifier, tokens[1].type) + assertEquals("user_name", tokens[1].lexeme) + } + + @Test + fun integerLiteralCarriesValue() { + val token = lex("42").first() + assertEquals(TokenType.IntLiteral, token.type) + assertEquals(42L, token.intValue) + } + + @Test + fun realLiteralCarriesValue() { + val token = lex("3.14").first() + assertEquals(TokenType.RealLiteral, token.type) + assertEquals(3.14, token.realValue) + } + + @Test + fun trailingDotIsNotPartOfNumber() { + assertEquals( + listOf(TokenType.IntLiteral, TokenType.EndOfInput), + types("7") + ) + } + + @Test + fun stringLiteralHandlesDoubledQuoteEscape() { + val token = lex("'it''s ada'").first() + assertEquals(TokenType.TextLiteral, token.type) + assertEquals("it's ada", token.textValue) + } + + @Test + fun comparisonOperatorsAreTwoCharacterAware() { + assertEquals( + listOf( + TokenType.LessEqual, TokenType.NotEqual, TokenType.GreaterEqual, + TokenType.NotEqual, TokenType.Less, TokenType.Greater, TokenType.Equal, + TokenType.EndOfInput + ), + types("<= <> >= != < > =") + ) + } + + @Test + fun lineCommentsAreIgnored() { + assertEquals( + listOf(TokenType.Select, TokenType.Star, TokenType.EndOfInput), + types("SELECT -- pick everything\n *") + ) + } + + @Test + fun blockCommentsAreIgnored() { + assertEquals( + listOf(TokenType.Select, TokenType.Star, TokenType.EndOfInput), + types("SELECT /* inline\n comment */ *") + ) + } + + @Test + fun positionsTrackLineAndColumn() { + val tokens = lex("SELECT\n age") + val ageToken = tokens[1] + assertEquals("age", ageToken.lexeme) + assertEquals(2, ageToken.line) + assertEquals(3, ageToken.column) + } + + @Test + fun unterminatedStringReportsIncomplete() { + val exception = assertFailsWith { lex("'open") } + assertTrue(exception.incomplete) + } + + @Test + fun unexpectedCharacterFails() { + assertFailsWith { lex("SELECT @") } + } +} diff --git a/src/test/kotlin/kestrel/ParserTest.kt b/src/test/kotlin/kestrel/ParserTest.kt new file mode 100644 index 0000000..7771760 --- /dev/null +++ b/src/test/kotlin/kestrel/ParserTest.kt @@ -0,0 +1,170 @@ +package kestrel + +import kestrel.sql.ast.Assignment +import kestrel.sql.ast.BinaryExpression +import kestrel.sql.ast.BinaryOperator +import kestrel.sql.ast.ColumnExpression +import kestrel.sql.ast.CreateTableStatement +import kestrel.sql.ast.DeleteStatement +import kestrel.sql.ast.DropTableStatement +import kestrel.sql.ast.Expression +import kestrel.sql.ast.ExpressionProjection +import kestrel.sql.ast.InsertStatement +import kestrel.sql.ast.LiteralExpression +import kestrel.sql.ast.SelectStatement +import kestrel.sql.ast.StarProjection +import kestrel.sql.ast.Statement +import kestrel.sql.ast.UnaryExpression +import kestrel.sql.ast.UnaryOperator +import kestrel.sql.ast.UpdateStatement +import kestrel.sql.lexer.Lexer +import kestrel.sql.parser.Parser +import kestrel.storage.ColumnType +import kestrel.storage.Value +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFailsWith +import kotlin.test.assertNull +import kotlin.test.assertTrue + +class ParserTest { + private fun parse(sql: String): List = Parser(Lexer(sql).tokenize()).parseProgram() + + private fun parseOne(sql: String): Statement = parse(sql).single() + + private fun intLit(value: Long) = LiteralExpression(Value.IntegerValue(value)) + + private fun whereOf(sql: String): Expression = (parseOne(sql) as SelectStatement).where!! + + private fun firstProjection(sql: String): Expression = + ((parseOne(sql) as SelectStatement).projection as ExpressionProjection).items.first().expression + + @Test + fun parsesCreateTableWithConstraints() { + val statement = parseOne("CREATE TABLE users (id INTEGER PRIMARY KEY, name TEXT NOT NULL, age INTEGER)") + assertTrue(statement is CreateTableStatement) + assertEquals("users", statement.tableName) + assertEquals(3, statement.columns.size) + val id = statement.columns[0] + assertEquals(ColumnType.Integer, id.type) + assertTrue(id.primaryKey) + assertTrue(id.notNull) + val name = statement.columns[1] + assertTrue(name.notNull) + assertTrue(!name.primaryKey) + assertTrue(!statement.columns[2].notNull) + } + + @Test + fun parsesInsertWithoutColumnList() { + val statement = parseOne("INSERT INTO users VALUES (1, 'ada', 36)") + assertTrue(statement is InsertStatement) + assertNull(statement.columns) + assertEquals(1, statement.rows.size) + assertEquals(3, statement.rows[0].size) + } + + @Test + fun parsesInsertWithColumnListAndMultipleRows() { + val statement = parseOne("INSERT INTO t (a, b) VALUES (1, 2), (3, 4), (5, 6)") as InsertStatement + assertEquals(listOf("a", "b"), statement.columns) + assertEquals(3, statement.rows.size) + } + + @Test + fun parsesSelectStar() { + val statement = parseOne("SELECT * FROM users") as SelectStatement + assertEquals(StarProjection, statement.projection) + assertEquals("users", statement.tableName) + assertNull(statement.where) + assertNull(statement.orderBy) + assertNull(statement.limit) + } + + @Test + fun parsesSelectWithAliasWhereOrderLimit() { + val statement = parseOne( + "SELECT name AS who, age FROM users WHERE age > 40 ORDER BY age DESC LIMIT 5" + ) as SelectStatement + val projection = statement.projection as ExpressionProjection + assertEquals("who", projection.items[0].alias) + assertEquals(true, statement.orderBy!!.descending) + assertEquals(5, statement.limit) + } + + @Test + fun parsesDeleteAndDropAndUpdate() { + assertTrue(parseOne("DELETE FROM t WHERE id = 1") is DeleteStatement) + assertTrue(parseOne("DROP TABLE t") is DropTableStatement) + val update = parseOne("UPDATE t SET a = 1, b = a + 2 WHERE id = 3") as UpdateStatement + assertEquals(2, update.assignments.size) + assertEquals(Assignment("a", intLit(1)), update.assignments[0]) + } + + @Test + fun multipleStatementsAreSeparatedBySemicolons() { + val statements = parse("CREATE TABLE t (a INTEGER); DROP TABLE t;") + assertEquals(2, statements.size) + } + + @Test + fun arithmeticHonoursPrecedence() { + val expression = firstProjection("SELECT 1 + 2 * 3 FROM t") + val expected = BinaryExpression( + BinaryOperator.Add, + intLit(1), + BinaryExpression(BinaryOperator.Multiply, intLit(2), intLit(3)) + ) + assertEquals(expected, expression) + } + + @Test + fun notBindsTighterThanAnd() { + val expression = whereOf("SELECT 1 FROM t WHERE NOT a AND b") + val expected = BinaryExpression( + BinaryOperator.And, + UnaryExpression(UnaryOperator.Not, ColumnExpression("a")), + ColumnExpression("b") + ) + assertEquals(expected, expression) + } + + @Test + fun andBindsTighterThanOr() { + val expression = whereOf("SELECT 1 FROM t WHERE a OR b AND c") + val expected = BinaryExpression( + BinaryOperator.Or, + ColumnExpression("a"), + BinaryExpression(BinaryOperator.And, ColumnExpression("b"), ColumnExpression("c")) + ) + assertEquals(expected, expression) + } + + @Test + fun parenthesesOverridePrecedence() { + val expression = firstProjection("SELECT (1 + 2) * 3 FROM t") + val expected = BinaryExpression( + BinaryOperator.Multiply, + BinaryExpression(BinaryOperator.Add, intLit(1), intLit(2)), + intLit(3) + ) + assertEquals(expected, expression) + } + + @Test + fun reportsMissingProjectionWithPosition() { + val exception = assertFailsWith { parse("SELECT FROM t") } + assertEquals(1, exception.line) + assertEquals(8, exception.column) + } + + @Test + fun reportsMissingClosingParen() { + assertFailsWith { parse("CREATE TABLE t (a INTEGER") } + } + + @Test + fun reportsUnknownStatement() { + assertFailsWith { parse("EXPLAIN users") } + } +}