-
-
Notifications
You must be signed in to change notification settings - Fork 57
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
jan-tennert
wants to merge
19
commits into
master
Choose a base branch
from
postgrest-column-ksp
base: master
Could not load branches
Branch not found: {{ refName }}
Loading
Could not load tags
Nothing to show
Loading
Are you sure you want to change the base?
Some commits from the old base branch may be removed from the timeline,
and old review comments may become outdated.
Open
Changes from all commits
Commits
Show all changes
19 commits
Select commit
Hold shift + click to select a range
0cc6ac2
Initial commit
jan-tennert 0615226
Small improvements
jan-tennert 2ca488f
Improve error checks
jan-tennert be8a919
Support for more auto casts
jan-tennert 93732cd
Add alias support for json columns
jan-tennert 1860276
Revert JsonPath change
jan-tennert 6bf5441
Use new column registry
jan-tennert 38b3419
Add some docs
jan-tennert 5ca1485
Fix typo
jan-tennert ac5bc46
Update KSP docs
jan-tennert c882977
Fix detekt and JS
jan-tennert 9df7b82
Small improvements
jan-tennert bd4f31b
Fix error when not using the root package
jan-tennert 1a65dc4
Cleanup and add some tests
jan-tennert b5079f8
Fix invalid test type
jan-tennert 196961b
Improve docs
jan-tennert dac4d82
Add note about IDE issues
jan-tennert f93b1fc
Update docs
jan-tennert 2a44214
Update versions
jan-tennert File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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") | ||
} | ||
} | ||
``` |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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
12
KSP/src/main/kotlin/io/github/jan/supabase/ksp/ColumnOptions.kt
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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, | ||
) |
25 changes: 25 additions & 0 deletions
25
KSP/src/main/kotlin/io/github/jan/supabase/ksp/PrimitiveColumnType.kt
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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) |
232 changes: 232 additions & 0 deletions
232
KSP/src/main/kotlin/io/github/jan/supabase/ksp/SelectableSymbolProcessor.kt
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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 | ||
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() | ||
|
||
} | ||
|
||
} |
13 changes: 13 additions & 0 deletions
13
KSP/src/main/kotlin/io/github/jan/supabase/ksp/SelectableSymbolProcessorProvider.kt
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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) | ||
} | ||
|
||
} |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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() |
Oops, something went wrong.
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
There was a problem hiding this comment.
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.