Skip to content

Introduce KSP compiler for generating columns via @Selectable annotation #769

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Open
wants to merge 19 commits into
base: master
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
56 changes: 56 additions & 0 deletions KSP/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,56 @@
# Supabase KSP compiler

Currently only supports generating columns for `@Selectable` data classes.

To install it, add the KSP Gradle plugin to your project:

```kotlin
plugins {
id("com.google.devtools.ksp") version "2.1.21-2.0.2" //kotlinVersion-kspVersion
}
```

Then add the Supabase KSP compiler to your dependencies:

[**JVM**](https://kotlinlang.org/docs/ksp-quickstart.html#add-a-processor):

```kotlin
depdencies {
ksp("io.github.jan-tennert.supabase:ksp-compiler:VERSION")
}
```

[**Multiplatform**](https://kotlinlang.org/docs/ksp-multiplatform.html):

```kotlin
kotlin {
//...
jvm()
androidTarget()
iosX64()
//...

//Might need to add this if you cannot see generated code in your IDE
sourceSets.named("commonMain").configure {
kotlin.srcDir("build/generated/ksp/metadata/commonMain/kotlin")
}
}

dependencies {
//Add KSP compiler to all targets that need processing
//Advised to use Gradle Version Catalogs
add("kspCommonMainMetadata", "io.github.jan-tennert.supabase:ksp-compiler:VERSION")

//Note: If only the common target needs processing, you don't need the following lines
add("kspJvm", "io.github.jan-tennert.supabase:ksp-compiler:VERSION")
add("kspAndroid", "io.github.jan-tennert.supabase:ksp-compiler:VERSION")
add("kspIosX64", "io.github.jan-tennert.supabase:ksp-compiler:VERSION")
}

//Might need to add this if you cannot see generated code in your IDE
project.tasks.withType(KotlinCompilationTask::class.java).configureEach {
if(name != "kspCommonMainKotlinMetadata") {
dependsOn("kspCommonMainKotlinMetadata")
}
}
```
21 changes: 21 additions & 0 deletions KSP/build.gradle.kts
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
import org.jetbrains.kotlin.gradle.tasks.KotlinCompilationTask

plugins {
id(libs.plugins.kotlin.jvm.get().pluginId)
}

repositories {
mavenCentral()
}

dependencies {
implementation(libs.ksp)
implementation(libs.kotlin.poet)
implementation(libs.kotlin.poet.ksp)
implementation(project(":postgrest-kt"))
}

tasks.named<KotlinCompilationTask<*>>("compileKotlin").configure {
compilerOptions.freeCompilerArgs.add("-opt-in=io.github.jan.supabase.annotations.SupabaseInternal")
compilerOptions.freeCompilerArgs.add("-opt-in=io.github.jan.supabase.annotations.SupabaseExperimental")
}
12 changes: 12 additions & 0 deletions KSP/src/main/kotlin/io/github/jan/supabase/ksp/ColumnOptions.kt
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
package io.github.jan.supabase.ksp

data class ColumnOptions(
val alias: String,
val columnName: String?,
val isForeign: Boolean,
val jsonPath: List<String>?,
val returnAsText: Boolean,
val function: String?,
val cast: String?,
val innerColumns: String,
)
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
package io.github.jan.supabase.ksp

//Used for auto-casting, when no type is specified
val primitiveColumnTypes = mapOf<String, String>(
"kotlin.String" to "text",
"kotlin.Int" to "int4",
"kotlin.Long" to "int8",
"kotlin.Float" to "float4",
"kotlin.Double" to "float8",
"kotlin.Boolean" to "bool",
"kotlin.Byte" to "int2",
"kotlin.Short" to "int2",
"kotlin.Char" to "char",
"kotlinx.datetime.Instant" to "timestamptz",
"kotlinx.datetime.LocalDateTime" to "timestamp",
"kotlin.uuid.Uuid" to "uuid",
"kotlinx.datetime.LocalTime" to "time",
"kotlinx.datetime.LocalDate" to "date",
"kotlinx.serialization.json.JsonElement" to "jsonb",
"kotlinx.serialization.json.JsonObject" to "jsonb",
"kotlinx.serialization.json.JsonArray" to "jsonb",
"kotlinx.serialization.json.JsonPrimitive" to "jsonb",
)

fun isPrimitive(qualifiedName: String) = primitiveColumnTypes.containsKey(qualifiedName)
Original file line number Diff line number Diff line change
@@ -0,0 +1,232 @@
package io.github.jan.supabase.ksp

import com.google.devtools.ksp.processing.CodeGenerator
import com.google.devtools.ksp.processing.Dependencies
import com.google.devtools.ksp.processing.KSPLogger
import com.google.devtools.ksp.processing.Resolver
import com.google.devtools.ksp.processing.SymbolProcessor
import com.google.devtools.ksp.symbol.KSAnnotated
import com.google.devtools.ksp.symbol.KSClassDeclaration
import com.google.devtools.ksp.symbol.KSFile
import com.google.devtools.ksp.symbol.KSNode
import com.google.devtools.ksp.symbol.KSValueParameter
import com.google.devtools.ksp.symbol.Modifier
import com.squareup.kotlinpoet.AnnotationSpec
import com.squareup.kotlinpoet.ClassName
import com.squareup.kotlinpoet.FileSpec
import com.squareup.kotlinpoet.FunSpec
import io.github.jan.supabase.annotations.SupabaseInternal
import io.github.jan.supabase.postgrest.Postgrest
import io.github.jan.supabase.postgrest.annotations.ApplyFunction
import io.github.jan.supabase.postgrest.annotations.Cast
import io.github.jan.supabase.postgrest.annotations.ColumnName
import io.github.jan.supabase.postgrest.annotations.Foreign
import io.github.jan.supabase.postgrest.annotations.JsonPath
import io.github.jan.supabase.postgrest.annotations.Selectable

class SelectableSymbolProcessor(
private val codeGenerator: CodeGenerator,
private val logger: KSPLogger,
private val options: Map<String, String>
) : SymbolProcessor {

val packageName = options["selectablePackageName"] ?: "io.github.jan.supabase.postgrest"
val fileName = options["selectableFileName"] ?: "PostgrestColumns"

override fun process(resolver: Resolver): List<KSAnnotated> {
val symbols = resolver.getSymbolsWithAnnotation(Selectable::class.java.name).filterIsInstance<KSClassDeclaration>()
if (!symbols.iterator().hasNext()) return emptyList()
val types = hashMapOf<String, String>()
symbols.forEach { symbol ->
val className = symbol.simpleName.asString()
val qualifiedName = symbol.simpleName.asString() //Kotlin JS doesn't support qualified names
Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Name clashes could be possible due to only using the simple name, but we cannot use qualified names because of Kotlin JS support.

if (!symbol.modifiers.contains(Modifier.DATA)) {
logger.error("The class $className is not a data class", symbol)
return emptyList()
}
val parameters = symbol.primaryConstructor?.parameters
if(parameters == null) {
logger.error("Primary constructor is null or has no parameter", symbol)
return emptyList()
}
val columns = parameters.joinToString(",") { processParameters(it, resolver) }
types[qualifiedName] = columns
}
writePostgrestExtensionFunction(types, symbols.mapNotNull { it.containingFile }.toList())
return emptyList()
}

private fun writePostgrestExtensionFunction(
columns: Map<String, String>,
sources: List<KSFile>
) {
val function = FunSpec.builder("addSelectableTypes")
.addKdoc(COMMENT)
.addAnnotation(AnnotationSpec.builder(ClassName.bestGuess("kotlin.OptIn")).addMember("%T::class",
SupabaseInternal::class).build())
.receiver(Postgrest.Config::class)
columns.forEach { (qualifiedName, columns) ->
function.addStatement("columnRegistry.registerColumns(\"$qualifiedName\", \"$columns\")")
}
val fileSpec = FileSpec.builder(packageName, fileName)
.addFunction(function.build())
.addImport("io.github.jan.supabase.postgrest.annotations", "Selectable")
.build()
val stream = codeGenerator.createNewFile(
Dependencies(false, *sources.toTypedArray()),
packageName,
fileName,
extensionName = "kt"
)
stream.bufferedWriter().use {
fileSpec.writeTo(it)
}
}

private fun processParameters(parameter: KSValueParameter, resolver: Resolver): String {
val parameterClass = resolver.getClassDeclarationByName(parameter.type.resolve().declaration.qualifiedKSName)
requireNotNull(parameterClass) { "Parameter class is null" }
val innerColumns = if(!isPrimitive(parameterClass.qualifiedNameAsString)) {
val annotation = parameterClass.annotations.getAnnotationOrNull(Selectable::class.java.simpleName)
if(annotation == null) {
//Could be a JSON column or a custom type, so don't throw an error
logger.info("Type of parameter ${parameter.nameAsString} is not a primitive type and does not have @Selectable annotation", parameter)
""
} else {
val columns = parameterClass.primaryConstructor?.parameters
if(columns == null) {
logger.error("Primary constructor of ${parameterClass.qualifiedName} is null or has no parameter", parameter)
return ""
}
columns.joinToString(",") { processParameters(it, resolver) }
}
} else ""
val alias = parameter.nameAsString
val columnName = parameter.annotations.getAnnotationOrNull(ColumnName::class.java.simpleName)
?.arguments?.getParameterValue<String>(ColumnName.NAME_PARAMETER_NAME)
val isForeign = parameter.annotations.getAnnotationOrNull(Foreign::class.java.simpleName) != null
val jsonPathArguments = parameter.annotations.getAnnotationOrNull(JsonPath::class.java.simpleName)?.arguments
val jsonPath = jsonPathArguments?.getParameterValue<ArrayList<String>>(JsonPath.PATH_PARAMETER_NAME)
val returnAsText = jsonPathArguments?.getParameterValue<Boolean>(JsonPath.RETURN_AS_TEXT_PARAMETER_NAME) == true
val function = parameter.annotations.getAnnotationOrNull(ApplyFunction::class.java.simpleName)
?.arguments?.getParameterValue<String>(ApplyFunction.FUNCTION_PARAMETER_NAME)
val cast = parameter.annotations.getAnnotationOrNull(Cast::class.java.simpleName)
?.arguments?.getParameterValue<String>(Cast.TYPE_PARAMETER_NAME)
val options = ColumnOptions(
alias = alias,
columnName = columnName,
isForeign = isForeign,
jsonPath = jsonPath,
returnAsText = returnAsText,
function = function,
cast = cast,
innerColumns = innerColumns
)
checkValidCombinations(
parameterName = parameter.nameAsString,
options = options,
symbol = parameter
)
return buildColumns(
options = options,
parameterName = parameter.nameAsString,
symbol = parameter,
qualifiedTypeName = parameterClass.simpleName.asString() //Qualified names are not supported in Kotlin JS
)
}

private fun checkValidCombinations(
options: ColumnOptions,
parameterName: String,
symbol: KSNode
) {
if(options.isForeign && options.jsonPath != null) {
logger.error("Parameter $parameterName can't have both @Foreign and @JsonPath annotation", symbol)
}
if(options.isForeign && options.function != null) {
logger.error("Parameter $parameterName can't have both @Foreign and @ApplyFunction annotation", symbol)
}
if(options.jsonPath != null && options.function != null) {
logger.error("Parameter $parameterName can't have both @JsonPath and @ApplyFunction annotation", symbol)
}
if(options.jsonPath != null && options.columnName == null) {
logger.error("Parameter $parameterName must have a @ColumnName annotation when using @JsonPath", symbol)
}
if(options.jsonPath != null && options.jsonPath.isEmpty()) {
logger.error("Parameter $parameterName must have at least one path in @JsonPath annotation", symbol)
}
}

private fun buildColumns(
options: ColumnOptions,
parameterName: String,
qualifiedTypeName: String,
symbol: KSNode
): String {
return buildString {
if(options.jsonPath != null) {
if(options.alias != options.jsonPath.last()) {
append("${options.alias}:")
}
append(buildJsonPath(options.columnName, options.jsonPath, options.returnAsText))
return@buildString
}

//If the column name is not provided, use the alias (parameter name)
if(options.columnName == null) {
append(options.alias)
} else {
append("${options.alias}:${options.columnName}")
}
if(options.isForeign) {
append("(${options.innerColumns})")
return@buildString
}

//If a custom cast is provided, use it
if(options.cast != null && options.cast.isNotEmpty()) {
append("::${options.cast}")
} else if(options.cast != null) { //If cast is empty, try to auto-cast
val autoCast = primitiveColumnTypes[qualifiedTypeName]
if(autoCast != null) {
append("::$autoCast")
} else {
logger.error("Type of parameter $parameterName is not a primitive type and does not have an automatic cast type. Try to specify it manually.", symbol)
}
}
if(options.function != null) {
append(".${options.function}()")
}
}
}

private fun buildJsonPath(
columnName: String?,
jsonPath: List<String>,
returnAsText: Boolean
): String {
val operator = if(returnAsText) "->>" else "->"
return buildString {
append(columnName)
if(jsonPath.size > 1) {
jsonPath.dropLast(1).forEach {
append("->$it")
}
}
append(operator)
append(jsonPath.last())
}
}

companion object {

val COMMENT = """
|Adds the types annotated with [Selectable] to the ColumnRegistry. Allows to use the automatically generated columns in the PostgrestQueryBuilder.
|
|This file is generated by the SelectableSymbolProcessor.
|Do not modify it manually.
""".trimMargin()

}

}
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
package io.github.jan.supabase.ksp

import com.google.devtools.ksp.processing.SymbolProcessor
import com.google.devtools.ksp.processing.SymbolProcessorEnvironment
import com.google.devtools.ksp.processing.SymbolProcessorProvider

class SelectableSymbolProcessorProvider: SymbolProcessorProvider {

override fun create(environment: SymbolProcessorEnvironment): SymbolProcessor {
return SelectableSymbolProcessor(environment.codeGenerator, environment.logger, environment.options)
}

}
28 changes: 28 additions & 0 deletions KSP/src/main/kotlin/io/github/jan/supabase/ksp/Utils.kt
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
package io.github.jan.supabase.ksp

import com.google.devtools.ksp.symbol.KSAnnotation
import com.google.devtools.ksp.symbol.KSDeclaration
import com.google.devtools.ksp.symbol.KSValueArgument
import com.google.devtools.ksp.symbol.KSValueParameter

fun Sequence<KSAnnotation>.getAnnotationOrNull(target: String): KSAnnotation? {
for (element in this) if (element.shortName.asString() == target) return element
return null
}

fun <T> List<KSValueArgument>.getParameterValue(target: String): T {
return getParameterValueIfExist(target) ?:
throw NoSuchElementException("Sequence contains no element matching the predicate.")
}

fun <T> List<KSValueArgument>.getParameterValueIfExist(target: String): T? {
for (element in this) if (element.name?.asString() == target) (element.value as? T)?.let { return it }
return null
}

//Note these should never be null for our use case, but we still need to handle it
val KSValueParameter.nameAsString: String get() = name?.asString() ?: error("Parameter name is null")

val KSDeclaration.qualifiedNameAsString get() = qualifiedKSName.asString()
val KSDeclaration.qualifiedKSName get() = qualifiedName ?: error("Qualified name is null")
val KSDeclaration.simpleNameAsString get() = simpleName.asString()
Loading
Loading