init research

This commit is contained in:
2026-02-08 11:20:43 -10:00
commit bdf064f54d
3041 changed files with 1592200 additions and 0 deletions
@@ -0,0 +1,17 @@
package org.jetbrains.dataframe.gradle
import org.junit.Before
import java.util.Properties
abstract class AbstractDataFramePluginIntegrationTest {
protected val kotlinVersion = TestData.kotlinVersion
protected lateinit var dataframeJarPath: String
@Before
fun before() {
val properties = Properties().also {
it.load(javaClass.getResourceAsStream("df.properties"))
}
dataframeJarPath = properties.getProperty("DATAFRAME_JAR")
}
}
@@ -0,0 +1,86 @@
package org.jetbrains.dataframe.gradle
import io.kotest.matchers.shouldBe
import org.gradle.testkit.runner.TaskOutcome
import org.jetbrains.kotlinx.dataframe.DataFrame
import org.junit.Test
import java.io.File
class ApiChangesDetectionTest : AbstractDataFramePluginIntegrationTest() {
// GenerateDataSchemaTask::class,
// DefaultReadCsvMethod::class,
// DefaultReadJsonMethod::class
@Test
fun `cast api`() {
compiles {
"""
import ${DataFrame::class.qualifiedName!!}
import org.jetbrains.kotlinx.dataframe.api.cast
interface Marker
fun DataFrame<*>.resolveApi() {
cast<Marker>()
cast<Marker>(true)
}
""".trimIndent()
}
}
// GenerateDataSchemaTask::class,
// DefaultReadJsonMethod::class
@Test
fun `read json api`() {
compiles {
"""
import ${DataFrame::class.qualifiedName!!}
import org.jetbrains.kotlinx.dataframe.io.readJson
fun DataFrame<*>.resolveApi(s: String) {
DataFrame.readJson(s)
}
""".trimIndent()
}
}
// GenerateDataSchemaTask::class,
// DefaultReadCsvMethod::class,
@Test
fun `read csv api`() {
compiles {
"""
import ${DataFrame::class.qualifiedName!!}
import org.jetbrains.kotlinx.dataframe.io.readCSV
fun DataFrame<*>.resolveApi(s: String, ch: Char) {
DataFrame.readCSV(s, ch)
}
""".trimIndent()
}
}
private fun compiles(code: () -> String) {
val (_, result) = runGradleBuild(":build") { buildDir ->
val kotlin = File(buildDir, "src/main/kotlin").also { it.mkdirs() }
val main = File(kotlin, "Main.kt")
main.writeText(code())
"""
plugins {
kotlin("jvm") version "$kotlinVersion"
id("org.jetbrains.kotlinx.dataframe")
}
dependencies {
implementation(files("$dataframeJarPath"))
}
repositories {
mavenCentral()
mavenLocal()
}
""".trimIndent()
}
result.task(":build")?.outcome shouldBe TaskOutcome.SUCCESS
}
}
@@ -0,0 +1,685 @@
package org.jetbrains.dataframe.gradle
import io.kotest.assertions.asClue
import io.kotest.matchers.shouldBe
import org.gradle.testkit.runner.TaskOutcome
import org.junit.Ignore
import org.junit.Test
import java.io.File
import java.sql.Connection
import java.sql.DriverManager
class SchemaGeneratorPluginIntegrationTest : AbstractDataFramePluginIntegrationTest() {
private companion object {
private const val FIRST_NAME = "first.csv"
private const val SECOND_NAME = "second.csv"
}
@Test
fun `compileKotlin depends on generateAll task`() {
val (_, result) = runGradleBuild(":compileKotlin") { buildDir ->
File(buildDir, FIRST_NAME).also { it.writeText(TestData.csvSample) }
File(buildDir, SECOND_NAME).also { it.writeText(TestData.csvSample) }
"""
import org.jetbrains.dataframe.gradle.SchemaGeneratorExtension
plugins {
kotlin("jvm") version "$kotlinVersion"
id("org.jetbrains.kotlinx.dataframe")
}
repositories {
mavenCentral()
mavenLocal()
}
dependencies {
implementation(files("$dataframeJarPath"))
}
dataframes {
schema {
data = file("$FIRST_NAME")
name = "Test"
packageName = "org.test"
}
schema {
data = file("$SECOND_NAME")
name = "Schema"
packageName = "org.test"
}
}
""".trimIndent()
}
result.task(":generateDataFrameTest")?.outcome shouldBe TaskOutcome.SUCCESS
result.task(":generateDataFrameSchema")?.outcome shouldBe TaskOutcome.SUCCESS
}
@Test
fun `packageName convention is 'dataframe'`() {
val (dir, result) = runGradleBuild(":build") { buildDir ->
val dataFile = File(buildDir, TestData.csvName)
dataFile.writeText(TestData.csvSample)
"""
import org.jetbrains.dataframe.gradle.SchemaGeneratorExtension
plugins {
kotlin("jvm") version "$kotlinVersion"
id("org.jetbrains.kotlinx.dataframe")
}
repositories {
mavenCentral()
mavenLocal()
}
dependencies {
implementation(files("$dataframeJarPath"))
}
dataframes {
schema {
data = file("${TestData.csvName}")
name = "Data"
}
}
""".trimIndent()
}
result.task(":generateDataFrameData")?.outcome shouldBe TaskOutcome.SUCCESS
File(dir, "build/generated/dataframe").walkBottomUp().toList().asClue {
File(dir, "build/generated/dataframe/main/kotlin/dataframe/Data.Generated.kt").exists() shouldBe true
}
}
@Test
fun `fallback all properties to conventions`() {
val (_, result) = runGradleBuild(":build") { buildDir ->
val dataFile = File(buildDir, TestData.csvName)
dataFile.writeText(TestData.csvSample)
"""
import org.jetbrains.dataframe.gradle.SchemaGeneratorExtension
plugins {
kotlin("jvm") version "$kotlinVersion"
id("org.jetbrains.kotlinx.dataframe")
}
repositories {
mavenCentral()
mavenLocal()
}
dependencies {
implementation(files("$dataframeJarPath"))
}
dataframes {
schema {
data = file("${TestData.csvName}")
}
}
""".trimIndent()
}
result.task(":generateDataFrameData")?.outcome shouldBe TaskOutcome.SUCCESS
}
@Test
fun `generated schemas resolved`() {
val (_, result) = runGradleBuild(":build") { buildDir ->
val dataFile = File(buildDir, "data.csv")
dataFile.writeText(TestData.csvSample)
val kotlin = File(buildDir, "src/main/kotlin").also { it.mkdirs() }
val main = File(kotlin, "Main.kt")
main.writeText(
"""
import org.example.Schema
""".trimIndent(),
)
"""
import org.jetbrains.dataframe.gradle.SchemaGeneratorExtension
plugins {
kotlin("jvm") version "$kotlinVersion"
id("org.jetbrains.kotlinx.dataframe")
}
repositories {
mavenLocal()
mavenCentral()
maven(url="https://jitpack.io")
}
dependencies {
implementation(files("$dataframeJarPath"))
}
kotlin.sourceSets.getByName("main").kotlin.srcDir("build/generated/ksp/main/kotlin/")
dataframes {
schema {
data = file("${TestData.csvName}")
name = "org.example.Schema"
}
}
""".trimIndent()
}
result.task(":build")?.outcome shouldBe TaskOutcome.SUCCESS
}
@Test
fun `generated schemas resolved in jvmMain source set for multiplatform project`() {
val (_, result) = runGradleBuild(":build") { buildDir ->
val dataFile = File(buildDir, "data.csv")
dataFile.writeText(TestData.csvSample)
val kotlin = File(buildDir, "src/jvmMain/kotlin").also { it.mkdirs() }
val main = File(kotlin, "Main.kt")
main.writeText(
"""
import org.example.Schema
""".trimIndent(),
)
"""
import org.jetbrains.dataframe.gradle.SchemaGeneratorExtension
plugins {
kotlin("multiplatform") version "$kotlinVersion"
id("org.jetbrains.kotlinx.dataframe")
}
repositories {
mavenCentral()
mavenLocal()
}
kotlin {
jvm()
sourceSets {
val jvmMain by getting {
dependencies {
implementation(files("$dataframeJarPath"))
}
}
}
}
dataframes {
schema {
data = file("${TestData.csvName}")
name = "org.example.Schema"
}
}
""".trimIndent()
}
result.task(":build")?.outcome shouldBe TaskOutcome.SUCCESS
}
@Ignore
@Test
fun `kotlin identifiers generated from csv names`() {
fun escapeDoubleQuotes(it: Char) = if (it == '"') "\"\"" else it.toString()
val (_, result) = runGradleBuild(":build") { buildDir ->
val filename = "data.csv"
val dataFile = File(buildDir, filename)
val notSupportedChars = setOf('\n', '\r')
(Char.MIN_VALUE..Char.MAX_VALUE).asSequence()
.filterNot { it in notSupportedChars }
.chunked(100) {
it.joinToString(
separator = "",
prefix = "\"",
postfix = "\"",
transform = ::escapeDoubleQuotes,
)
}.let {
dataFile.writeText(it.joinToString(",") + "\n" + (0 until it.count()).joinToString(","))
}
val kotlin = File(buildDir, "src/main/kotlin").also { it.mkdirs() }
val main = File(kotlin, "Main.kt")
main.writeText(
"""
import org.jetbrains.kotlinx.dataframe.DataFrame
import org.jetbrains.kotlinx.dataframe.annotations.DataSchema
import org.jetbrains.kotlinx.dataframe.io.read
import org.jetbrains.kotlinx.dataframe.api.cast
import org.jetbrains.kotlinx.dataframe.api.filter
fun main() {
val df = DataFrame.read("${TestData.csvName}").cast<Schema>()
}
""".trimIndent(),
)
"""
import org.jetbrains.dataframe.gradle.SchemaGeneratorExtension
plugins {
kotlin("jvm") version "$kotlinVersion"
id("org.jetbrains.kotlinx.dataframe")
}
repositories {
mavenLocal()
mavenCentral()
}
dependencies {
implementation(files("$dataframeJarPath"))
}
kotlin.sourceSets.getByName("main").kotlin.srcDir("build/generated/ksp/main/kotlin/")
dataframes {
schema {
data = "${TestData.csvName}"
name = "Schema"
packageName = ""
}
}
""".trimIndent()
}
result.task(":build")?.outcome shouldBe TaskOutcome.SUCCESS
}
@Test
fun `preprocessor generates extensions for DataSchema`() {
val (_, result) = runGradleBuild(":build") { buildDir ->
val dataFile = File(buildDir, "data.csv")
dataFile.writeText(TestData.csvSample)
val kotlin = File(buildDir, "src/main/kotlin").also { it.mkdirs() }
val main = File(kotlin, "Main.kt")
main.writeText(
"""
import org.jetbrains.kotlinx.dataframe.DataFrame
import org.jetbrains.kotlinx.dataframe.annotations.DataSchema
import org.jetbrains.kotlinx.dataframe.io.read
import org.jetbrains.kotlinx.dataframe.api.cast
import org.jetbrains.kotlinx.dataframe.api.filter
@DataSchema
interface MySchema {
val age: Int
}
fun main() {
val df = DataFrame.read("${TestData.csvName}").cast<MySchema>()
val df1 = df.filter { age != null }
}
""".trimIndent(),
)
@Suppress("DuplicatedCode")
"""
import org.jetbrains.dataframe.gradle.SchemaGeneratorExtension
plugins {
kotlin("jvm") version "$kotlinVersion"
id("org.jetbrains.kotlinx.dataframe")
}
repositories {
mavenLocal()
mavenCentral()
}
dependencies {
implementation(files("$dataframeJarPath"))
}
kotlin.sourceSets.getByName("main").kotlin.srcDir("build/generated/ksp/main/kotlin/")
""".trimIndent()
}
result.task(":build")?.outcome shouldBe TaskOutcome.SUCCESS
}
@Test
fun `preprocessor imports schema from local file`() {
val (_, result) = runGradleBuild(":build") { buildDir ->
val dataFile = File(buildDir, "data.csv")
dataFile.writeText(TestData.csvSample)
val kotlin = File(buildDir, "src/main/kotlin").also { it.mkdirs() }
val main = File(kotlin, "Main.kt")
main.writeText(
"""
@file:ImportDataSchema(name = "MySchema", path = "${TestData.csvName}")
package test
import org.jetbrains.kotlinx.dataframe.annotations.ImportDataSchema
import org.jetbrains.kotlinx.dataframe.api.filter
fun main() {
val df = MySchema.readCsv()
val df1 = df.filter { age != null }
}
""".trimIndent(),
)
"""
import org.jetbrains.dataframe.gradle.SchemaGeneratorExtension
plugins {
kotlin("jvm") version "$kotlinVersion"
id("org.jetbrains.kotlinx.dataframe")
}
repositories {
mavenLocal()
mavenCentral()
}
dependencies {
implementation(files("$dataframeJarPath"))
}
kotlin.sourceSets.getByName("main").kotlin.srcDir("build/generated/ksp/main/kotlin/")
""".trimIndent()
}
result.task(":build")?.outcome shouldBe TaskOutcome.SUCCESS
}
/* TODO: test is broken
e: file://test3901867314473689900/src/main/kotlin/Main.kt:12:43 Unresolved reference: readSqlTable
e: file://test3901867314473689900/src/main/kotlin/Main.kt:13:43 Unresolved reference: DbConnectionConfig
e: file://test3901867314473689900/src/main/kotlin/Main.kt:19:28 Unresolved reference: readSqlTable
e: file://test3901867314473689900/src/main/kotlin/Main.kt:20:21 Unresolved reference: age
e: file://test3901867314473689900/src/main/kotlin/Main.kt:22:29 Unresolved reference: readSqlTable
e: file://test3901867314473689900/src/main/kotlin/Main.kt:23:22 Unresolved reference: age
e: file://test3901867314473689900/src/main/kotlin/Main.kt:25:24 Unresolved reference: DbConnectionConfig
e: file://test3901867314473689900/src/main/kotlin/Main.kt:26:29 Unresolved reference: readSqlTable
e: file://test3901867314473689900/src/main/kotlin/Main.kt:27:22 Unresolved reference: age
e: file://test3901867314473689900/src/main/kotlin/Main.kt:29:29 Unresolved reference: readSqlTable
e: file://test3901867314473689900/src/main/kotlin/Main.kt:30:22 Unresolved reference: age
*/
@Test
@Ignore
fun `preprocessor imports schema from database`() {
val connectionUrl = "jdbc:h2:mem:test;DB_CLOSE_DELAY=-1;MODE=MySQL;DATABASE_TO_UPPER=false"
DriverManager.getConnection(connectionUrl).use {
val (_, result) = runGradleBuild(":build") { buildDir ->
createTestDatabase(it)
val kotlin = File(buildDir, "src/main/kotlin").also { it.mkdirs() }
val main = File(kotlin, "Main.kt")
// this is a copy of the code snippet in the
// DataFrameJdbcSymbolProcessorTest.`schema extracted via readFromDB method is resolved`
main.writeText(
"""
@file:ImportDataSchema(name = "Customer", path = "$connectionUrl")
package test
import org.jetbrains.kotlinx.dataframe.annotations.ImportDataSchema
import org.jetbrains.kotlinx.dataframe.api.filter
import org.jetbrains.kotlinx.dataframe.DataFrame
import org.jetbrains.kotlinx.dataframe.api.cast
import java.sql.Connection
import java.sql.DriverManager
import java.sql.SQLException
import org.jetbrains.kotlinx.dataframe.io.readSqlTable
import org.jetbrains.kotlinx.dataframe.io.DbConnectionConfig
fun main() {
Class.forName("org.h2.Driver")
val tableName = "Customer"
DriverManager.getConnection("$connectionUrl").use { connection ->
val df = DataFrame.readSqlTable(connection, tableName).cast<Customer>()
df.filter { age != null && age > 30 }
val df1 = DataFrame.readSqlTable(connection, tableName, 1).cast<Customer>()
df1.filter { age != null && age > 30 }
val dbConfig = DbConnectionConfig(url = "$connectionUrl")
val df2 = DataFrame.readSqlTable(dbConfig, tableName).cast<Customer>()
df2.filter { age != null && age > 30 }
val df3 = DataFrame.readSqlTable(dbConfig, tableName, 1).cast<Customer>()
df3.filter { age != null && age > 30 }
}
}
""".trimIndent(),
)
"""
import org.jetbrains.dataframe.gradle.SchemaGeneratorExtension
plugins {
kotlin("jvm") version "$kotlinVersion"
id("org.jetbrains.kotlinx.dataframe")
}
repositories {
mavenLocal()
mavenCentral()
}
dependencies {
implementation(files("$dataframeJarPath"))
}
kotlin.sourceSets.getByName("main").kotlin.srcDir("build/generated/ksp/main/kotlin/")
""".trimIndent()
}
result.task(":build")?.outcome shouldBe TaskOutcome.SUCCESS
}
}
private fun createTestDatabase(connection: Connection) {
// Crate table Customer
connection.createStatement().execute(
"""
CREATE TABLE Customer (
id INT PRIMARY KEY,
name VARCHAR(50),
age INT
)
""".trimIndent(),
)
// Create table Sale
connection.createStatement().execute(
"""
CREATE TABLE Sale (
id INT PRIMARY KEY,
customerId INT,
amount DECIMAL(10, 2)
)
""".trimIndent(),
)
// add data to the Customer table
connection.createStatement().execute("INSERT INTO Customer (id, name, age) VALUES (1, 'John', 40)")
connection.createStatement().execute("INSERT INTO Customer (id, name, age) VALUES (2, 'Alice', 25)")
connection.createStatement().execute("INSERT INTO Customer (id, name, age) VALUES (3, 'Bob', 47)")
// add data to the Sale table
connection.createStatement().execute("INSERT INTO Sale (id, customerId, amount) VALUES (1, 1, 100.50)")
connection.createStatement().execute("INSERT INTO Sale (id, customerId, amount) VALUES (2, 2, 50.00)")
connection.createStatement().execute("INSERT INTO Sale (id, customerId, amount) VALUES (3, 1, 75.25)")
connection.createStatement().execute("INSERT INTO Sale (id, customerId, amount) VALUES (4, 3, 35.15)")
}
@Test
fun `generated code compiles in explicit api mode`() {
val (_, result) = runGradleBuild(":build") { buildDir ->
val dataFile = File(buildDir, TestData.csvName)
dataFile.writeText(TestData.csvSample)
"""
import org.jetbrains.dataframe.gradle.SchemaGeneratorExtension
plugins {
kotlin("jvm") version "$kotlinVersion"
id("org.jetbrains.kotlinx.dataframe")
}
repositories {
mavenCentral()
mavenLocal()
}
dependencies {
implementation(files("$dataframeJarPath"))
}
kotlin {
explicitApi()
}
dataframes {
schema {
data = file("${TestData.csvName}")
}
}
""".trimIndent()
}
result.task(":generateDataFrameData")?.outcome shouldBe TaskOutcome.SUCCESS
}
@Ignore
@Test
fun `plugin doesn't break multiplatform build without JVM`() {
val (_, result) = runGradleBuild(":build") { buildDir ->
val dataFile = File(buildDir, TestData.csvName)
val kotlin = File(buildDir, "src/jsMain/kotlin").also { it.mkdirs() }
val main = File(kotlin, "Main.kt")
main.writeText(
"""
fun main() {
console.log("Hello, Kotlin/JS!")
}
""".trimIndent(),
)
dataFile.writeText(TestData.csvSample)
"""
import org.jetbrains.dataframe.gradle.SchemaGeneratorExtension
plugins {
kotlin("multiplatform") version "$kotlinVersion"
id("org.jetbrains.kotlinx.dataframe")
}
repositories {
mavenCentral()
mavenLocal()
}
kotlin {
sourceSets {
js(IR) {
browser()
}
}
}
dataframes {
schema {
data = file("${TestData.csvName}")
name = "Schema"
packageName = ""
src = buildDir
}
}
""".trimIndent()
}
result.task(":build")?.outcome shouldBe TaskOutcome.SUCCESS
}
@Test
fun `plugin doesn't break multiplatform build with JVM`() {
val (_, result) = runGradleBuild(":build") { buildDir ->
val dataFile = File(buildDir, TestData.csvName)
val kotlin = File(buildDir, "src/jvmMain/kotlin").also { it.mkdirs() }
val main = File(kotlin, "Main.kt")
main.writeText(
"""
fun main() {
println("Hello, Kotlin/JVM!")
}
""".trimIndent(),
)
dataFile.writeText(TestData.csvSample)
"""
import org.jetbrains.dataframe.gradle.SchemaGeneratorExtension
plugins {
kotlin("multiplatform") version "$kotlinVersion"
id("org.jetbrains.kotlinx.dataframe")
}
repositories {
mavenCentral()
mavenLocal()
}
kotlin {
sourceSets {
jvm()
}
}
dataframes {
schema {
data = file("${TestData.csvName}")
name = "Schema"
packageName = ""
src = buildDir
}
}
""".trimIndent()
}
result.task(":build")?.outcome shouldBe TaskOutcome.SUCCESS
}
@Test
fun `companion object for csv compiles`() {
testCompanionObject(TestData.csvName, TestData.csvSample)
}
@Test
fun `companion object for json compiles`() {
testCompanionObject(TestData.jsonName, TestData.jsonSample)
}
private fun testCompanionObject(dataName: String, dataSample: String) {
val (_, result) = runGradleBuild(":build") { buildDir ->
val dataFile = File(buildDir, dataName)
dataFile.writeText(dataSample)
"""
import org.jetbrains.dataframe.gradle.SchemaGeneratorExtension
plugins {
kotlin("jvm") version "$kotlinVersion"
id("org.jetbrains.kotlinx.dataframe")
}
repositories {
mavenCentral()
mavenLocal()
}
dependencies {
implementation(files("$dataframeJarPath"))
}
dataframes {
schema {
data = file("$dataName")
name = "Schema"
}
}
""".trimIndent()
}
result.task(":build")?.outcome shouldBe TaskOutcome.SUCCESS
}
}
@@ -0,0 +1 @@
DATAFRAME_JAR=%DATAFRAME_JAR%
@@ -0,0 +1,131 @@
package org.jetbrains.dataframe.gradle
import com.google.devtools.ksp.gradle.KspExtension
import org.gradle.api.Plugin
import org.gradle.api.Project
import org.gradle.kotlin.dsl.getByType
import java.util.Properties
@Suppress("unused")
public class ConvenienceSchemaGeneratorPlugin : Plugin<Project> {
public companion object {
/**
* (boolean, default `true`) whether to add KSP plugin
*/
public const val PROP_ADD_KSP: String = "kotlin.dataframe.add.ksp"
/**
* (string, default `null`) comma-delimited list of configurations to add KSP processing to.
* Defaults to guessing configurations based on which kotlin plugin is applied (jvm or multiplatform)
*/
public const val PROP_KSP_CONFIGS: String = "kotlin.dataframe.ksp.configs"
}
override fun apply(target: Project) {
val property = target.findProperty(PROP_ADD_KSP)?.toString()
var addKsp = true
if (property != null) {
if (property.equals("true", ignoreCase = true) || property.equals("false", ignoreCase = true)) {
addKsp = property.toBoolean()
} else {
target.logger.warn(
"Invalid value '$property' for '$PROP_ADD_KSP' property. Defaulting to '$addKsp'. Please use 'true' or 'false'.",
)
}
}
val properties = Properties()
properties.load(javaClass.getResourceAsStream("plugin.properties"))
val preprocessorVersion = properties.getProperty("PREPROCESSOR_VERSION")
// regardless whether we add KSP or the user adds it, when it's added,
// configure it to depend on symbol-processor-all
target.plugins.whenPluginAdded {
if (this::class.qualifiedName?.contains("com.google.devtools.ksp") == true) {
val isMultiplatform by lazy {
when {
target.plugins.hasPlugin("org.jetbrains.kotlin.jvm") -> false
target.plugins.hasPlugin("org.jetbrains.kotlin.multiplatform") -> true
else -> {
target.logger.warn(
"Kotlin plugin must be applied first so we know whether to use multiplatform configurations or not",
)
false
}
}
}
val overriddenConfigs = target.findProperty(PROP_KSP_CONFIGS)
?.let { (it as String) }
?.split(",")
?.map { it.trim() }
val configs = when {
overriddenConfigs != null -> overriddenConfigs
isMultiplatform -> listOf("kspJvm", "kspJvmTest")
else -> listOf("ksp", "kspTest")
}
val cfgsToAdd = configs.toMutableSet()
configs.forEach { cfg ->
target.configurations.findByName(cfg)?.apply {
cfgsToAdd.remove(cfg)
dependencies.add(
target.dependencies.create(
"org.jetbrains.kotlinx.dataframe:symbol-processor-all:$preprocessorVersion",
),
)
}
}
target.gradle.projectsEvaluated {
cfgsToAdd.forEach { cfg ->
target.logger.warn(
"Configuration '$cfg' was never found. Please make sure the KSP plugin is applied.",
)
}
}
target.logger.info("Added DataFrame dependency to the KSP plugin.")
target.extensions.getByType<KspExtension>().arg(
"dataframe.resolutionDir",
target.projectDir.absolutePath,
)
}
}
if (addKsp) {
target.plugins.apply(KspPluginApplier::class.java)
} else {
target.logger.warn(
"Plugin 'org.jetbrains.kotlinx.dataframe' comes bundled with its own version of KSP which is " +
"currently disabled as 'kotlin.dataframe.add.ksp' is set to 'false' in a 'properties' file. " +
"Either set 'kotlin.dataframe.add.ksp' to 'true' or add the plugin 'com.google.devtools.ksp' " +
"manually.",
)
}
target.plugins.apply(SchemaGeneratorPlugin::class.java)
}
}
@Suppress("unused")
public class DeprecatingSchemaGeneratorPlugin : Plugin<Project> {
override fun apply(target: Project) {
target.logger.warn(
"DEPRECATION: Replace plugin id(\"org.jetbrains.kotlin.plugin.dataframe\") and kotlin(\"plugin.dataframe\") with id(\"org.jetbrains.kotlinx.dataframe\").",
)
target.plugins.apply(ConvenienceSchemaGeneratorPlugin::class.java)
}
}
/**
* Applies the KSP plugin in the target project.
*/
internal class KspPluginApplier : Plugin<Project> {
override fun apply(target: Project) {
val properties = Properties()
properties.load(javaClass.getResourceAsStream("plugin.properties"))
target.plugins.apply("com.google.devtools.ksp")
}
}
@@ -0,0 +1,7 @@
package org.jetbrains.dataframe.gradle
public enum class DataSchemaVisibility {
INTERNAL,
IMPLICIT_PUBLIC,
EXPLICIT_PUBLIC,
}
@@ -0,0 +1,21 @@
package org.jetbrains.dataframe.gradle
import java.io.File
import java.net.MalformedURLException
import java.net.URL
internal fun extractFileName(url: URL): String? =
url.path.takeIf { it.isNotEmpty() }
?.substringAfterLast("/")
?.substringBeforeLast(".")
internal fun extractFileName(file: File): String = file.nameWithoutExtension
internal fun extractFileName(path: String): String? =
try {
val url = URL(path)
extractFileName(url)
} catch (e: MalformedURLException) {
val file = File(path)
extractFileName(file)
}
@@ -0,0 +1,10 @@
package org.jetbrains.dataframe.gradle
import java.io.File
internal fun File.isMiddlePackage(): Boolean = isDirectory && (listFiles()?.singleOrNull()?.isDirectory ?: false)
internal fun File.findDeepestCommonSubdirectory(): File {
if (!exists()) return this
return walkTopDown().filterNot { it.isMiddlePackage() }.first()
}
@@ -0,0 +1,278 @@
package org.jetbrains.dataframe.gradle
import org.gradle.api.DefaultTask
import org.gradle.api.provider.Property
import org.gradle.api.provider.Provider
import org.gradle.api.provider.SetProperty
import org.gradle.api.tasks.Input
import org.gradle.api.tasks.OutputFile
import org.gradle.api.tasks.TaskAction
import org.jetbrains.kotlinx.dataframe.codeGen.CodeGenerator
import org.jetbrains.kotlinx.dataframe.codeGen.MarkerVisibility
import org.jetbrains.kotlinx.dataframe.codeGen.NameNormalizer
import org.jetbrains.kotlinx.dataframe.impl.codeGen.CodeGenerationReadResult
import org.jetbrains.kotlinx.dataframe.impl.codeGen.DfReadResult
import org.jetbrains.kotlinx.dataframe.impl.codeGen.from
import org.jetbrains.kotlinx.dataframe.impl.codeGen.toStandaloneSnippet
import org.jetbrains.kotlinx.dataframe.impl.codeGen.urlCodeGenReader
import org.jetbrains.kotlinx.dataframe.impl.codeGen.urlDfReader
import org.jetbrains.kotlinx.dataframe.io.ArrowFeather
import org.jetbrains.kotlinx.dataframe.io.CsvDeephaven
import org.jetbrains.kotlinx.dataframe.io.Excel
import org.jetbrains.kotlinx.dataframe.io.JSON
import org.jetbrains.kotlinx.dataframe.io.OpenApi
import org.jetbrains.kotlinx.dataframe.io.TsvDeephaven
import org.jetbrains.kotlinx.dataframe.io.isUrl
import org.jetbrains.kotlinx.dataframe.io.readSqlQuery
import org.jetbrains.kotlinx.dataframe.io.readSqlTable
import org.jetbrains.kotlinx.dataframe.schema.DataFrameSchema
import java.io.File
import java.net.URL
import java.nio.file.Paths
import java.sql.Connection
import java.sql.DriverManager
public abstract class GenerateDataSchemaTask : DefaultTask() {
@get:Input
public abstract val data: Property<Any>
@get:Input
public abstract val csvOptions: Property<CsvOptionsDsl>
@get:Input
public abstract val jsonOptions: Property<JsonOptionsDsl>
@get:Input
public abstract val jdbcOptions: Property<JdbcOptionsDsl>
@get:Input
public abstract val src: Property<File>
@get:Input
public abstract val interfaceName: Property<String>
@get:Input
public abstract val packageName: Property<String>
@get:Input
public abstract val schemaVisibility: Property<DataSchemaVisibility>
@get:Input
public abstract val defaultPath: Property<Boolean>
@get:Input
public abstract val delimiters: SetProperty<Char>
@get:Input
public abstract val enableExperimentalOpenApi: Property<Boolean>
@Suppress("LeakingThis")
@get:OutputFile
public val dataSchema: Provider<File> = packageName.zip(interfaceName) { packageName, interfaceName ->
val packagePath = packageName.replace('.', File.separatorChar)
Paths.get(src.get().absolutePath, packagePath, "$interfaceName.Generated.kt").toFile()
}
@TaskAction
public fun generate() {
val csvOptions = csvOptions.get()
val jsonOptions = jsonOptions.get()
val jdbcOptions = jdbcOptions.get()
val schemaFile = dataSchema.get()
val escapedPackageName = escapePackageName(packageName.get())
val rawUrl = data.get().toString()
// revisit architecture for an addition of the new data source https://github.com/Kotlin/dataframe/issues/450
if (rawUrl.startsWith("jdbc")) {
val connection = DriverManager.getConnection(rawUrl, jdbcOptions.user, jdbcOptions.password)
connection.use {
val schema = generateSchemaByJdbcOptions(jdbcOptions, connection)
val codeGenerator = CodeGenerator.create(useFqNames = false)
val additionalImports: List<String> = listOf()
val delimiters = delimiters.get()
val codeGenResult = codeGenerator.generate(
schema = schema,
name = interfaceName.get(),
fields = true,
extensionProperties = false,
isOpen = true,
visibility = when (schemaVisibility.get()) {
DataSchemaVisibility.INTERNAL -> MarkerVisibility.INTERNAL
DataSchemaVisibility.IMPLICIT_PUBLIC -> MarkerVisibility.IMPLICIT_PUBLIC
DataSchemaVisibility.EXPLICIT_PUBLIC -> MarkerVisibility.EXPLICIT_PUBLIC
},
readDfMethod = null,
fieldNameNormalizer = NameNormalizer.from(delimiters),
)
schemaFile.writeText(codeGenResult.toStandaloneSnippet(escapedPackageName, additionalImports))
return
}
} else {
val url = urlOf(data.get())
val formats = listOfNotNull(
CsvDeephaven(delimiter = csvOptions.delimiter),
JSON(
typeClashTactic = jsonOptions.typeClashTactic,
keyValuePaths = jsonOptions.keyValuePaths,
unifyNumbers = jsonOptions.unifyNumbers,
),
Excel(),
TsvDeephaven(),
ArrowFeather(),
if (enableExperimentalOpenApi.get()) OpenApi() else null,
)
// first try without creating dataframe
when (val codeGenResult = CodeGenerator.urlCodeGenReader(url, interfaceName.get(), formats, false)) {
is CodeGenerationReadResult.Success -> {
val readDfMethod = codeGenResult.getReadDfMethod(stringOf(data.get()))
val code = codeGenResult
.code
.toStandaloneSnippet(escapedPackageName, readDfMethod.additionalImports)
schemaFile.bufferedWriter().use {
it.write(code)
}
return
}
is CodeGenerationReadResult.Error ->
logger.warn("Error while reading types-only from data at $url: ${codeGenResult.reason}")
}
// on error, try with reading dataframe first
val parsedDf = when (val readResult = CodeGenerator.urlDfReader(url, formats)) {
is DfReadResult.Error -> throw Exception(
"Error while reading dataframe from data at $url",
readResult.reason,
)
is DfReadResult.Success -> readResult
}
val codeGenerator = CodeGenerator.create(useFqNames = false)
val delimiters = delimiters.get()
val readDfMethod = parsedDf.getReadDfMethod(stringOf(data.get()))
val codeGenResult = codeGenerator.generate(
schema = parsedDf.schema,
name = interfaceName.get(),
fields = true,
extensionProperties = false,
isOpen = true,
visibility = when (schemaVisibility.get()) {
DataSchemaVisibility.INTERNAL -> MarkerVisibility.INTERNAL
DataSchemaVisibility.IMPLICIT_PUBLIC -> MarkerVisibility.IMPLICIT_PUBLIC
DataSchemaVisibility.EXPLICIT_PUBLIC -> MarkerVisibility.EXPLICIT_PUBLIC
},
readDfMethod = readDfMethod,
fieldNameNormalizer = NameNormalizer.from(delimiters),
)
schemaFile.writeText(codeGenResult.toStandaloneSnippet(escapedPackageName, readDfMethod.additionalImports))
}
}
// TODO: copy pasted from symbol-processor: DataSchemaGenerator, should be refactored somehow
private fun generateSchemaByJdbcOptions(jdbcOptions: JdbcOptionsDsl, connection: Connection): DataFrameSchema {
logger.debug("Table name: ${jdbcOptions.tableName}")
logger.debug("SQL query: ${jdbcOptions.sqlQuery}")
val tableName = jdbcOptions.tableName
val sqlQuery = jdbcOptions.sqlQuery
return when {
isTableNameNotBlankAndQueryBlank(tableName, sqlQuery) -> generateSchemaForTable(connection, tableName)
isQueryNotBlankAndTableBlank(tableName, sqlQuery) -> generateSchemaForQuery(connection, sqlQuery)
areBothNotBlank(tableName, sqlQuery) -> throwBothFieldsFilledException(tableName, sqlQuery)
else -> throwBothFieldsEmptyException(tableName, sqlQuery)
}
}
private fun isTableNameNotBlankAndQueryBlank(tableName: String, sqlQuery: String) =
tableName.isNotBlank() && sqlQuery.isBlank()
private fun isQueryNotBlankAndTableBlank(tableName: String, sqlQuery: String) =
sqlQuery.isNotBlank() && tableName.isBlank()
private fun areBothNotBlank(tableName: String, sqlQuery: String) = sqlQuery.isNotBlank() && tableName.isNotBlank()
private fun generateSchemaForTable(connection: Connection, tableName: String) =
DataFrameSchema.readSqlTable(connection, tableName)
private fun generateSchemaForQuery(connection: Connection, sqlQuery: String) =
DataFrameSchema.readSqlQuery(connection, sqlQuery)
private fun throwBothFieldsFilledException(tableName: String, sqlQuery: String): Nothing =
throw RuntimeException(
"Table name '$tableName' and SQL query '$sqlQuery' both are filled! " +
"Clear 'tableName' or 'sqlQuery' properties in jdbcOptions with value to generate schema for SQL table or result of SQL query!",
)
private fun throwBothFieldsEmptyException(tableName: String, sqlQuery: String): Nothing =
throw RuntimeException(
"Table name '$tableName' and SQL query '$sqlQuery' both are empty! " +
"Populate 'tableName' or 'sqlQuery' properties in jdbcOptions with value to generate schema for SQL table or result of SQL query!",
)
private fun stringOf(data: Any): String =
when (data) {
is File -> data.absolutePath
is URL -> data.toExternalForm()
is String ->
when {
isUrl(data) -> stringOf(URL(data))
else -> {
val relativeFile = project.file(data)
val absoluteFile = File(data)
stringOf(if (relativeFile.exists()) relativeFile else absoluteFile)
}
}
else -> unsupportedType()
}
// See RegexExpectationsTest
private fun escapePackageName(packageName: String): String =
if (packageName.isNotEmpty()) {
packageName.split(NameChecker.PACKAGE_IDENTIFIER_DELIMITER)
.joinToString(".") { part -> "`$part`" }
} else {
packageName
}
private fun urlOf(data: Any): URL =
when (data) {
is File -> data.toURI()
is URL -> data.toURI()
is String -> when {
isUrl(data) -> URL(data).toURI()
else -> {
val relativeFile = project.file(data)
val absoluteFile = File(data)
if (relativeFile.exists()) {
relativeFile
} else {
absoluteFile
}.toURI()
}
}
else -> unsupportedType()
}.toURL()
private fun unsupportedType(): Nothing =
throw IllegalArgumentException("data for schema \"${interfaceName.get()}\" must be File, URL or String")
}
@@ -0,0 +1,28 @@
package org.jetbrains.dataframe.gradle
internal object NameChecker {
fun checkValidIdentifier(identifiers: String) {
check(identifiers.none { char -> char in NAME_RESTRICTED_CHARS }) {
val illegalChars = NAME_RESTRICTED_CHARS.intersect(identifiers.toSet()).joinToString(",")
"$identifiers contains illegal characters: $illegalChars"
}
}
fun checkValidPackageName(name: String) {
val identifiers = name
.split(PACKAGE_IDENTIFIER_DELIMITER)
.joinToString("")
checkValidIdentifier(identifiers)
}
// https://github.com/JetBrains/kotlin/blob/1.5.30/compiler/frontend.java/src/org/jetbrains/kotlin/resolve/jvm/checkers/JvmSimpleNameBacktickChecker.kt
private val INVALID_CHARS = setOf('.', ';', '[', ']', '/', '<', '>', ':', '\\')
private val DANGEROUS_CHARS = setOf('?', '*', '"', '|', '%')
// QuotedSymbol https://kotlinlang.org/spec/syntax-and-grammar.html#identifiers
private val RESTRICTED_CHARS = setOf('`', '\r', '\n')
private val NAME_RESTRICTED_CHARS = INVALID_CHARS + DANGEROUS_CHARS + RESTRICTED_CHARS
// https://kotlinlang.org/spec/syntax-and-grammar.html#grammar-rule-identifier
val PACKAGE_IDENTIFIER_DELIMITER = "(\\n|\\r\\n)*\\.".toRegex()
}
@@ -0,0 +1,155 @@
@file:Suppress("unused")
package org.jetbrains.dataframe.gradle
import groovy.lang.Closure
import org.gradle.api.Project
import org.jetbrains.kotlinx.dataframe.api.JsonPath
import org.jetbrains.kotlinx.dataframe.io.JSON
import java.io.File
import java.io.Serializable
import java.net.URL
public open class SchemaGeneratorExtension {
public lateinit var project: Project
internal val schemas: MutableList<Schema> = mutableListOf()
public var packageName: String? = null
public var sourceSet: String? = null
public var visibility: DataSchemaVisibility? = null
internal var defaultPath: Boolean? = null
internal var withNormalizationBy: Set<Char>? = null
/** Can be set to `true` to enable experimental OpenAPI 3.0.0 types support */
public var enableExperimentalOpenApi: Boolean = false
public fun schema(config: Schema.() -> Unit) {
val schema = Schema(project).apply(config)
schemas.add(schema)
}
public fun schema(config: Closure<*>) {
val schema = Schema(project)
project.configure(schema, config)
schemas.add(schema)
}
public fun withoutDefaultPath() {
defaultPath = false
}
public fun withNormalizationBy(vararg delimiter: Char) {
withNormalizationBy = delimiter.toSet()
}
// Overload for Groovy.
// It's impossible to call a method with char argument without type cast in groovy, because it only has string literals
public fun withNormalizationBy(delimiter: String) {
withNormalizationBy = delimiter.toSet()
}
public fun withoutNormalization() {
withNormalizationBy = emptySet()
}
public fun enableExperimentalOpenApi(enable: Boolean) {
enableExperimentalOpenApi = enable
}
}
public class Schema(
private val project: Project,
public var data: Any? = null,
public var src: File? = null,
public var name: String? = null,
public var packageName: String? = null,
public var sourceSet: String? = null,
public var visibility: DataSchemaVisibility? = null,
internal var defaultPath: Boolean? = null,
internal var withNormalizationBy: Set<Char>? = null,
public val csvOptions: CsvOptionsDsl = CsvOptionsDsl(),
public val jsonOptions: JsonOptionsDsl = JsonOptionsDsl(),
public val jdbcOptions: JdbcOptionsDsl = JdbcOptionsDsl(),
) {
public fun setData(file: File) {
data = file
}
public fun setData(path: String) {
data = path
}
public fun setData(url: URL) {
data = url
}
public fun csvOptions(config: CsvOptionsDsl.() -> Unit) {
csvOptions.apply(config)
}
public fun csvOptions(config: Closure<*>) {
project.configure(csvOptions, config)
}
public fun jsonOptions(config: JsonOptionsDsl.() -> Unit) {
jsonOptions.apply(config)
}
public fun jsonOptions(config: Closure<*>) {
project.configure(jsonOptions, config)
}
public fun jdbcOptions(config: JdbcOptionsDsl.() -> Unit) {
jdbcOptions.apply(config)
}
public fun jdbcOptions(config: Closure<*>) {
project.configure(jdbcOptions, config)
}
public fun withoutDefaultPath() {
defaultPath = false
}
public fun withDefaultPath() {
defaultPath = true
}
public fun withNormalizationBy(vararg delimiter: Char) {
withNormalizationBy = delimiter.toSet()
}
// Overload for Groovy.
// It's impossible to call a method with char argument without type cast in groovy, because it only has string literals
public fun withNormalizationBy(delimiter: String) {
withNormalizationBy = delimiter.toSet()
}
public fun withoutNormalization() {
withNormalizationBy = emptySet()
}
}
// Without Serializable GradleRunner tests fail
// TODO add more options
public data class CsvOptionsDsl(var delimiter: Char = ',') : Serializable
public data class JsonOptionsDsl(
var typeClashTactic: JSON.TypeClashTactic = JSON.TypeClashTactic.ARRAY_AND_VALUE_COLUMNS,
var keyValuePaths: List<JsonPath> = emptyList(),
var unifyNumbers: Boolean = true,
) : Serializable
/**
* Represents the configuration options for JDBC data source.
*
* @property [user] The username used to authenticate with the database. Default is an empty string.
* @property [password] The password used to authenticate with the database. Default is an empty string.
* @property [tableName] The name of the table to generate schema for. Default is an empty string.
* @property [sqlQuery] The SQL query used to generate schema. Default is an empty string.
*/
public data class JdbcOptionsDsl(
var user: String = "",
var password: String = "",
var tableName: String = "",
var sqlQuery: String = "",
) : Serializable
@@ -0,0 +1,240 @@
package org.jetbrains.dataframe.gradle
import com.google.devtools.ksp.gradle.KspAATask
import com.google.devtools.ksp.gradle.KspTask
import com.google.devtools.ksp.gradle.KspTaskJvm
import org.gradle.api.Plugin
import org.gradle.api.Project
import org.gradle.api.file.FileCollection
import org.gradle.api.logging.LogLevel
import org.gradle.api.tasks.TaskProvider
import org.gradle.internal.logging.services.DefaultLoggingManager
import org.gradle.kotlin.dsl.create
import org.jetbrains.kotlin.gradle.dsl.ExplicitApiMode
import org.jetbrains.kotlin.gradle.dsl.KotlinAndroidProjectExtension
import org.jetbrains.kotlin.gradle.dsl.KotlinJvmProjectExtension
import org.jetbrains.kotlin.gradle.dsl.KotlinMultiplatformExtension
import org.jetbrains.kotlin.gradle.dsl.KotlinProjectExtension
import org.jetbrains.kotlin.gradle.tasks.BaseKotlinCompile
import java.io.File
import java.net.URL
import java.nio.file.Path
import java.nio.file.Paths
import java.util.Locale
public class SchemaGeneratorPlugin : Plugin<Project> {
override fun apply(target: Project) {
val extension = target.extensions.create<SchemaGeneratorExtension>("dataframes")
extension.project = target
target.afterEvaluate {
val appliedPlugin =
KOTLIN_EXTENSIONS.firstNotNullOfOrNull {
target.extensions.findByType(it.extensionClass)?.let { ext -> AppliedPlugin(ext, it) }
}
if (appliedPlugin == null) {
target.logger.warn("Schema generator plugin applied, but no Kotlin plugin was found")
}
val generationTasks = extension.schemas.map {
registerTask(target, extension, appliedPlugin, it)
}
val generateAll = target.tasks.register("generateDataFrames") {
group = GROUP
dependsOn(*generationTasks.toTypedArray())
}
tasks.withType(KspTask::class.java).configureEach {
dependsOn(generateAll)
dependsOn(*generationTasks.toTypedArray())
}
tasks.withType(KspAATask::class.java).configureEach {
error(
"Detected KSP2. This is not supported by the DataFrame Gradle/Ksp plugin. Add 'ksp.useKSP2=false' to 'gradle.properties'.",
)
}
tasks.withType(BaseKotlinCompile::class.java).configureEach {
dependsOn(generateAll)
}
}
}
private fun registerTask(
target: Project,
extension: SchemaGeneratorExtension,
appliedPlugin: AppliedPlugin?,
schema: Schema,
): TaskProvider<GenerateDataSchemaTask> {
val interfaceName = getInterfaceName(schema)
fun propertyError(property: String): Nothing {
error(
"No supported Kotlin plugin was found. Please apply one or specify property $property for schema $interfaceName explicitly",
)
}
val sourceSetName by lazy {
schema.sourceSet
?: extension.sourceSet
?: (appliedPlugin ?: propertyError("sourceSet")).sourceSetConfiguration.defaultSourceSet
}
val src: File = schema.src
?: run {
appliedPlugin ?: propertyError("src")
val sourceSet = appliedPlugin.kotlinExtension.sourceSets.getByName(sourceSetName)
val src = target.file(Paths.get("build/generated/dataframe/", sourceSetName, "kotlin").toFile())
// Add the new sources to the source set
sourceSet.kotlin.srcDir(src)
// Configure the right ksp task to be aware of these new sources
val kspTaskName = "ksp${sourceSetName.replaceFirstChar { it.uppercase() }}Kotlin"
target.tasks.withType(KspTaskJvm::class.java).configureEach {
if ((sourceSetName == "main" && name == "kspKotlin") || name == kspTaskName) {
source(src)
}
}
src
}
val packageName = schema.name?.let { name -> extractPackageName(name) }
?: schema.packageName
?: extension.packageName
?: run {
(appliedPlugin ?: propertyError("packageName"))
val sourceSet = appliedPlugin.kotlinExtension.sourceSets.getByName(sourceSetName)
val path = appliedPlugin.sourceSetConfiguration.getKotlinRoot(
sourceSet.kotlin.sourceDirectories,
sourceSetName,
)
val src = target.file(path)
inferPackageName(src)
}
val visibility = schema.visibility
?: extension.visibility
?: run {
if (appliedPlugin != null) {
when (appliedPlugin.kotlinExtension.explicitApi) {
null -> DataSchemaVisibility.IMPLICIT_PUBLIC
ExplicitApiMode.Strict -> DataSchemaVisibility.EXPLICIT_PUBLIC
ExplicitApiMode.Warning -> DataSchemaVisibility.EXPLICIT_PUBLIC
ExplicitApiMode.Disabled -> DataSchemaVisibility.IMPLICIT_PUBLIC
}
} else {
DataSchemaVisibility.IMPLICIT_PUBLIC
}
}
val defaultPath = schema.defaultPath ?: extension.defaultPath ?: true
val delimiters = schema.withNormalizationBy ?: extension.withNormalizationBy ?: setOf('\t', ' ', '_')
return target.tasks.register("generateDataFrame$interfaceName", GenerateDataSchemaTask::class.java) {
(logging as? DefaultLoggingManager)?.setLevelInternal(LogLevel.QUIET)
group = GROUP
data.set(schema.data)
this.interfaceName.set(interfaceName)
this.packageName.set(packageName)
this.src.set(src)
this.schemaVisibility.set(visibility)
this.csvOptions.set(schema.csvOptions)
this.jsonOptions.set(schema.jsonOptions)
this.jdbcOptions.set(schema.jdbcOptions)
this.defaultPath.set(defaultPath)
this.delimiters.set(delimiters)
this.enableExperimentalOpenApi.set(extension.enableExperimentalOpenApi)
}
}
private fun getInterfaceName(schema: Schema): String? {
val rawName = schema.name?.substringAfterLast('.')
?: fileName(schema.data)
?.toCamelCaseByDelimiters(delimiters)
?.replaceFirstChar { if (it.isLowerCase()) it.titlecase(Locale.getDefault()) else it.toString() }
?.removeSurrounding("`")
?: return null
NameChecker.checkValidIdentifier(rawName)
return rawName
}
private val delimiters = "[\\s_]".toRegex()
private class AppliedPlugin(
val kotlinExtension: KotlinProjectExtension,
val sourceSetConfiguration: SourceSetConfiguration<*>,
)
private class SourceSetConfiguration<T : KotlinProjectExtension>(
val extensionClass: Class<T>,
val defaultSourceSet: String,
) {
fun getKotlinRoot(sourceDirectories: FileCollection, sourceSetName: String): File {
fun sourceSet(lang: String) = Paths.get("src", sourceSetName, lang)
val ktSet = sourceSet("kotlin")
val javaSet = sourceSet("java")
val isKotlinRoot: (Path) -> Boolean = { f -> f.endsWith(ktSet) }
val genericRoot = sourceDirectories.find { isKotlinRoot(it.toPath()) }
if (genericRoot != null) return genericRoot
val androidSpecificRoot = if (extensionClass == KotlinAndroidProjectExtension::class.java) {
val isAndroidKotlinRoot: (Path) -> Boolean = { f -> f.endsWith(javaSet) }
sourceDirectories.find { isAndroidKotlinRoot(it.toPath()) }
} else {
error("Directory '$ktSet' was not found in $sourceSetName. Please, specify 'src' explicitly")
}
return androidSpecificRoot
?: error(
"Directory '$ktSet' or '$javaSet' was not found in $sourceSetName. Please, specify 'src' explicitly",
)
}
}
private fun fileName(data: Any?): String? =
when (data) {
is String -> extractFileName(data)
is URL -> extractFileName(data)
is File -> extractFileName(data)
else -> throw IllegalArgumentException(
"data for schema must be File, URL or String, but was ${data?.javaClass ?: ""}($data)",
)
}
private fun extractPackageName(fqName: String): String? {
val packageName = fqName
.substringBeforeLast('.')
.takeIf { it != fqName }
if (packageName != null) {
NameChecker.checkValidPackageName(packageName)
}
return packageName
}
private fun inferPackageName(root: File): String {
val node = root.findDeepestCommonSubdirectory()
val parentPath = root.absolutePath
return node.absolutePath
.removePrefix(parentPath)
.removePrefix(File.separator)
.replace(File.separatorChar, '.')
.let {
when {
it.isEmpty() -> "dataframe"
it.endsWith(".dataframe") -> it
else -> "$it.dataframe"
}
}
}
private companion object {
private val KOTLIN_EXTENSIONS = sequenceOf(
SourceSetConfiguration(KotlinJvmProjectExtension::class.java, "main"),
SourceSetConfiguration(KotlinMultiplatformExtension::class.java, "jvmMain"),
SourceSetConfiguration(KotlinAndroidProjectExtension::class.java, "main"),
)
private const val GROUP = "dataframe"
}
}
@@ -0,0 +1,15 @@
package org.jetbrains.dataframe.gradle
import java.util.Locale
public fun String.toCamelCaseByDelimiters(delimiters: Regex): String =
split(delimiters)
.joinToCamelCaseString()
.replaceFirstChar { it.lowercase(Locale.getDefault()) }
public fun List<String>.joinToCamelCaseString(): String =
joinToString(separator = "") { s ->
s.replaceFirstChar {
if (it.isLowerCase()) it.titlecase(Locale.getDefault()) else it.toString()
}
}
@@ -0,0 +1 @@
PREPROCESSOR_VERSION=%PREPROCESSOR_VERSION%
@@ -0,0 +1,143 @@
package org.jetbrains.dataframe.gradle
import io.kotest.assertions.asClue
import io.kotest.assertions.throwables.shouldNotThrowAny
import io.kotest.assertions.throwables.shouldThrow
import io.kotest.assertions.throwables.shouldThrowAny
import io.kotest.matchers.shouldBe
import kotlinx.serialization.SerializationException
import org.jetbrains.kotlinx.dataframe.DataFrame
import org.jetbrains.kotlinx.dataframe.api.isEmpty
import org.jetbrains.kotlinx.dataframe.io.read
import org.jetbrains.kotlinx.dataframe.io.readCsv
import org.jetbrains.kotlinx.dataframe.io.readSqlTable
import org.junit.Test
import java.io.File
import java.io.FileNotFoundException
import java.io.IOException
import java.net.URL
import java.nio.file.Files
import java.nio.file.Paths
import java.sql.DriverManager
import kotlin.io.path.absolutePathString
class DataFrameReadTest {
@Test
fun `file that does not exists`() {
val temp = Files.createTempDirectory("").toFile()
val definitelyDoesNotExists = File(temp, "absolutelyRandomName")
shouldThrow<FileNotFoundException> {
DataFrame.read(definitelyDoesNotExists)
}
}
@Test
fun `file with invalid json`() {
val temp = Files.createTempDirectory("").toFile()
val invalidJson = File(temp, "test.json").also { it.writeText(".") }
shouldThrow<IllegalStateException> {
DataFrame.read(invalidJson)
}
}
@Test
fun `file with invalid csv`() {
val temp = Files.createTempDirectory("").toFile()
val invalidCsv = File(temp, "test.csv").also { it.writeText("") }
DataFrame.read(invalidCsv).isEmpty() shouldBe true
}
@Test
fun `invalid url`() {
val exception = shouldThrowAny {
DataFrame.read("http:://example.com")
}
exception.asClue {
(exception is IllegalArgumentException || exception is IOException) shouldBe true
}
}
@Test
fun `valid url`() {
useHostedJson("{}") {
DataFrame.read(URL(it))
}
}
@Test
fun `path that is valid url`() {
useHostedJson("{}") {
DataFrame.read(it)
}
}
@Test
fun `URL with invalid JSON`() {
useHostedJson("<invalid json>") { url ->
shouldThrow<SerializationException> {
DataFrame.read(url).also { println(it) }
}
}
}
@Test
fun `data accessible and readable`() {
shouldNotThrowAny {
DataFrame.readCsv(Paths.get("../../data/jetbrains repositories.csv").absolutePathString(), skipLines = 1)
}
}
@Test
fun `csvSample is valid csv`() {
val temp = Files.createTempFile("f", "csv").toFile()
temp.writeText(TestData.csvSample)
val df = DataFrame.read(temp)
df.columnNames() shouldBe listOf("name", "age")
}
@Test
fun `jdbcSample is valid jdbc`() {
DriverManager.getConnection("jdbc:h2:mem:test;DB_CLOSE_DELAY=-1;MODE=MySQL;DATABASE_TO_UPPER=false")
.use { connection ->
// Create table Customer
connection.createStatement().execute(
"""
CREATE TABLE Customer (
id INT PRIMARY KEY,
name VARCHAR(50),
age INT
)
""".trimIndent(),
)
// Create table Sale
connection.createStatement().execute(
"""
CREATE TABLE Sale (
id INT PRIMARY KEY,
customerId INT,
amount DECIMAL(10, 2)
)
""".trimIndent(),
)
// add data to the Customer table
connection.createStatement().execute("INSERT INTO Customer (id, name, age) VALUES (1, 'John', 40)")
connection.createStatement().execute("INSERT INTO Customer (id, name, age) VALUES (2, 'Alice', 25)")
connection.createStatement().execute("INSERT INTO Customer (id, name, age) VALUES (3, 'Bob', 47)")
// add data to the Sale table
connection.createStatement().execute("INSERT INTO Sale (id, customerId, amount) VALUES (1, 1, 100.50)")
connection.createStatement().execute("INSERT INTO Sale (id, customerId, amount) VALUES (2, 2, 50.00)")
connection.createStatement().execute("INSERT INTO Sale (id, customerId, amount) VALUES (3, 1, 75.25)")
connection.createStatement().execute("INSERT INTO Sale (id, customerId, amount) VALUES (4, 3, 35.15)")
val dfCustomer = DataFrame.readSqlTable(connection, "Customer")
dfCustomer.columnNames() shouldBe listOf("id", "name", "age")
val dfSale = DataFrame.readSqlTable(connection, "Sale")
dfSale.columnNames() shouldBe listOf("id", "customerId", "amount")
}
}
}
@@ -0,0 +1,23 @@
package org.jetbrains.dataframe.gradle
import io.ktor.http.ContentType
import io.ktor.server.application.call
import io.ktor.server.engine.embeddedServer
import io.ktor.server.netty.Netty
import io.ktor.server.response.respondText
import io.ktor.server.routing.get
import io.ktor.server.routing.routing
fun useHostedJson(json: String, f: (url: String) -> Unit) {
// duplicated in ksp/EmbeddedServerRunners.kt
val port = 14771
val server = embeddedServer(Netty, port = port) {
routing {
get("/test.json") {
call.respondText(json, ContentType.Application.Json)
}
}
}.start()
f("http://0.0.0.0:$port/test.json")
server.stop(500, 1000)
}
@@ -0,0 +1,45 @@
package org.jetbrains.dataframe.gradle
import org.junit.Test
import java.io.File
import java.net.URL
class ExtractFileNameTest {
@Test
fun `1`() {
val name =
extractFileName(
"https://raw.githubusercontent.com/Kotlin/dataframe/8ea139c35aaf2247614bb227756d6fdba7359f6a/data/playlistItems.json",
)
assert(name == "playlistItems")
}
@Test
fun `2`() {
val name =
extractFileName(
URL(
"https://raw.githubusercontent.com/Kotlin/dataframe/8ea139c35aaf2247614bb227756d6fdba7359f6a/data/playlistItems.json",
),
)
assert(name == "playlistItems")
}
@Test
fun `3`() {
val name = extractFileName("/etc/example/file.json")
assert(name == "file")
}
@Test
fun `4`() {
val name = extractFileName(File("/etc/example/file.json"))
assert(name == "file")
}
@Test
fun `5`() {
val name = extractFileName("abc")
assert(name == "abc")
}
}
@@ -0,0 +1,8 @@
package org.jetbrains.dataframe.gradle
import io.kotest.matchers.should
import java.io.File
import java.nio.file.Paths
fun File.shouldEndWith(first: String, vararg path: String) =
this should { it.endsWith(Paths.get(first, *path).toFile()) }
@@ -0,0 +1,59 @@
package org.jetbrains.dataframe.gradle
import io.kotest.matchers.shouldBe
import org.junit.Before
import org.junit.Test
import java.io.File
import java.nio.file.Files
internal class FileTraversalTest {
private lateinit var temp: File
@Before
fun init() {
temp = Files.createTempDirectory("temp").toFile()
}
@Test
fun directoryWithFileIsNotMiddlePackage() {
val leaf = File(temp, "a/b/c").also { it.mkdirs() }
val file = File(leaf, "test.txt").also { it.createNewFile() }
leaf.isMiddlePackage() shouldBe false
file.isMiddlePackage() shouldBe false
listOf(leaf, file).filterNot { it.isMiddlePackage() }.first() shouldBe leaf
}
@Test
fun emptySubdirectory() {
val leaf = File(temp, "a/b/c").also { it.mkdirs() }
temp.findDeepestCommonSubdirectory() shouldBe leaf
}
@Test
fun subdirectoryWithFile() {
val leaf = File(temp, "a/b/c").also { it.mkdirs() }
File(leaf, "test.txt").also { it.createNewFile() }
temp.findDeepestCommonSubdirectory() shouldBe leaf
}
@Test
fun forkAtDepth0() {
File(temp, "a/b/c").also { it.mkdirs() }
File(temp, "b/c/d").also { it.mkdirs() }
temp.findDeepestCommonSubdirectory() shouldBe temp
}
@Test
fun forkAtDepth1() {
val a = File(temp, "a").also { it.mkdirs() }
File(a, "b/c").also { it.mkdirs() }
File(a, "c/d").also { it.mkdirs() }
temp.findDeepestCommonSubdirectory() shouldBe a
}
@Test
fun noSubdirectories() {
temp.findDeepestCommonSubdirectory() shouldBe temp
}
}
@@ -0,0 +1,34 @@
package org.jetbrains.dataframe.gradle
import org.gradle.testkit.runner.BuildResult
import org.gradle.testkit.runner.GradleRunner
import org.intellij.lang.annotations.Language
import java.io.File
import java.nio.file.Files
fun runGradleBuild(
task: String,
@Language("kts") settingsGradle: (File) -> String = { "" },
@Language("kts") build: (File) -> String,
): Build {
val buildDir = Files.createTempDirectory("test").toFile()
val buildFile = File(buildDir, "build.gradle.kts")
buildFile.writeText(build(buildDir))
val settingsFile = File(buildDir, "settings.gradle.kts")
settingsFile.writeText(settingsGradle(buildDir))
val propertiesFile = File(buildDir, "gradle.properties")
propertiesFile.writeText("ksp.useKSP2=false")
return Build(buildDir, gradleRunner(buildDir, task).build())
}
fun gradleRunner(buildDir: File, task: String): GradleRunner =
GradleRunner.create()
.withProjectDir(buildDir)
// if we use api from the newest Gradle release, a user project will fail with NoSuchMethod
// testing compatibility with an older Gradle version ensures our plugin can run on as many versions as possible
.withGradleVersion("8.5")
.withPluginClasspath()
.withArguments(task, "--stacktrace", "--info")
.withDebug(true)
data class Build(val buildDir: File, val buildResult: BuildResult)
@@ -0,0 +1,38 @@
package org.jetbrains.dataframe.gradle
import io.kotest.assertions.throwables.shouldThrow
import org.gradle.api.internal.plugins.PluginApplicationException
import org.junit.Test
class KotlinPluginExpectationsTest {
@Test
fun `project can only apply 1 Kotlin plugin 1`() {
val project = makeProject()
project.plugins.apply("org.jetbrains.kotlin.jvm")
shouldThrow<PluginApplicationException> {
project.plugins.apply("org.jetbrains.kotlin.android")
project.evaluate()
}
}
@Test
fun `project can only apply 1 Kotlin plugin 2`() {
val project = makeProject()
project.plugins.apply("org.jetbrains.kotlin.multiplatform")
shouldThrow<PluginApplicationException> {
project.plugins.apply("org.jetbrains.kotlin.android")
project.evaluate()
}
}
@Test
fun `project can only apply 1 Kotlin plugin 3`() {
val project = makeProject()
project.plugins.apply("org.jetbrains.kotlin.multiplatform")
shouldThrow<PluginApplicationException> {
project.plugins.apply("org.jetbrains.kotlin.jvm")
project.evaluate()
}
}
}
@@ -0,0 +1,17 @@
package org.jetbrains.dataframe.gradle
import io.kotest.matchers.shouldBe
import org.junit.Test
class RegexExpectationsTest {
// for package name validation
@Test
fun `regex split returns non empty list for empty string`() {
"".split("123".toRegex()) shouldBe listOf("")
}
@Test
fun `regex split ignore delimiter`() {
"1.2.3".split("\\.".toRegex()) shouldBe listOf("1", "2", "3")
}
}
@@ -0,0 +1,413 @@
package org.jetbrains.dataframe.gradle
import io.kotest.assertions.asClue
import io.kotest.inspectors.forOne
import io.kotest.matchers.shouldBe
import io.kotest.matchers.string.shouldContain
import org.gradle.testkit.runner.TaskOutcome
import org.jetbrains.kotlin.gradle.dsl.KotlinJvmProjectExtension
import org.junit.Test
import java.io.File
import java.nio.file.Files
internal class SchemaGeneratorPluginTest {
private companion object {
private val KOTLIN_VERSION = TestData.kotlinVersion
}
@Test
fun `plugin configured via configure`() {
val (_, result) = runGradleBuild(":generateDataFrameTest") {
// language=kts
"""
import java.net.URI
import org.jetbrains.dataframe.gradle.SchemaGeneratorExtension
plugins {
kotlin("jvm") version "$KOTLIN_VERSION"
id("org.jetbrains.kotlinx.dataframe")
}
repositories {
mavenCentral()
}
configure<SchemaGeneratorExtension> {
schema {
data = URI("https://raw.githubusercontent.com/Kotlin/dataframe/8ea139c35aaf2247614bb227756d6fdba7359f6a/data/playlistItems.json").toURL()
name = "Test"
packageName = "org.test"
}
}
""".trimIndent()
}
result.task(":generateDataFrameTest")?.outcome shouldBe TaskOutcome.SUCCESS
}
@Test
fun `plugin configured via extension DSL`() {
val (_, result) = runGradleBuild(":generateDataFrameTest") {
// language=kts
"""
import java.net.URI
import org.jetbrains.dataframe.gradle.SchemaGeneratorExtension
plugins {
kotlin("jvm") version "$KOTLIN_VERSION"
id("org.jetbrains.kotlinx.dataframe")
}
repositories {
mavenCentral()
}
dataframes {
schema {
data = URI("https://raw.githubusercontent.com/Kotlin/dataframe/8ea139c35aaf2247614bb227756d6fdba7359f6a/data/playlistItems.json").toURL()
name = "Test"
packageName = "org.test"
}
}
""".trimIndent()
}
result.task(":generateDataFrameTest")?.outcome shouldBe TaskOutcome.SUCCESS
}
@Test
fun `plugin configured via extension DSL with Groovy`() {
val buildDir = Files.createTempDirectory("test").toFile()
val buildFile = File(buildDir, "build.gradle")
buildFile.writeText(
// language=groovy
"""
import java.net.URI
import org.jetbrains.dataframe.gradle.SchemaGeneratorExtension
plugins {
id "org.jetbrains.kotlin.jvm" version "$KOTLIN_VERSION"
id "org.jetbrains.kotlinx.dataframe"
}
repositories {
mavenCentral()
}
dataframes {
schema {
data = new URI("https://raw.githubusercontent.com/Kotlin/dataframe/8ea139c35aaf2247614bb227756d6fdba7359f6a/data/playlistItems.json").toURL()
name = "Test"
packageName = "org.test"
}
}
""".trimIndent(),
)
val result = gradleRunner(buildDir, ":generateDataFrameTest").build()
result.task(":generateDataFrameTest")?.outcome shouldBe TaskOutcome.SUCCESS
}
@Test
fun `delimiters configured with Groovy`() {
val buildDir = Files.createTempDirectory("test").toFile()
val buildFile = File(buildDir, "build.gradle")
buildFile.writeText(
// language=groovy
"""
import java.net.URL
import org.jetbrains.dataframe.gradle.SchemaGeneratorExtension
plugins {
id "org.jetbrains.kotlin.jvm" version "$KOTLIN_VERSION"
id "org.jetbrains.kotlinx.dataframe"
}
repositories {
mavenCentral()
}
dataframes {
schema {
data = new URI("https://raw.githubusercontent.com/Kotlin/dataframe/8ea139c35aaf2247614bb227756d6fdba7359f6a/data/playlistItems.json").toURL()
name = "Test"
packageName = "org.test"
withNormalizationBy('-_\t ')
}
}
""".trimIndent(),
)
val result = gradleRunner(buildDir, ":generateDataFrameTest").build()
result.task(":generateDataFrameTest")?.outcome shouldBe TaskOutcome.SUCCESS
}
@Test
fun `plugin configure multiple schemas from URLs via extension`() {
val (_, result) = runGradleBuild(":generateDataFrames") {
// language=kts
"""
import java.net.URI
import org.jetbrains.dataframe.gradle.SchemaGeneratorExtension
plugins {
kotlin("jvm") version "$KOTLIN_VERSION"
id("org.jetbrains.kotlinx.dataframe")
}
repositories {
mavenCentral()
}
dataframes {
schema {
data = URI("https://raw.githubusercontent.com/Kotlin/dataframe/8ea139c35aaf2247614bb227756d6fdba7359f6a/data/playlistItems.json").toURL()
name = "Test"
packageName = "org.test"
}
schema {
data = URI("https://raw.githubusercontent.com/Kotlin/dataframe/master/data/jetbrains_repositories.csv").toURL()
name = "Schema"
packageName = "org.test"
}
}
""".trimIndent()
}
result.task(":generateDataFrameTest")?.outcome shouldBe TaskOutcome.SUCCESS
result.task(":generateDataFrameSchema")?.outcome shouldBe TaskOutcome.SUCCESS
}
@Test
fun `plugin configure multiple schemas from strings via extension`() {
val (_, result) = runGradleBuild(":generateDataFrames") { buildDir ->
File(buildDir, "data").also {
it.mkdirs()
File(it, TestData.csvName).writeText(TestData.csvSample)
File(it, TestData.jsonName).writeText(TestData.jsonSample)
}
// language=kts
"""
import org.jetbrains.dataframe.gradle.SchemaGeneratorExtension
plugins {
kotlin("jvm") version "$KOTLIN_VERSION"
id("org.jetbrains.kotlinx.dataframe")
}
repositories {
mavenCentral()
}
dataframes {
schema {
data = "data/${TestData.csvName}"
name = "Test"
packageName = "org.test"
}
schema {
data = "data/${TestData.jsonName}"
name = "Schema"
packageName = "org.test"
}
}
""".trimIndent()
}
result.task(":generateDataFrameTest")?.outcome shouldBe TaskOutcome.SUCCESS
result.task(":generateDataFrameSchema")?.outcome shouldBe TaskOutcome.SUCCESS
}
@Test
fun `data is string and relative path`() {
val (_, result) = runGradleBuild(":generateDataFrameTest") { buildDir ->
val dataDir = File(buildDir, "data").also { it.mkdirs() }
File(dataDir, TestData.jsonName).writeText(TestData.jsonSample)
// language=kts
"""
import org.jetbrains.dataframe.gradle.SchemaGeneratorExtension
plugins {
kotlin("jvm") version "$KOTLIN_VERSION"
id("org.jetbrains.kotlinx.dataframe")
}
repositories {
mavenCentral()
}
dataframes {
schema {
data = "data/${TestData.jsonName}"
name = "Test"
packageName = "org.test"
}
}
""".trimIndent()
}
result.task(":generateDataFrameTest")?.outcome shouldBe TaskOutcome.SUCCESS
}
@Test
fun `data is string and absolute path`() {
val (_, result) = runGradleBuild(":generateDataFrameTest") { buildDir ->
val dataDir = File(buildDir, "data").also { it.mkdirs() }
val file = File(dataDir, TestData.jsonName).also { it.writeText(TestData.jsonSample) }
val absolutePath = file.absolutePath.replace(File.separatorChar, '/')
// language=kts
"""
import org.jetbrains.dataframe.gradle.SchemaGeneratorExtension
plugins {
kotlin("jvm") version "$KOTLIN_VERSION"
id("org.jetbrains.kotlinx.dataframe")
}
repositories {
mavenCentral()
}
dataframes {
schema {
data = "$absolutePath"
name = "Test"
packageName = "org.test"
}
}
""".trimIndent()
}
result.task(":generateDataFrameTest")?.outcome shouldBe TaskOutcome.SUCCESS
}
@Test
fun `data is string and url`() {
val (_, result) = runGradleBuild(":generateDataFrameTest") { buildDir ->
println("Build dir: $buildDir")
// language=kts
"""
import org.jetbrains.dataframe.gradle.SchemaGeneratorExtension
plugins {
kotlin("jvm") version "$KOTLIN_VERSION"
id("org.jetbrains.kotlinx.dataframe")
}
repositories {
mavenCentral()
}
dataframes {
schema {
data = "https://raw.githubusercontent.com/Kotlin/dataframe/8ea139c35aaf2247614bb227756d6fdba7359f6a/data/ghost.json"
name = "Test"
packageName = "org.test"
}
}
""".trimIndent()
}
result.task(":generateDataFrameTest")?.outcome shouldBe TaskOutcome.SUCCESS
}
@Test
fun `data is OpenApi string and url`() {
val (_, result) = runGradleBuild(":generateDataFrameTest") { buildDir ->
println("Build dir: $buildDir")
// language=kts
"""
import org.jetbrains.dataframe.gradle.SchemaGeneratorExtension
plugins {
kotlin("jvm") version "$KOTLIN_VERSION"
id("org.jetbrains.kotlinx.dataframe")
}
repositories {
mavenCentral()
}
dataframes {
schema {
data = "https://raw.githubusercontent.com/Kotlin/dataframe/2ad1c2f5d27267fa9b2abde1bf611fa50c5abce9/data/petstore.json"
name = "Test"
packageName = "org.test"
}
}
""".trimIndent()
}
result.task(":generateDataFrameTest")?.outcome shouldBe TaskOutcome.SUCCESS
}
@Test
fun `custom csv delimiter`() {
val (buildDir, result) = runGradleBuild(":generateDataFrameTest") { buildDir ->
val csv = "semicolons.csv"
val data = File(buildDir, csv)
data.writeText(
"""
a;b;c
1;2;3
""".trimIndent(),
)
// language=kts
"""
import org.jetbrains.dataframe.gradle.SchemaGeneratorExtension
plugins {
kotlin("jvm") version "$KOTLIN_VERSION"
id("org.jetbrains.kotlinx.dataframe")
}
repositories {
mavenCentral()
}
dataframes {
schema {
data = "$csv"
name = "Test"
packageName = "org.test"
csvOptions {
delimiter = ';'
}
}
}
""".trimIndent()
}
result.task(":generateDataFrameTest")?.outcome shouldBe TaskOutcome.SUCCESS
File(buildDir, "build/generated/dataframe/main/kotlin/org/test/Test.Generated.kt").asClue {
it.readLines().let {
it.forOne {
it.shouldContain("val a")
}
it.forOne {
it.shouldContain("val b")
}
it.forOne {
it.shouldContain("val c")
}
}
}
}
@Test
fun `most specific sourceSet is used in the packageName inference`() {
val project = makeProject()
project.plugins.apply(SchemaGeneratorPlugin::class.java)
project.plugins.apply("org.jetbrains.kotlin.jvm")
project.extensions.getByType(KotlinJvmProjectExtension::class.java).apply {
sourceSets.create("main1")
}
project.extensions.getByType(SchemaGeneratorExtension::class.java).apply {
sourceSet = "main"
schema {
sourceSet = "main1"
data = "123"
name = "321"
}
}
project.file("src/main1/kotlin/org/example/test").also { it.mkdirs() }
project.evaluate()
(project.tasks.getByName("generateDataFrame321") as GenerateDataSchemaTask)
.dataSchema
.get()
.shouldBe(
project.file("build/generated/dataframe/main1/kotlin/org/example/test/dataframe/321.Generated.kt"),
)
}
}
@@ -0,0 +1,30 @@
package org.jetbrains.dataframe.gradle
import io.kotest.inspectors.forAny
import io.kotest.matchers.shouldBe
import org.jetbrains.kotlin.gradle.dsl.KotlinJvmProjectExtension
import org.jetbrains.kotlin.gradle.dsl.KotlinMultiplatformExtension
import org.junit.Test
class SourceSetsExpectationsTest {
@Test
fun `there is main in default JVM project`() {
val project = makeProject()
project.plugins.apply("org.jetbrains.kotlin.jvm")
project.extensions.getByType(KotlinJvmProjectExtension::class.java).let { extension ->
val main = extension.sourceSets.getByName("main")
main.kotlin.sourceDirectories.toList().forAny {
it.shouldEndWith("src", "main", "kotlin")
}
}
}
@Test
fun `there is no jvmMain in default Multiplatform project`() {
val project = makeProject()
project.plugins.apply("org.jetbrains.kotlin.multiplatform")
project.extensions.getByType(KotlinMultiplatformExtension::class.java).let {
it.sourceSets.findByName("jvmMain") shouldBe null
}
}
}
@@ -0,0 +1,21 @@
package org.jetbrains.dataframe.gradle
import org.jetbrains.kotlinx.dataframe.BuildConfig
object TestData {
val csvSample =
"""
name, age
Alice, 15
Bob,
""".trimIndent()
val csvName = "data.csv"
val jsonSample = """{"name": "Test"}"""
val jsonName = "test.json"
val kotlinVersion = BuildConfig.KOTLIN_VERSION
}
@@ -0,0 +1,49 @@
@file:Suppress("UnstableApiUsage")
package org.jetbrains.dataframe.gradle
import org.gradle.api.Project
import org.gradle.api.internal.project.DefaultProject
import org.gradle.api.internal.project.ProjectInternal
import org.gradle.api.provider.Provider
import org.gradle.build.event.BuildEventsListenerRegistry
import org.gradle.internal.service.DefaultServiceRegistry
import org.gradle.internal.service.scopes.Scope
import org.gradle.internal.service.scopes.ServiceScope
import org.gradle.testfixtures.ProjectBuilder
import org.gradle.tooling.events.OperationCompletionListener
import java.lang.reflect.Field
import java.util.concurrent.atomic.AtomicReference
internal fun makeProject(): ProjectInternal {
val project = ProjectBuilder.builder().build() as ProjectInternal
addBuildEventsListenerRegistryMock(project)
return project
}
/**
* In Gradle 6.7-rc-1 BuildEventsListenerRegistry service is not created in we need it in order
* to instantiate AGP. This creates a fake one and injects it - http://b/168630734.
*/
internal fun addBuildEventsListenerRegistryMock(project: Project) {
try {
val projectScopeServices = (project as DefaultProject).services as DefaultServiceRegistry
val state: Field = DefaultServiceRegistry::class.java.getDeclaredField("state")
state.isAccessible = true
@Suppress("UNCHECKED_CAST")
val stateValue: AtomicReference<Any> = state.get(projectScopeServices) as AtomicReference<Any>
val enumClass = Class.forName(DefaultServiceRegistry::class.java.name + "\$State")
stateValue.set(enumClass.enumConstants[0])
// add service and set state so that future mutations are not allowed
projectScopeServices.add(BuildEventsListenerRegistryMock::class.java, BuildEventsListenerRegistryMock)
stateValue.set(enumClass.enumConstants[1])
} catch (e: Throwable) {
throw RuntimeException(e)
}
}
@ServiceScope(Scope.Project::class)
object BuildEventsListenerRegistryMock : BuildEventsListenerRegistry {
override fun onTaskCompletion(listener: Provider<out OperationCompletionListener>?) = Unit
}
@@ -0,0 +1,31 @@
package org.jetbrains.dataframe.gradle.taskProperties
import io.kotest.matchers.shouldBe
import org.jetbrains.dataframe.gradle.GenerateDataSchemaTask
import org.jetbrains.dataframe.gradle.SchemaGeneratorExtension
import org.jetbrains.dataframe.gradle.SchemaGeneratorPlugin
import org.jetbrains.dataframe.gradle.makeProject
import org.junit.Test
class TaskCsvOptionsPropertyTest {
@Test
fun `configure delimiter`() {
val project = makeProject()
project.plugins.apply(SchemaGeneratorPlugin::class.java)
val tab = '\t'
project.extensions.getByType(SchemaGeneratorExtension::class.java).apply {
schema {
data = "/test/data.csv"
name = "org.example.Data"
src = project.projectDir
csvOptions {
delimiter = tab
}
}
}
project.evaluate()
(project.tasks.getByName("generateDataFrameData") as GenerateDataSchemaTask)
.csvOptions.get()
.delimiter shouldBe tab
}
}
@@ -0,0 +1,36 @@
package org.jetbrains.dataframe.gradle.taskProperties
import io.kotest.assertions.throwables.shouldNotThrow
import io.kotest.matchers.shouldBe
import org.gradle.api.ProjectConfigurationException
import org.jetbrains.dataframe.gradle.GenerateDataSchemaTask
import org.jetbrains.dataframe.gradle.SchemaGeneratorExtension
import org.jetbrains.dataframe.gradle.SchemaGeneratorPlugin
import org.jetbrains.dataframe.gradle.makeProject
import org.jetbrains.kotlin.gradle.dsl.KotlinJvmProjectExtension
import org.junit.Test
class TaskDataSchemaPropertyTest {
@Test
fun `extension sourceSet present in project`() {
val project = makeProject()
project.plugins.apply(SchemaGeneratorPlugin::class.java)
project.plugins.apply("org.jetbrains.kotlin.jvm")
project.extensions.getByType(KotlinJvmProjectExtension::class.java).apply {
sourceSets.create("main1")
}
project.extensions.getByType(SchemaGeneratorExtension::class.java).apply {
sourceSet = "main1"
schema {
data = "123"
name = "org.example.my.321"
}
}
shouldNotThrow<ProjectConfigurationException> {
project.evaluate()
}
(project.tasks.getByName("generateDataFrame321") as GenerateDataSchemaTask)
.dataSchema.get()
.shouldBe(project.file("build/generated/dataframe/main1/kotlin/org/example/my/321.Generated.kt"))
}
}
@@ -0,0 +1,93 @@
package org.jetbrains.dataframe.gradle.taskProperties
import io.kotest.assertions.throwables.shouldThrow
import io.kotest.matchers.shouldNotBe
import io.kotest.matchers.string.shouldContain
import org.gradle.api.ProjectConfigurationException
import org.jetbrains.dataframe.gradle.SchemaGeneratorExtension
import org.jetbrains.dataframe.gradle.SchemaGeneratorPlugin
import org.jetbrains.dataframe.gradle.makeProject
import org.junit.Test
class TaskNamePropertyTest {
@Test
fun `task name is last part of FQ name`() {
val project = makeProject()
project.plugins.apply(SchemaGeneratorPlugin::class.java)
project.extensions.getByType(SchemaGeneratorExtension::class.java).apply {
schema {
data = "123"
name = "org.example.my.321"
src = project.projectDir
}
}
project.evaluate()
(project.tasks.findByName("generateDataFrame321") shouldNotBe null)
}
@Test
fun `name from task property have higher priority then inferred from data`() {
val project = makeProject()
project.plugins.apply(SchemaGeneratorPlugin::class.java)
project.extensions.getByType(SchemaGeneratorExtension::class.java).apply {
schema {
data = "/test/data.json"
name = "org.example.my.321"
src = project.projectDir
}
}
project.evaluate()
(project.tasks.findByName("generateDataFrame321") shouldNotBe null)
}
@Test
fun `task name contains invalid characters`() {
val project = makeProject()
project.plugins.apply(SchemaGeneratorPlugin::class.java)
project.extensions.getByType(SchemaGeneratorExtension::class.java).apply {
schema {
data = "123"
name = "org.test.example.[321]"
src = project.projectDir
}
}
val exception = shouldThrow<ProjectConfigurationException> {
project.evaluate()
}
exception.causes.single().message shouldContain "[321] contains illegal characters: [,]"
}
@Test
fun `data name should not override invalid name`() {
val project = makeProject()
project.plugins.apply(SchemaGeneratorPlugin::class.java)
project.extensions.getByType(SchemaGeneratorExtension::class.java).apply {
schema {
data =
"https://datalore-samples.s3-eu-west-1.amazonaws.com/datalore_gallery_of_samples/city_population.csv"
name = "org.test.example.[321]"
src = project.projectDir
}
}
val exception = shouldThrow<ProjectConfigurationException> {
project.evaluate()
}
exception.causes.single().message shouldContain "[321] contains illegal characters: [,]"
}
@Test
fun `name convention is data file name`() {
val project = makeProject()
project.plugins.apply(SchemaGeneratorPlugin::class.java)
project.extensions.getByType(SchemaGeneratorExtension::class.java).apply {
schema {
data =
"https://datalore-samples.s3-eu-west-1.amazonaws.com/datalore_gallery_of_samples/city_population.csv"
packageName = ""
src = project.projectDir
}
}
project.evaluate()
project.tasks.getByName("generateDataFrameCityPopulation") shouldNotBe null
}
}
@@ -0,0 +1,131 @@
package org.jetbrains.dataframe.gradle.taskProperties
import io.kotest.assertions.throwables.shouldThrow
import io.kotest.matchers.shouldBe
import org.gradle.api.ProjectConfigurationException
import org.jetbrains.dataframe.gradle.GenerateDataSchemaTask
import org.jetbrains.dataframe.gradle.SchemaGeneratorExtension
import org.jetbrains.dataframe.gradle.SchemaGeneratorPlugin
import org.jetbrains.dataframe.gradle.makeProject
import org.junit.Test
import java.io.File
class TaskPackageNamePropertyTest {
@Test
fun `task inherit default packageName from extension`() {
val project = makeProject()
project.plugins.apply(SchemaGeneratorPlugin::class.java)
project.extensions.getByType(SchemaGeneratorExtension::class.java).apply {
packageName = "org.example.test"
schema {
data = "123"
name = "321"
src = project.projectDir
}
}
project.evaluate()
(project.tasks.getByName("generateDataFrame321") as GenerateDataSchemaTask)
.packageName.get() shouldBe "org.example.test"
}
@Test
fun `task packageName overrides packageName from extension`() {
val project = makeProject()
project.plugins.apply(SchemaGeneratorPlugin::class.java)
project.extensions.getByType(SchemaGeneratorExtension::class.java).apply {
packageName = "org.example.test"
schema {
data = "123"
packageName = "org.example.my"
name = "321"
src = project.projectDir
}
}
project.evaluate()
(project.tasks.getByName("generateDataFrame321") as GenerateDataSchemaTask)
.packageName.get() shouldBe "org.example.my"
}
@Test
fun `task packageName convention is package part of FQ name`() {
val project = makeProject()
project.plugins.apply(SchemaGeneratorPlugin::class.java)
project.extensions.getByType(SchemaGeneratorExtension::class.java).apply {
packageName = "org.example.test"
schema {
data = "123"
name = "org.example.my.321"
src = project.projectDir
}
}
project.evaluate()
(project.tasks.findByName("generateDataFrame321") as GenerateDataSchemaTask)
.packageName.get() shouldBe "org.example.my"
}
@Test
fun `name package part overrides packageName`() {
val project = makeProject()
project.plugins.apply(SchemaGeneratorPlugin::class.java)
project.extensions.getByType(SchemaGeneratorExtension::class.java).apply {
schema {
data = "123"
packageName = "org.example.test"
name = "org.example.my.321"
src = project.projectDir
}
}
project.evaluate()
(project.tasks.findByName("generateDataFrame321") as GenerateDataSchemaTask)
.packageName.get() shouldBe "org.example.my"
}
@Test
fun `illegal characters in package part of name cause exception`() {
val project = makeProject()
project.plugins.apply(SchemaGeneratorPlugin::class.java)
project.extensions.getByType(SchemaGeneratorExtension::class.java).apply {
schema {
data = "123"
name = "`[org]`.321"
}
}
shouldThrow<ProjectConfigurationException> {
project.evaluate()
}
}
@Test
fun `task infers packageName from directory structure`() {
val project = makeProject()
project.plugins.apply(SchemaGeneratorPlugin::class.java)
project.plugins.apply("org.jetbrains.kotlin.jvm")
File(project.projectDir, "/src/main/kotlin/org/test/").also { it.mkdirs() }
project.extensions.getByType(SchemaGeneratorExtension::class.java).apply {
schema {
data = "123"
name = "321"
}
}
project.evaluate()
(project.tasks.getByName("generateDataFrame321") as GenerateDataSchemaTask)
.packageName.get() shouldBe "org.test.dataframe"
}
@Test
fun `task will not add _dataframe_ if inferred package ends with _dataframe_`() {
val project = makeProject()
project.plugins.apply(SchemaGeneratorPlugin::class.java)
project.plugins.apply("org.jetbrains.kotlin.jvm")
File(project.projectDir, "/src/main/kotlin/org/dataframe/").also { it.mkdirs() }
project.extensions.getByType(SchemaGeneratorExtension::class.java).apply {
schema {
data = "123"
name = "321"
}
}
project.evaluate()
(project.tasks.getByName("generateDataFrame321") as GenerateDataSchemaTask)
.packageName.get() shouldBe "org.dataframe"
}
}
@@ -0,0 +1,164 @@
package org.jetbrains.dataframe.gradle.taskProperties
import io.kotest.assertions.throwables.shouldNotThrow
import io.kotest.assertions.throwables.shouldThrow
import io.kotest.inspectors.forOne
import io.kotest.matchers.collections.shouldHaveSize
import io.kotest.matchers.shouldBe
import io.kotest.matchers.string.shouldContain
import org.gradle.api.ProjectConfigurationException
import org.jetbrains.dataframe.gradle.GenerateDataSchemaTask
import org.jetbrains.dataframe.gradle.SchemaGeneratorExtension
import org.jetbrains.dataframe.gradle.SchemaGeneratorPlugin
import org.jetbrains.dataframe.gradle.makeProject
import org.jetbrains.kotlin.gradle.dsl.KotlinJvmProjectExtension
import org.junit.Test
class TaskSourceSetPropertyTest {
@Test
fun `extension sourceSet present in project`() {
val project = makeProject()
project.plugins.apply(SchemaGeneratorPlugin::class.java)
project.plugins.apply("org.jetbrains.kotlin.jvm")
project.extensions.getByType(KotlinJvmProjectExtension::class.java).apply {
sourceSets.create("main1")
}
project.extensions.getByType(SchemaGeneratorExtension::class.java).apply {
sourceSet = "main1"
schema {
data = "123"
name = "org.example.my.321"
}
}
shouldNotThrow<ProjectConfigurationException> {
project.evaluate()
}
(project.tasks.getByName("generateDataFrame321") as GenerateDataSchemaTask)
.src.get()
.shouldBe(project.file("build/generated/dataframe/main1/kotlin/"))
}
@Test
fun `extension sourceSet not present in project`() {
val project = makeProject()
project.plugins.apply(SchemaGeneratorPlugin::class.java)
project.plugins.apply("org.jetbrains.kotlin.jvm")
project.extensions.getByType(SchemaGeneratorExtension::class.java).apply {
sourceSet = "main1"
schema {
data = "123"
name = "org.example.my.321"
}
}
val exception = shouldThrow<ProjectConfigurationException> {
project.evaluate()
}
exception.causes.shouldHaveSize(1)
exception.causes.forOne { it.message shouldContain "KotlinSourceSet with name 'main1' not found" }
}
@Test
fun `extension sourceSet not specified`() {
val project = makeProject()
project.plugins.apply(SchemaGeneratorPlugin::class.java)
project.plugins.apply("org.jetbrains.kotlin.jvm")
project.extensions.getByType(SchemaGeneratorExtension::class.java).apply {
schema {
data = "123"
name = "org.example.my.321"
}
}
project.evaluate()
(project.tasks.getByName("generateDataFrame321") as GenerateDataSchemaTask)
.src.get()
.shouldBe(project.file("build/generated/dataframe/main/kotlin/"))
}
@Test
fun `choose most specific sourceSet`() {
val project = makeProject()
project.plugins.apply(SchemaGeneratorPlugin::class.java)
project.plugins.apply("org.jetbrains.kotlin.jvm")
project.extensions.getByType(KotlinJvmProjectExtension::class.java).apply {
sourceSets.create("main1")
}
project.extensions.getByType(SchemaGeneratorExtension::class.java).apply {
sourceSet = "main"
schema {
sourceSet = "main1"
data = "123"
name = "321"
}
}
project.evaluate()
(project.tasks.getByName("generateDataFrame321") as GenerateDataSchemaTask)
.src.get()
.shouldBe(project.file("build/generated/dataframe/main1/kotlin/"))
}
@Test
fun `extension sourceSet specified but no kotlin plugin found`() {
val project = makeProject()
project.plugins.apply(SchemaGeneratorPlugin::class.java)
project.extensions.getByType(SchemaGeneratorExtension::class.java).apply {
packageName = "org.example.test"
sourceSet = "myMain"
schema {
data = "123"
name = "org.example.my.321"
}
}
val exception = shouldThrow<ProjectConfigurationException> {
project.evaluate()
}
exception.causes.shouldHaveSize(1)
exception.causes.forOne {
it.message shouldContain
"No supported Kotlin plugin was found. Please apply one or specify property src for schema 321 explicitly"
}
}
@Test
fun `task with explicit src don't evaluates sourceSet`() {
val project = makeProject()
project.plugins.apply(SchemaGeneratorPlugin::class.java)
project.extensions.getByType(SchemaGeneratorExtension::class.java).apply {
packageName = "org.example.test"
sourceSet = "myMain"
schema {
data = "123"
src = project.file("src/main/kotlin")
name = "org.example.my.321"
}
}
shouldNotThrow<ProjectConfigurationException> {
project.evaluate()
}
(project.tasks.getByName("generateDataFrame321") as GenerateDataSchemaTask)
.dataSchema.get()
.shouldBe(project.file("src/main/kotlin/org/example/my/321.Generated.kt"))
}
@Test
fun `task and extension sourceSet not present in project`() {
val project = makeProject()
project.plugins.apply(SchemaGeneratorPlugin::class.java)
project.extensions.getByType(SchemaGeneratorExtension::class.java).apply {
packageName = "org.example.test"
sourceSet = "myMain"
schema {
data = "123"
sourceSet = "myMain1"
name = "org.example.my.321"
}
}
val exception = shouldThrow<ProjectConfigurationException> {
project.evaluate()
}
exception.causes.shouldHaveSize(1)
exception.causes.forOne {
it.message shouldContain
"No supported Kotlin plugin was found. Please apply one or specify property src for schema 321 explicitly"
}
}
}
@@ -0,0 +1,81 @@
package org.jetbrains.dataframe.gradle.taskProperties
import io.kotest.assertions.throwables.shouldNotThrow
import io.kotest.matchers.shouldBe
import org.gradle.api.ProjectConfigurationException
import org.jetbrains.dataframe.gradle.DataSchemaVisibility
import org.jetbrains.dataframe.gradle.GenerateDataSchemaTask
import org.jetbrains.dataframe.gradle.SchemaGeneratorExtension
import org.jetbrains.dataframe.gradle.SchemaGeneratorPlugin
import org.jetbrains.dataframe.gradle.makeProject
import org.jetbrains.kotlin.gradle.dsl.KotlinJvmProjectExtension
import org.junit.Test
class TaskVisibilityPropertyTest {
@Test
fun `extension sourceSet present in project 1 `() {
val project = makeProject()
project.plugins.apply(SchemaGeneratorPlugin::class.java)
project.extensions.getByType(SchemaGeneratorExtension::class.java).apply {
sourceSet = "main1"
visibility = DataSchemaVisibility.INTERNAL
schema {
src = project.file("src")
data = "123"
name = "org.example.my.321"
}
}
shouldNotThrow<ProjectConfigurationException> {
project.evaluate()
}
(project.tasks.getByName("generateDataFrame321") as GenerateDataSchemaTask)
.schemaVisibility.get()
.shouldBe(DataSchemaVisibility.INTERNAL)
}
@Test
fun `extension sourceSet present in project 2`() {
val project = makeProject()
project.plugins.apply(SchemaGeneratorPlugin::class.java)
project.extensions.getByType(SchemaGeneratorExtension::class.java).apply {
sourceSet = "main1"
visibility = DataSchemaVisibility.INTERNAL
schema {
src = project.file("src")
visibility = DataSchemaVisibility.EXPLICIT_PUBLIC
data = "123"
name = "org.example.my.321"
}
}
shouldNotThrow<ProjectConfigurationException> {
project.evaluate()
}
(project.tasks.getByName("generateDataFrame321") as GenerateDataSchemaTask)
.schemaVisibility.get()
.shouldBe(DataSchemaVisibility.EXPLICIT_PUBLIC)
}
@Test
fun `extension sourceSet present in project`() {
val project = makeProject()
project.plugins.apply(SchemaGeneratorPlugin::class.java)
project.plugins.apply("org.jetbrains.kotlin.jvm")
project.extensions.getByType(KotlinJvmProjectExtension::class.java).apply {
sourceSets.create("main1")
explicitApi()
}
project.extensions.getByType(SchemaGeneratorExtension::class.java).apply {
sourceSet = "main1"
schema {
data = "123"
name = "org.example.my.321"
}
}
shouldNotThrow<ProjectConfigurationException> {
project.evaluate()
}
(project.tasks.getByName("generateDataFrame321") as GenerateDataSchemaTask)
.schemaVisibility.get()
.shouldBe(DataSchemaVisibility.EXPLICIT_PUBLIC)
}
}