From fe91f8ddce3487f81bc3b9feaa0a5c302bac8a14 Mon Sep 17 00:00:00 2001 From: Maxwell Date: Mon, 1 Jul 2024 21:52:09 +0200 Subject: [PATCH] feat: add new symbol processor --- annotations/build.gradle.kts | 19 +++ build.gradle.kts | 11 ++ buildSrc/build.gradle.kts | 3 +- .../query/builder/buildSrc/module/Modules.kt | 3 +- .../components/ProjectConfiguration.kt | 9 +- .../plugins/components/ProjectPlugins.kt | 13 +- .../plugins/components/ProjectProperties.kt | 4 +- .../plugins/strategy/DependencyStrategy.kt | 12 +- core/ext/build.gradle.kts | 2 +- gradle/libs.versions.toml | 60 ++++--- processor/build.gradle.kts | 50 ++++-- .../processor/EntitySchemaProcessor.kt | 79 ---------- .../query/builder/processor/Processor.kt | 70 ++++++++ .../query/builder/processor/Provider.kt | 17 ++ .../codegen/EntitySchemaCodeGenerator.kt | 39 +++++ .../codegen/contract/ICodeGenerator.kt | 8 + .../extensions/CodeAnalyserExtension.kt | 22 +++ .../processor/extensions/ElementExtensions.kt | 74 --------- .../builder/processor/factory/ClassFactory.kt | 123 ++++++++------- .../builder/processor/logger/CoreLogger.kt | 47 ------ .../processor/logger/contract/ILogger.kt | 24 --- .../builder/processor/model/Candidate.kt | 149 ++++++++++-------- .../processor/model/table/TableItem.kt | 4 +- .../query/builder/processor/ProcessorTest.kt | 94 +++++++++++ .../extensions/SourceCompilerExtensions.kt | 40 +++++ .../query/builder/processor/util/TestExt.kt | 57 +++++++ sample/build.gradle.kts | 41 +++-- .../sample/data/entity/person/PersonEntity.kt | 2 +- .../sample/data/entity/pet/PetEntity.kt | 9 +- 29 files changed, 663 insertions(+), 422 deletions(-) delete mode 100644 processor/src/main/kotlin/co/anitrend/support/query/builder/processor/EntitySchemaProcessor.kt create mode 100644 processor/src/main/kotlin/co/anitrend/support/query/builder/processor/Processor.kt create mode 100644 processor/src/main/kotlin/co/anitrend/support/query/builder/processor/Provider.kt create mode 100644 processor/src/main/kotlin/co/anitrend/support/query/builder/processor/codegen/EntitySchemaCodeGenerator.kt create mode 100644 processor/src/main/kotlin/co/anitrend/support/query/builder/processor/codegen/contract/ICodeGenerator.kt create mode 100644 processor/src/main/kotlin/co/anitrend/support/query/builder/processor/extensions/CodeAnalyserExtension.kt delete mode 100644 processor/src/main/kotlin/co/anitrend/support/query/builder/processor/extensions/ElementExtensions.kt delete mode 100644 processor/src/main/kotlin/co/anitrend/support/query/builder/processor/logger/CoreLogger.kt delete mode 100644 processor/src/main/kotlin/co/anitrend/support/query/builder/processor/logger/contract/ILogger.kt create mode 100644 processor/src/test/kotlin/co/anitrend/support/query/builder/processor/ProcessorTest.kt create mode 100644 processor/src/test/kotlin/co/anitrend/support/query/builder/processor/extensions/SourceCompilerExtensions.kt create mode 100644 processor/src/test/kotlin/co/anitrend/support/query/builder/processor/util/TestExt.kt diff --git a/annotations/build.gradle.kts b/annotations/build.gradle.kts index f25169bf..dfef5b63 100644 --- a/annotations/build.gradle.kts +++ b/annotations/build.gradle.kts @@ -2,6 +2,25 @@ plugins { id("co.anitrend.support.query.builder.plugin") } +// Ensure Kotlin module metadata is generated properly +tasks.withType { + compilerOptions { + // Ensure module metadata is generated + freeCompilerArgs.addAll(listOf( + "-Xjvm-default=all", + "-opt-in=kotlin.RequiresOptIn" + )) + } +} + +// Ensure JAR task includes proper metadata +tasks.jar { + manifest { + attributes["Implementation-Title"] = project.name + attributes["Implementation-Version"] = project.version + } +} + tasks.withType { dependsOn(":annotations:classesJar") } diff --git a/build.gradle.kts b/build.gradle.kts index d22aa869..ee721eac 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -1,3 +1,9 @@ +// Top-level build file where you can add configuration options common to all sub-projects/modules. +import org.jetbrains.dokka.gradle.DokkaMultiModuleTask + +plugins { + id("org.jetbrains.dokka") +} buildscript { repositories { google() @@ -19,3 +25,8 @@ allprojects { } } } + +tasks.withType(DokkaMultiModuleTask::class.java) { + outputDirectory.set(rootProject.file("dokka-docs")) + failOnWarning.set(false) +} diff --git a/buildSrc/build.gradle.kts b/buildSrc/build.gradle.kts index 8e5233b9..b998d3d1 100644 --- a/buildSrc/build.gradle.kts +++ b/buildSrc/build.gradle.kts @@ -1,7 +1,6 @@ plugins { `kotlin-dsl` `maven-publish` - `version-catalog` } repositories { @@ -29,6 +28,8 @@ dependencies { /* Depend on the default Gradle API's since we want to build a custom plugin */ implementation(gradleApi()) implementation(localGroovy()) + + implementation(kotlin("test")) /** Work around to include ../.gradle/LibrariesForLibs generated file for version catalog */ implementation(files(libs.javaClass.superclass.protectionDomain.codeSource.location)) diff --git a/buildSrc/src/main/java/co/anitrend/support/query/builder/buildSrc/module/Modules.kt b/buildSrc/src/main/java/co/anitrend/support/query/builder/buildSrc/module/Modules.kt index 5460080b..6e6c640d 100644 --- a/buildSrc/src/main/java/co/anitrend/support/query/builder/buildSrc/module/Modules.kt +++ b/buildSrc/src/main/java/co/anitrend/support/query/builder/buildSrc/module/Modules.kt @@ -16,7 +16,8 @@ internal object Modules { } enum class Processor(override val id: String) : Module { - Kapt("processor") + Kapt("processor"), + Ksp("processor") } enum class Common(override val id: String) : Module { diff --git a/buildSrc/src/main/java/co/anitrend/support/query/builder/buildSrc/plugins/components/ProjectConfiguration.kt b/buildSrc/src/main/java/co/anitrend/support/query/builder/buildSrc/plugins/components/ProjectConfiguration.kt index 2cfd5d9b..6e235789 100644 --- a/buildSrc/src/main/java/co/anitrend/support/query/builder/buildSrc/plugins/components/ProjectConfiguration.kt +++ b/buildSrc/src/main/java/co/anitrend/support/query/builder/buildSrc/plugins/components/ProjectConfiguration.kt @@ -1,11 +1,13 @@ package co.anitrend.support.query.builder.buildSrc.plugins.components -import co.anitrend.support.query.builder.buildSrc.extension.* import co.anitrend.support.query.builder.buildSrc.extension.baseAppExtension import co.anitrend.support.query.builder.buildSrc.extension.baseExtension +import co.anitrend.support.query.builder.buildSrc.extension.isSampleModule +import co.anitrend.support.query.builder.buildSrc.extension.props import com.android.build.gradle.internal.dsl.DefaultConfig import org.gradle.api.JavaVersion import org.gradle.api.Project +import org.gradle.api.tasks.testing.Test import org.jetbrains.kotlin.gradle.dsl.JvmTarget import org.jetbrains.kotlin.gradle.tasks.KotlinCompilationTask import org.jetbrains.kotlin.gradle.tasks.KotlinJvmCompile @@ -100,6 +102,11 @@ internal fun Project.configureAndroid(): Unit = baseExtension().run { } } + tasks.withType(Test::class.java) { + useJUnitPlatform() + failOnNoDiscoveredTests.set(false) + } + tasks.withType(KotlinJvmCompile::class.java) { compilerOptions { jvmTarget.set(JvmTarget.JVM_21) diff --git a/buildSrc/src/main/java/co/anitrend/support/query/builder/buildSrc/plugins/components/ProjectPlugins.kt b/buildSrc/src/main/java/co/anitrend/support/query/builder/buildSrc/plugins/components/ProjectPlugins.kt index 6b24c361..5395c85b 100644 --- a/buildSrc/src/main/java/co/anitrend/support/query/builder/buildSrc/plugins/components/ProjectPlugins.kt +++ b/buildSrc/src/main/java/co/anitrend/support/query/builder/buildSrc/plugins/components/ProjectPlugins.kt @@ -1,10 +1,10 @@ package co.anitrend.support.query.builder.buildSrc.plugins.components +import co.anitrend.support.query.builder.buildSrc.extension.isKotlinLibraryGroup +import co.anitrend.support.query.builder.buildSrc.extension.isProcessorModule +import co.anitrend.support.query.builder.buildSrc.extension.isSampleModule import org.gradle.api.Project import org.gradle.api.plugins.PluginContainer -import co.anitrend.support.query.builder.buildSrc.extension.isSampleModule -import co.anitrend.support.query.builder.buildSrc.extension.isProcessorModule -import co.anitrend.support.query.builder.buildSrc.extension.isKotlinLibraryGroup private fun addAndroidPlugin(project: Project, pluginContainer: PluginContainer) { when { @@ -28,14 +28,7 @@ private fun addKotlinAndroidPlugin(project: Project, pluginContainer: PluginCont pluginContainer.apply("kotlin-android") } -private fun addAnnotationProcessor(project: Project, pluginContainer: PluginContainer) { - if (project.isSampleModule() || project.isProcessorModule()) - pluginContainer.apply("kotlin-kapt") -} - - internal fun Project.configurePlugins() { addAndroidPlugin(project, plugins) addKotlinAndroidPlugin(project, plugins) - addAnnotationProcessor(project, plugins) } diff --git a/buildSrc/src/main/java/co/anitrend/support/query/builder/buildSrc/plugins/components/ProjectProperties.kt b/buildSrc/src/main/java/co/anitrend/support/query/builder/buildSrc/plugins/components/ProjectProperties.kt index e652f2de..845d3a81 100644 --- a/buildSrc/src/main/java/co/anitrend/support/query/builder/buildSrc/plugins/components/ProjectProperties.kt +++ b/buildSrc/src/main/java/co/anitrend/support/query/builder/buildSrc/plugins/components/ProjectProperties.kt @@ -10,12 +10,12 @@ enum class PropertyTypes(val key: String) { VERSION("version"), } -class PropertiesReader(project: Project) { +class PropertiesReader(project: Project, path: String = "gradle/version.properties") { @Suppress("NewApi") private val properties = Properties(2) init { - val releaseFile = File(project.rootDir, "gradle/version.properties") + val releaseFile = File(project.rootDir, path) if (!releaseFile.exists()) { project.logger.error("Release file cannot be found in path: $releaseFile") } diff --git a/buildSrc/src/main/java/co/anitrend/support/query/builder/buildSrc/plugins/strategy/DependencyStrategy.kt b/buildSrc/src/main/java/co/anitrend/support/query/builder/buildSrc/plugins/strategy/DependencyStrategy.kt index 99b9a218..ef717dfc 100644 --- a/buildSrc/src/main/java/co/anitrend/support/query/builder/buildSrc/plugins/strategy/DependencyStrategy.kt +++ b/buildSrc/src/main/java/co/anitrend/support/query/builder/buildSrc/plugins/strategy/DependencyStrategy.kt @@ -1,6 +1,9 @@ package co.anitrend.support.query.builder.buildSrc.plugins.strategy -import co.anitrend.support.query.builder.buildSrc.extension.* +import co.anitrend.support.query.builder.buildSrc.extension.implementation +import co.anitrend.support.query.builder.buildSrc.extension.isSampleModule +import co.anitrend.support.query.builder.buildSrc.extension.libs +import co.anitrend.support.query.builder.buildSrc.extension.test import org.gradle.api.Project import org.gradle.api.artifacts.dsl.DependencyHandler @@ -12,13 +15,14 @@ internal class DependencyStrategy(private val project: Project) { test(project.libs.junit) test(project.libs.mockk) + test(project.libs.jetbrains.kotlin.test) } private fun DependencyHandler.applyLifeCycleDependencies() { implementation(project.libs.androidx.lifecycle.extensions) - implementation(project.libs.androidx.lifecycle.runTimeKtx) - implementation(project.libs.androidx.lifecycle.liveDataKtx) - implementation(project.libs.androidx.lifecycle.liveDataCoreKtx) + implementation(project.libs.androidx.lifecycle.runTime.ktx) + implementation(project.libs.androidx.lifecycle.liveData.ktx) + implementation(project.libs.androidx.lifecycle.liveDataCore.ktx) } fun applyDependenciesOn(handler: DependencyHandler) { diff --git a/core/ext/build.gradle.kts b/core/ext/build.gradle.kts index 747c7d23..c560388b 100644 --- a/core/ext/build.gradle.kts +++ b/core/ext/build.gradle.kts @@ -13,7 +13,7 @@ tasks.withType { dependencies { implementation(project(":core")) - implementation(libs.androidx.sqliteKtx) + implementation(libs.androidx.sqlite.ktx) } tasks.withType { diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index d570e3bc..880d362f 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -1,5 +1,5 @@ [versions] -ktlint = "1.1.1" +ktlint = "1.5.0" gradle-plugin = "8.13.0" @@ -17,35 +17,47 @@ jetbrains-kotlin = "2.2.20" io-mockk = "1.14.5" -spek2-spek = "2.0.19" +google-devtools-ksp = "2.2.0-2.0.2" +squareup-kotlinpoet = "2.2.0" +google-auto-service = "1.1.1" + +kotlin-compile-testing = "0.8.0" + +junit5 = "5.13.4" [plugins] android-junit5 = { id = "de.mannodermaus.android-junit5", version = "1.13.4.0" } - +google-devtools-ksp = { id = "com.google.devtools.ksp", version.ref = "google-devtools-ksp" } [libraries] -timber = "com.jakewharton.timber:timber:5.0.1" -junit = "junit:junit:4.13.2" +timber = { module = "com.jakewharton.timber:timber", version = "5.0.1" } +junit = { module ="junit:junit", version = "4.13.2" } +junit5-api = { module = "org.junit.jupiter:junit-jupiter-api", version.ref = "junit5" } +junit5-engine = { module = "org.junit.jupiter:junit-jupiter-engine", version.ref = "junit5" } android-gradle-plugin = { module = "com.android.tools.build:gradle", version.ref = "gradle-plugin" } -androidx-activityKtx = "androidx.activity:activity-ktx:1.11.0" +androidx-activity = { module = "androidx.activity:activity", version = "1.10.1" } +androidx-activity-ktx = { module = "androidx.activity:activity-ktx", version = "1.11.0" } androidx-appcompat = { module = "androidx.appcompat:appcompat", version.ref = "androidx-appcompat" } androidx-appcompatResources = { module = "androidx.appcompat:appcompat-resources", version.ref = "androidx-appcompat" } androidx-constraintLayout = "androidx.constraintlayout:constraintlayout:2.2.1" -androidx-fragmentKtx = "androidx.fragment:fragment-ktx:1.8.9" +androidx-fragment = { module = "androidx.fragment:fragment", version = "1.8.9" } +androidx-fragment-ktx = { module = "androidx.fragment:fragment-ktx", version = "1.8.9" } -androidx-lifecycle-extensions = "androidx.lifecycle:lifecycle-extensions:2.2.0" -androidx-lifecycle-runTimeKtx = { module = "androidx.lifecycle:lifecycle-runtime-ktx", version.ref = "androidx-lifecycle" } -androidx-lifecycle-liveDataKtx = { module = "androidx.lifecycle:lifecycle-livedata-ktx", version.ref = "androidx-lifecycle" } -androidx-lifecycle-liveDataCoreKtx = { module = "androidx.lifecycle:lifecycle-livedata-core-ktx", version.ref = "androidx-lifecycle" } +androidx-lifecycle-extensions = { module = "androidx.lifecycle:lifecycle-extensions", version = "2.2.0" } +androidx-lifecycle-runTime-ktx = { module = "androidx.lifecycle:lifecycle-runtime-ktx", version.ref = "androidx-lifecycle" } +androidx-lifecycle-liveData-ktx = { module = "androidx.lifecycle:lifecycle-livedata-ktx", version.ref = "androidx-lifecycle" } +androidx-lifecycle-liveDataCore-ktx = { module = "androidx.lifecycle:lifecycle-livedata-core-ktx", version.ref = "androidx-lifecycle" } -androidx-navigation-fragmentKtx = { module = "androidx.navigation:navigation-fragment-ktx", version.ref = "androidx-navigation" } -androidx-navigation-uiKtx = { module = "androidx.navigation:navigation-ui-ktx", version.ref = "androidx-navigation" } +androidx-navigation-fragment-ktx = { module = "androidx.navigation:navigation-fragment-ktx", version.ref = "androidx-navigation" } +androidx-navigation-fragment = { module = "androidx.navigation:navigation-fragment", version.ref = "androidx-navigation" } +androidx-navigation-ui = { module = "androidx.navigation:navigation-ui", version.ref = "androidx-navigation" } +androidx-navigation-ui-ktx = { module = "androidx.navigation:navigation-ui-ktx", version.ref = "androidx-navigation" } androidx-room-compiler = { module = "androidx.room:room-compiler", version.ref = "androidx-room" } androidx-room-common = { module = "androidx.room:room-common", version.ref = "androidx-room" } @@ -53,35 +65,37 @@ androidx-room-runtime = { module = "androidx.room:room-runtime", version.ref = " androidx-room-ktx = { module = "androidx.room:room-ktx", version.ref = "androidx-room" } androidx-sqlite = { module = "androidx.sqlite:sqlite", version.ref = "androidx-sqlite" } -androidx-sqliteKtx = { module = "androidx.sqlite:sqlite-ktx", version.ref = "androidx-sqlite" } +androidx-sqlite-ktx = { module = "androidx.sqlite:sqlite-ktx", version.ref = "androidx-sqlite" } androidx-test-core = { module = "androidx.test:core", version = "1.7.0" } -androidx-test-coreKtx = { module = "androidx.test:core-ktx", version = "1.7.0" } +androidx-test-core-ktx = { module = "androidx.test:core-ktx", version = "1.7.0" } androidx-test-runner = { module = "androidx.test:runner", version = "1.7.0" } androidx-test-rules = { module = "androidx.test:rules", version = "1.7.0" } androidx-junitKtx = { module = "androidx.test.ext:junit-ktx", version.ref = "androidx-test-ext" } -google-auto-service = "com.google.auto.service:auto-service:1.1.1" +google-auto-service = { module = "com.google.auto.service:auto-service-annotations", version.ref = "google-auto-service" } +auto-service-ksp = "dev.zacsweers.autoservice:auto-service-ksp:1.2.0" google-android-material = "com.google.android.material:material:1.13.0" jetbrains-dokka-gradle = { module = "org.jetbrains.dokka:dokka-gradle-plugin", version.ref = "jetbrains-dokka" } jetbrains-kotlin-stdlib-jdk8 = { module = "org.jetbrains.kotlin:kotlin-stdlib-jdk8", version.ref = "jetbrains-kotlin" } jetbrains-kotlin-reflect = { module = "org.jetbrains.kotlin:kotlin-reflect", version.ref = "jetbrains-kotlin" } jetbrains-kotlin-gradle = { module = "org.jetbrains.kotlin:kotlin-gradle-plugin", version.ref = "jetbrains-kotlin" } +jetbrains-kotlin-test = { module = "org.jetbrains.kotlin:kotlin-test", version.ref = "jetbrains-kotlin" } mockk = { module = "io.mockk:mockk", version.ref = "io-mockk" } mockk-android = { module = "io.mockk:mockk-android", version.ref = "io-mockk" } spotless-gradle = "com.diffplug.spotless:spotless-plugin-gradle:8.0.0" +pintrest-ktlint = { module = "com.pinterest:ktlint", version.ref = "ktlint" } -squareup-kotlinpoet = "com.squareup:kotlinpoet:2.2.0" +squareup-kotlinpoet = { module = "com.squareup:kotlinpoet", version.ref = "squareup-kotlinpoet" } +squareup-kotlinpoet-ksp = { module = "com.squareup:kotlinpoet-ksp", version.ref = "squareup-kotlinpoet" } -tschuchortdev-kotlin-compile-testing = "com.github.tschuchortdev:kotlin-compile-testing:1.6.0" +google-devtools-ksp = { module = "com.google.devtools.ksp:symbol-processing", version.ref = "google-devtools-ksp" } +google-devtools-ksp-api = { module = "com.google.devtools.ksp:symbol-processing-api", version.ref = "google-devtools-ksp" } gradle-plugins-android-junit5 = "de.mannodermaus.gradle.plugins:android-junit5:1.13.4.0" - -spek2-spek-dsl-jvm = { module = "org.spekframework.spek2:spek-dsl-jvm", version.ref = "spek2-spek" } -spek2-spek-runner-junit5 = { module = "org.spekframework.spek2:spek-runner-junit5", version.ref = "spek2-spek" } - -pintrest-ktlint = { module = "com.pinterest:ktlint", version.ref = "ktlint" } +kotlin-compile-testing = { module = "dev.zacsweers.kctfork:core", version.ref = "kotlin-compile-testing" } +kotlin-compile-testing-ksp = { module = "dev.zacsweers.kctfork:ksp", version.ref ="kotlin-compile-testing" } diff --git a/processor/build.gradle.kts b/processor/build.gradle.kts index 0842e824..255ad3e6 100644 --- a/processor/build.gradle.kts +++ b/processor/build.gradle.kts @@ -1,21 +1,51 @@ +import com.google.devtools.ksp.gradle.KspAATask +import org.jetbrains.kotlin.gradle.tasks.KotlinCompile + plugins { id("co.anitrend.support.query.builder.plugin") + alias(libs.plugins.google.devtools.ksp) } -tasks.withType { - dependsOn(":annotations:classesJar") +dependencies { + implementation(project(":annotations")) + + implementation(libs.google.auto.service) + ksp(libs.auto.service.ksp) + + compileOnly(libs.google.devtools.ksp.api) + compileOnly(libs.google.devtools.ksp) + + api(libs.squareup.kotlinpoet) + compileOnly(libs.androidx.room.common) + + testImplementation(project(":annotations")) + testImplementation(libs.androidx.room.common) + testImplementation(libs.google.devtools.ksp) + testImplementation(libs.google.devtools.ksp.api) + testImplementation(libs.kotlin.compile.testing) + testImplementation(libs.kotlin.compile.testing.ksp) + testImplementation(libs.junit5.api) + testRuntimeOnly(libs.junit5.engine) } -tasks.withType { - dependsOn(":processor:classesJar") +// Ensure KSP tasks wait for annotations to be fully built +tasks.withType { + dependsOn(":annotations:jar") + mustRunAfter(":annotations:classesJar") } -dependencies { - compileOnly(libs.google.auto.service) - kapt(libs.google.auto.service) +// Ensure compilation tasks wait for annotations +tasks.withType { + dependsOn(":annotations:jar") +} - api(libs.squareup.kotlinpoet) - compileOnly(libs.androidx.room.common) +// Ensure test tasks wait for annotations +tasks.withType { + dependsOn(":annotations:jar") +} - implementation(project(":annotations")) +tasks.test { + useJUnitPlatform() + // Ensure test classpath includes annotations + dependsOn(":annotations:jar") } diff --git a/processor/src/main/kotlin/co/anitrend/support/query/builder/processor/EntitySchemaProcessor.kt b/processor/src/main/kotlin/co/anitrend/support/query/builder/processor/EntitySchemaProcessor.kt deleted file mode 100644 index e3443faf..00000000 --- a/processor/src/main/kotlin/co/anitrend/support/query/builder/processor/EntitySchemaProcessor.kt +++ /dev/null @@ -1,79 +0,0 @@ -/* - * Copyright 2023 AniTrend - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * https://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -package co.anitrend.support.query.builder.processor - -import androidx.room.ColumnInfo -import androidx.room.Embedded -import androidx.room.Entity -import co.anitrend.support.query.builder.annotation.EntitySchema -import co.anitrend.support.query.builder.processor.extensions.createCandidate -import co.anitrend.support.query.builder.processor.factory.ClassFactory -import co.anitrend.support.query.builder.processor.logger.CoreLogger -import co.anitrend.support.query.builder.processor.logger.contract.ILogger -import com.google.auto.service.AutoService -import javax.annotation.processing.AbstractProcessor -import javax.annotation.processing.ProcessingEnvironment -import javax.annotation.processing.Processor -import javax.annotation.processing.RoundEnvironment -import javax.lang.model.SourceVersion -import javax.lang.model.element.TypeElement -import javax.lang.model.util.Elements -import javax.lang.model.util.Types - -@AutoService(Processor::class) -class EntitySchemaProcessor : AbstractProcessor() { - - private lateinit var logger: ILogger - private lateinit var types: Types - private lateinit var elements: Elements - - override fun init(processingEnv: ProcessingEnvironment) { - super.init(processingEnv) - logger = CoreLogger(processingEnv.messager) - types = processingEnv.typeUtils - elements = processingEnv.elementUtils - logger.lineBreakWithSeparatorCharacter() - } - - override fun getSupportedAnnotationTypes() = setOf( - EntitySchema::class.java.canonicalName, - ColumnInfo::class.java.canonicalName, - Embedded::class.java.canonicalName, - Entity::class.java.canonicalName, - ) - - /** - * If [EntitySchema] has any overrides we'd specify them here - */ - override fun getSupportedOptions() = emptySet() - - override fun getSupportedSourceVersion(): SourceVersion = SourceVersion.latestSupported() - - override fun process( - annotations: MutableSet, - roundEnvironment: RoundEnvironment, - ): Boolean { - val elementItems = roundEnvironment.getElementsAnnotatedWith( - EntitySchema::class.java, - ).map { element -> element.createCandidate(types, logger, roundEnvironment) } - if (elementItems.isNotEmpty()) { - logger.debug("Available candidates: [${elementItems.joinToString(separator = ", ")}]") - ClassFactory(processingEnv, elements, logger).generateUsing(elementItems) - } - return true - } -} diff --git a/processor/src/main/kotlin/co/anitrend/support/query/builder/processor/Processor.kt b/processor/src/main/kotlin/co/anitrend/support/query/builder/processor/Processor.kt new file mode 100644 index 00000000..cde60eae --- /dev/null +++ b/processor/src/main/kotlin/co/anitrend/support/query/builder/processor/Processor.kt @@ -0,0 +1,70 @@ +/* + * Copyright 2023 AniTrend + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package co.anitrend.support.query.builder.processor + +import co.anitrend.support.query.builder.annotation.EntitySchema +import co.anitrend.support.query.builder.processor.codegen.EntitySchemaCodeGenerator +import com.google.devtools.ksp.processing.CodeGenerator +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.validate + +class Processor( + codeGenerator: CodeGenerator, + private val logger: KSPLogger, + private val options: Map, +) : SymbolProcessor { + + private val entitySchemaCodeGenerator = + EntitySchemaCodeGenerator( + codeGenerator = codeGenerator, + options = options, + logger = logger, + ) + + /** + * Called by Kotlin Symbol Processing to run the processing task. + * + * @param resolver provides [SymbolProcessor] with access to compiler details such as Symbols. + * @return A list of deferred symbols that the processor can't process. Only symbols that can't be processed at this round should be returned. Symbols in compiled code (libraries) are always valid and are ignored if returned in the deferral list. + */ + override fun process(resolver: Resolver): List { + val schema = requireNotNull(EntitySchema::class.qualifiedName) { "Unable to resolve EntitySchema annotation" } + + val schemaSymbols = resolver.getSymbolsWithAnnotation(schema) + + // Only act on valid symbols for this round + val (valid, invalid) = schemaSymbols.partition { it.validate() } + + valid + .filterIsInstance() + .groupBy { it.parentDeclaration?.qualifiedName?.asString() } + .values + .forEach { classDeclarations -> + entitySchemaCodeGenerator( + resolver = resolver, + classDeclarations = classDeclarations + ) + } + + // Return the still-invalid to let KSP try again next round + return invalid + } +} diff --git a/processor/src/main/kotlin/co/anitrend/support/query/builder/processor/Provider.kt b/processor/src/main/kotlin/co/anitrend/support/query/builder/processor/Provider.kt new file mode 100644 index 00000000..fea68d42 --- /dev/null +++ b/processor/src/main/kotlin/co/anitrend/support/query/builder/processor/Provider.kt @@ -0,0 +1,17 @@ +package co.anitrend.support.query.builder.processor + +import com.google.auto.service.AutoService +import com.google.devtools.ksp.processing.SymbolProcessor +import com.google.devtools.ksp.processing.SymbolProcessorEnvironment +import com.google.devtools.ksp.processing.SymbolProcessorProvider + +@AutoService(SymbolProcessorProvider::class) +class Provider : SymbolProcessorProvider { + override fun create(environment: SymbolProcessorEnvironment): SymbolProcessor { + return Processor( + codeGenerator = environment.codeGenerator, + logger = environment.logger, + options = environment.options, + ) + } +} diff --git a/processor/src/main/kotlin/co/anitrend/support/query/builder/processor/codegen/EntitySchemaCodeGenerator.kt b/processor/src/main/kotlin/co/anitrend/support/query/builder/processor/codegen/EntitySchemaCodeGenerator.kt new file mode 100644 index 00000000..92971c1f --- /dev/null +++ b/processor/src/main/kotlin/co/anitrend/support/query/builder/processor/codegen/EntitySchemaCodeGenerator.kt @@ -0,0 +1,39 @@ +package co.anitrend.support.query.builder.processor.codegen + +import co.anitrend.support.query.builder.processor.codegen.contract.ICodeGenerator +import co.anitrend.support.query.builder.processor.factory.ClassFactory +import co.anitrend.support.query.builder.processor.model.Candidate +import com.google.devtools.ksp.processing.CodeGenerator +import com.google.devtools.ksp.processing.KSPLogger +import com.google.devtools.ksp.processing.Resolver +import com.google.devtools.ksp.symbol.KSClassDeclaration + +class EntitySchemaCodeGenerator( + private val codeGenerator: CodeGenerator, + private val options: Map, + private val logger: KSPLogger, +) : ICodeGenerator { + override fun invoke(resolver: Resolver, classDeclarations: List) { + val candidates = classDeclarations.map { + val template = """ + Package name: ${it.packageName.asString()} + Class name: ${it.simpleName.asString()} + File name: ${it.simpleName.asString()}Schema + """.trimIndent() + logger.info("[EntitySchemaCodeGenerator] Inspecting class declaration: $template") + Candidate( + classDeclaration = it, + logger = logger + ) + } + + if (candidates.isEmpty()) { + logger.info("[EntitySchemaCodeGenerator] No @EntitySchema candidates in this round") + return + } + + logger.info("[EntitySchemaCodeGenerator] Processed candidates: [${candidates.joinToString(separator = ", ")}]") + val factory = ClassFactory(codeGenerator, options, logger) + factory.generateUsing(candidates) + } +} diff --git a/processor/src/main/kotlin/co/anitrend/support/query/builder/processor/codegen/contract/ICodeGenerator.kt b/processor/src/main/kotlin/co/anitrend/support/query/builder/processor/codegen/contract/ICodeGenerator.kt new file mode 100644 index 00000000..0f5b374a --- /dev/null +++ b/processor/src/main/kotlin/co/anitrend/support/query/builder/processor/codegen/contract/ICodeGenerator.kt @@ -0,0 +1,8 @@ +package co.anitrend.support.query.builder.processor.codegen.contract + +import com.google.devtools.ksp.processing.Resolver +import com.google.devtools.ksp.symbol.KSClassDeclaration + +interface ICodeGenerator { + operator fun invoke(resolver: Resolver, classDeclarations: List) +} diff --git a/processor/src/main/kotlin/co/anitrend/support/query/builder/processor/extensions/CodeAnalyserExtension.kt b/processor/src/main/kotlin/co/anitrend/support/query/builder/processor/extensions/CodeAnalyserExtension.kt new file mode 100644 index 00000000..35725a0f --- /dev/null +++ b/processor/src/main/kotlin/co/anitrend/support/query/builder/processor/extensions/CodeAnalyserExtension.kt @@ -0,0 +1,22 @@ +package co.anitrend.support.query.builder.processor.extensions + +import com.google.devtools.ksp.symbol.KSAnnotation +import com.google.devtools.ksp.symbol.KSClassDeclaration +import com.google.devtools.ksp.symbol.KSDeclaration +import com.google.devtools.ksp.symbol.KSValueArgument +import kotlin.reflect.KClass +import kotlin.reflect.KProperty1 + +fun KSClassDeclaration.annotationArgOf(block: (KSValueArgument) -> Boolean) = annotations.flatMap { it.arguments }.first(block) + +fun KSDeclaration.annotationOf(clazz: KClass<*>): KSAnnotation? { + return annotations.firstOrNull { + it.shortName.getShortName() == clazz.java.simpleName + } +} + +fun KSAnnotation.annotationArgOf(property: KProperty1<*, String>): KSValueArgument? { + return arguments.firstOrNull { + it.name?.getShortName() == property.name + } +} diff --git a/processor/src/main/kotlin/co/anitrend/support/query/builder/processor/extensions/ElementExtensions.kt b/processor/src/main/kotlin/co/anitrend/support/query/builder/processor/extensions/ElementExtensions.kt deleted file mode 100644 index 5720974c..00000000 --- a/processor/src/main/kotlin/co/anitrend/support/query/builder/processor/extensions/ElementExtensions.kt +++ /dev/null @@ -1,74 +0,0 @@ -/* - * Copyright 2023 AniTrend - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * https://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -package co.anitrend.support.query.builder.processor.extensions - -import androidx.room.Entity -import co.anitrend.support.query.builder.annotation.EntitySchema -import co.anitrend.support.query.builder.processor.logger.contract.ILogger -import co.anitrend.support.query.builder.processor.model.Candidate -import co.anitrend.support.query.builder.processor.model.field.FieldItem -import javax.annotation.processing.RoundEnvironment -import javax.lang.model.element.Element -import javax.lang.model.element.ElementKind -import javax.lang.model.type.TypeMirror -import javax.lang.model.util.Types - -/** - * Creates a candidate by checking if it is also annotated with [androidx.room.Entity] - */ -internal fun Element.createCandidate( - types: Types, - logger: ILogger, - roundEnvironment: RoundEnvironment, -): Candidate { - val entitySchemas = roundEnvironment.getElementsAnnotatedWith(EntitySchema::class.java) - val entities = roundEnvironment.getElementsAnnotatedWith(Entity::class.java) - val difference = entitySchemas - entities - if (difference.isNotEmpty()) { - logger.error( - "[${difference.joinToString(", ")}] annotated with " + - "'co.anitrend.support.query.builder.annotation.EntitySchema' " + - "but not annotated with 'androidx.room.Entity'", - ) - } - return Candidate(types, logger, this) -} - -/** - * Returns enclosed types on an elements by [definition] - * - * @see Element.getAnnotationsByType - */ -internal fun Element.enclosingTypeOf(definition: Class): List> { - return enclosedElements.flatMap { element -> - element.getAnnotationsByType(definition).map { - FieldItem(element, it) - }.toList() - } -} - -/** - * Returns enclosed type on an elements that matches the [type] and [kind] - * - * @see Element.getEnclosedElements - */ -internal fun Element.enclosingTypeOf(type: TypeMirror, kind: ElementKind): Element { - return enclosedElements.first { element -> - element.asType() == type && - element.kind == kind - } -} diff --git a/processor/src/main/kotlin/co/anitrend/support/query/builder/processor/factory/ClassFactory.kt b/processor/src/main/kotlin/co/anitrend/support/query/builder/processor/factory/ClassFactory.kt index ba8d2427..eecd0a70 100644 --- a/processor/src/main/kotlin/co/anitrend/support/query/builder/processor/factory/ClassFactory.kt +++ b/processor/src/main/kotlin/co/anitrend/support/query/builder/processor/factory/ClassFactory.kt @@ -1,83 +1,90 @@ -/* - * Copyright 2023 AniTrend - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * https://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - package co.anitrend.support.query.builder.processor.factory -import co.anitrend.support.query.builder.processor.logger.contract.ILogger import co.anitrend.support.query.builder.processor.model.Candidate +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.symbol.KSClassDeclaration import com.squareup.kotlinpoet.FileSpec import com.squareup.kotlinpoet.TypeSpec -import java.io.File -import javax.annotation.processing.ProcessingEnvironment -import javax.lang.model.util.Elements +import java.nio.file.FileAlreadyExistsException internal class ClassFactory( - private val processingEnvironment: ProcessingEnvironment, - private val elements: Elements, - private val logger: ILogger, + private val codeGenerator: CodeGenerator, + private val options: Map, + private val logger: KSPLogger, ) { + // prevent duplicate writes across rounds + private val emitted = mutableSetOf() // key = "$pkg.$fileName" + private fun TypeSpec.Builder.construct(item: Candidate): FileSpec { val typeSpec = build() - logger.debug("Created type spec:") - logger.debug("$typeSpec") - return FileSpec.builder( - item.packageName(elements), - item.createFileName(), - ).addType(typeSpec).build() + logger.info("Created type spec:\n$typeSpec") + return FileSpec.builder(item.packageName, item.fileName) + .addType(typeSpec) + .build() } - private fun FileSpec.commit() { - logger.debug( - "Committing construct using available options [${ - processingEnvironment.options.entries.joinToString { "${it.key}: ${it.value}" } - }]", + private fun FileSpec.commitFrom(origin: KSClassDeclaration) { + val key = "${packageName}.$name" + if (!emitted.add(key)) { + logger.info("[ClassFactory] Skipping duplicate emission of $key") + return + } + + logger.info( + "Committing construct using options: ${ + options.entries.joinToString { "${it.key}=${it.value}" } + }" ) - runCatching { - val generatedDirectory = processingEnvironment.options[ - GENERATED_OPTION_KEY, - ] - writeTo( - File( - requireNotNull(generatedDirectory) { - "processingEnvironment does not have options with key: $GENERATED_OPTION_KEY " - }, - ), - ) - }.onFailure { - logger.error(it.message) + + val sourceFile = origin.containingFile + val deps = if (sourceFile != null) { + // isolating, one output per source + Dependencies(aggregating = false, sources = arrayOf(sourceFile)) + } else { + // symbol came from classpath, fall back to aggregating + Dependencies(aggregating = true) + } + + try { + codeGenerator.createNewFile( + dependencies = deps, + packageName = packageName, + fileName = name, + ).bufferedWriter().use { writer -> + writeTo(writer) + } + } catch (e: FileAlreadyExistsException) { + // Harmless in incremental/rounded runs + logger.info("[ClassFactory] Already generated: $key") + } catch (t: Throwable) { + logger.warn("[ClassFactory] Failed to write $key: $t") } } private fun createTypeSpecBuilderWith(item: Candidate): TypeSpec.Builder { - val builder = TypeSpec.objectBuilder(item.createFileName()) + val builder = TypeSpec.objectBuilder(item.fileName) item.getTable().writeToBuilder(builder) return builder } - fun generateUsing(items: List) = items.forEach { elementItem -> - logger.lineBreakWithSeparatorCharacter() - logger.debug("Inspecting element `$elementItem` and preparing to generate object") - val builder = runCatching { createTypeSpecBuilderWith(elementItem) } - .onFailure { logger.error(it.message) } - .getOrNull() - builder?.construct(elementItem)?.commit() - } + fun generateUsing(items: List) { + if (items.isEmpty()) { + logger.info("[ClassFactory] No @EntitySchema candidates in this round") + return + } - private companion object { - const val GENERATED_OPTION_KEY = "kapt.kotlin.generated" + items.forEach { elementItem -> + logger.info("[ClassFactory] Inspecting element `$elementItem` and preparing to generate object") + val builder = runCatching { createTypeSpecBuilderWith(elementItem) } + .onFailure { logger.warn("[ClassFactory] $it") } + .getOrNull() + + // Use the declaration we came from to attach proper deps + val origin = elementItem.classDeclaration + builder?.construct(elementItem)?.commitFrom(origin) + } } } diff --git a/processor/src/main/kotlin/co/anitrend/support/query/builder/processor/logger/CoreLogger.kt b/processor/src/main/kotlin/co/anitrend/support/query/builder/processor/logger/CoreLogger.kt deleted file mode 100644 index bcdd4a54..00000000 --- a/processor/src/main/kotlin/co/anitrend/support/query/builder/processor/logger/CoreLogger.kt +++ /dev/null @@ -1,47 +0,0 @@ -/* - * Copyright 2023 AniTrend - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * https://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -package co.anitrend.support.query.builder.processor.logger - -import co.anitrend.support.query.builder.processor.logger.contract.ILogger -import javax.annotation.processing.Messager -import javax.tools.Diagnostic.Kind.ERROR -import javax.tools.Diagnostic.Kind.NOTE -import javax.tools.Diagnostic.Kind.OTHER -import javax.tools.Diagnostic.Kind.WARNING - -internal class CoreLogger( - private val delegate: Messager, -) : ILogger { - - private val separator = (0..120).joinToString("") { "-" } - - private fun formatMessage(message: String?) = "$message\r\n" - - override fun lineBreakWithSeparatorCharacter() = - delegate.printMessage(OTHER, formatMessage(separator)) - - override fun debug(message: String) = - delegate.printMessage(NOTE, formatMessage(message)) - - override fun warning(message: String) = - delegate.printMessage(WARNING, formatMessage(message)) - - override fun error(message: String?) { - delegate.printMessage(ERROR, formatMessage(message)) - throw Throwable(message) - } -} diff --git a/processor/src/main/kotlin/co/anitrend/support/query/builder/processor/logger/contract/ILogger.kt b/processor/src/main/kotlin/co/anitrend/support/query/builder/processor/logger/contract/ILogger.kt deleted file mode 100644 index a5ee77a7..00000000 --- a/processor/src/main/kotlin/co/anitrend/support/query/builder/processor/logger/contract/ILogger.kt +++ /dev/null @@ -1,24 +0,0 @@ -/* - * Copyright 2023 AniTrend - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * https://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -package co.anitrend.support.query.builder.processor.logger.contract - -internal interface ILogger { - fun lineBreakWithSeparatorCharacter() - fun debug(message: String) - fun warning(message: String) - fun error(message: String?) -} diff --git a/processor/src/main/kotlin/co/anitrend/support/query/builder/processor/model/Candidate.kt b/processor/src/main/kotlin/co/anitrend/support/query/builder/processor/model/Candidate.kt index ab47348e..9e78ab8e 100644 --- a/processor/src/main/kotlin/co/anitrend/support/query/builder/processor/model/Candidate.kt +++ b/processor/src/main/kotlin/co/anitrend/support/query/builder/processor/model/Candidate.kt @@ -1,94 +1,113 @@ -/* - * Copyright 2023 AniTrend - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * https://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - package co.anitrend.support.query.builder.processor.model import androidx.room.ColumnInfo import androidx.room.Embedded import androidx.room.Entity -import co.anitrend.support.query.builder.processor.extensions.enclosingTypeOf -import co.anitrend.support.query.builder.processor.logger.contract.ILogger +import co.anitrend.support.query.builder.processor.extensions.annotationArgOf +import co.anitrend.support.query.builder.processor.extensions.annotationOf import co.anitrend.support.query.builder.processor.model.column.ColumnItem import co.anitrend.support.query.builder.processor.model.core.Item import co.anitrend.support.query.builder.processor.model.embed.EmbedItem import co.anitrend.support.query.builder.processor.model.table.TableItem -import javax.lang.model.element.Element -import javax.lang.model.util.Elements -import javax.lang.model.util.Types +import com.google.devtools.ksp.getDeclaredProperties +import com.google.devtools.ksp.processing.KSPLogger +import com.google.devtools.ksp.symbol.KSClassDeclaration +import com.google.devtools.ksp.symbol.KSDeclaration +import com.google.devtools.ksp.symbol.KSPropertyDeclaration internal class Candidate( - private val types: Types, - private val logger: ILogger, - private val element: Element, + val classDeclaration: KSClassDeclaration, + private val logger: KSPLogger, ) { - private fun name(): String = element.simpleName.toString() - fun packageName(elements: Elements) = elements.getPackageOf(element).toString() - fun createFileName() = "${name()}Schema" - - private fun getColumns(element: Element): List { - val fieldColumns = element.enclosingTypeOf(ColumnInfo::class.java) - if (fieldColumns.isEmpty()) { - logger.warning("$element does not contain any properties annotated with `androidx.room.ColumnInfo`") - } else { - logger.debug("Column names for $element as [${fieldColumns.joinToString(", ") { "`${it.annotation.name}`" }}]") - } - return fieldColumns.map { - ColumnItem(it.annotation.name, it.element.simpleName.toString()) + val packageName: String = classDeclaration.packageName.asString() + val className: String = classDeclaration.simpleName.asString() + val fileName: String = "${className}Schema" + + private fun KSDeclaration.getColumn(): ColumnItem? { + val columnInfo = annotationOf(ColumnInfo::class) + if (columnInfo == null) { + logger.warn("[KSCandidate] Column property `${simpleName.getShortName()}` does not have a column annotation") + return null } + logger.info("[KSCandidate] Column name for $classDeclaration as `${columnInfo.shortName.asString()}`") + + val columnName = columnInfo.arguments.find { argument -> + argument.name?.getShortName() == ColumnInfo::name.name + }?.value as String + + + return ColumnItem( + name = columnName, + fieldName = simpleName.getShortName() + ) } - private fun getEmbedded(): List { - return element.enclosingTypeOf(Embedded::class.java).map { field -> - logger.debug( - "Embedded prefix for $element.`${field.element.simpleName}` as ${ - "`${field.annotation.prefix}` of type ${field.element.asType()}" - }", + private fun Sequence.getEmbeddings(): List { + return mapNotNull { propertyDeclaration -> + val embeddedAnnotation = propertyDeclaration.annotationOf(Embedded::class) + + if (embeddedAnnotation == null) { + logger.warn("[KSCandidate] Embedded property `${propertyDeclaration.simpleName.getShortName()}` does not have an embedded annotation") + return@mapNotNull null + } + + val argument = embeddedAnnotation.annotationArgOf(Embedded::prefix) + + val prefix = argument?.value as? String + if (prefix == null) { + logger.warn("[KSCandidate] Embedded property `${propertyDeclaration.simpleName.getShortName()}` does not have a prefix argument") + } else { + logger.info( + "[KSCandidate] Embedded prefix for `${argument.name}` as `${prefix}`}", + ) + } + + val typeDeclaration: KSDeclaration? = propertyDeclaration.type.resolve().declaration + if (typeDeclaration !is KSClassDeclaration) { + logger.warn("[KSCandidate] Embedded property `${propertyDeclaration.simpleName.getShortName()}` type is not a class declaration") + return@mapNotNull null + } + + logger.info( + "[KSCandidate] Embedded `${propertyDeclaration.simpleName.getShortName()}` with prefix '$prefix' and type `$typeDeclaration`" ) - // The embedded class type of the current field - val parentElement = types.asElement(field.element.asType()) + + val columns = typeDeclaration + .getDeclaredProperties() + .mapNotNull { property -> + logger.info("[KSCandidate] Inspecting property `${property.simpleName.getShortName()}`") + property.getColumn() + }.toList() EmbedItem( - prefix = field.annotation.prefix, - fieldName = field.element.simpleName.toString(), - columns = getColumns(parentElement), + prefix = prefix ?: "", + fieldName = propertyDeclaration.simpleName.getShortName(), + columns = columns, ) - } + }.toList() } fun getTable(): Item { - val entity = element.getAnnotation(Entity::class.java) + val tableName = classDeclaration.annotationArgOf { valueArgument -> + logger.info("[KSCandidate.getTable] Argument name: ${valueArgument.name?.getShortName()} should match ${Entity::tableName.name}") + valueArgument.name?.getShortName() == Entity::tableName.name + }.value as String - val tableName = when { - entity.tableName.isEmpty() -> { - logger.debug("$element does not have `tableName` set, using class name instead") - element.simpleName.toString() - } - else -> entity.tableName - } + logger.info("[KSCandidate] Table name for $classDeclaration will be displayed as `$tableName`") + + val columns = classDeclaration.getDeclaredProperties().mapNotNull { property -> + logger.info("[KSCandidate] Inspecting property `${property.simpleName.getShortName()}`") + property.getColumn() + }.toList() + + val embeddings = classDeclaration.getDeclaredProperties().getEmbeddings() - logger.debug("Table name for $element will be displayed as `$tableName`") return TableItem( - tableName, - getColumns(element), - getEmbedded(), + name = requireNotNull(tableName) { "[KSCandidate.getTable] Table name cannot be null" }, + columns = columns, + embeddings = embeddings, ) } - /** - * Returns a string representation of the object. - */ - override fun toString(): String = element.toString() + override fun toString(): String = classDeclaration.simpleName.asString() } diff --git a/processor/src/main/kotlin/co/anitrend/support/query/builder/processor/model/table/TableItem.kt b/processor/src/main/kotlin/co/anitrend/support/query/builder/processor/model/table/TableItem.kt index ed46e63a..ffe92158 100644 --- a/processor/src/main/kotlin/co/anitrend/support/query/builder/processor/model/table/TableItem.kt +++ b/processor/src/main/kotlin/co/anitrend/support/query/builder/processor/model/table/TableItem.kt @@ -24,7 +24,7 @@ import com.squareup.kotlinpoet.TypeSpec internal data class TableItem( private val name: String, private val columns: List, - private val embedded: List, + private val embeddings: List, ) : Item { override fun writeToBuilder(builder: TypeSpec.Builder) { builder.addProperty( @@ -32,7 +32,7 @@ internal data class TableItem( .initializer("%S", name) .build(), ) - (columns + embedded).forEach { it.writeToBuilder(builder) } + (columns + embeddings).forEach { it.writeToBuilder(builder) } } /** diff --git a/processor/src/test/kotlin/co/anitrend/support/query/builder/processor/ProcessorTest.kt b/processor/src/test/kotlin/co/anitrend/support/query/builder/processor/ProcessorTest.kt new file mode 100644 index 00000000..0d246ebe --- /dev/null +++ b/processor/src/test/kotlin/co/anitrend/support/query/builder/processor/ProcessorTest.kt @@ -0,0 +1,94 @@ +package co.anitrend.support.query.builder.processor + +import co.anitrend.support.query.builder.processor.util.template +import co.anitrend.support.query.builder.processor.util.verifyFailing +import co.anitrend.support.query.builder.processor.util.verifyPassing +import com.tschuchort.compiletesting.SourceFile.Companion.kotlin +import org.jetbrains.kotlin.compiler.plugin.ExperimentalCompilerApi +import org.junit.jupiter.api.Test +import org.junit.jupiter.api.io.TempDir +import java.io.File + +class ProcessorTest { + + @TempDir + lateinit var temporaryFolder: File + + @OptIn(ExperimentalCompilerApi::class) + @Test + fun `should pass when entity is annotated correctly`() { + verifyPassing( + temporaryFolder = temporaryFolder, + source = kotlin( + name = "Person.kt", + contents = """ + package co.anitrend.support.query.builder.sample.data.entity.person + + import androidx.room.ColumnInfo + import androidx.room.Embedded + import androidx.room.Entity + import androidx.room.PrimaryKey + import co.anitrend.support.query.builder.annotation.EntitySchema + + @EntitySchema + @Entity(tableName = "person") + internal data class PersonEntity( + @PrimaryKey(autoGenerate = true) + @ColumnInfo(name = "id") val id: Long, + @ColumnInfo(name = "first_name") val firstName: String, + @ColumnInfo(name = "last_name") val lastName: String, + @Embedded(prefix = "city_") val city: City + ) { + data class City( + @ColumnInfo(name = "name") val name: String, + @ColumnInfo(name = "region") val region: String, + @ColumnInfo(name = "country") val country: String + ) + } + """.trimIndent() + ), + output = template( + """ + package co.anitrend.support.query.builder.sample.`data`.entity.person + + import kotlin.String + + public object PersonEntitySchema { + public const val tableName: String = "person" + + public const val id: String = "id" + + public const val firstName: String = "first_name" + + public const val lastName: String = "last_name" + + public const val cityName: String = "city_name" + + public const val cityRegion: String = "city_region" + + public const val cityCountry: String = "city_country" + } + """.trimIndent() + ) + ) + } + + @Test + fun `should fail when entity is not annotated with anything`() { + verifyFailing( + temporaryFolder = temporaryFolder, + source = kotlin( + name = "Entity.kt", + contents = """ + package com.example + + internal data class Entity( + val id: Long, + val firstName: String, + val lastName: String, + ) + """.trimIndent() + ), + ) + } +} diff --git a/processor/src/test/kotlin/co/anitrend/support/query/builder/processor/extensions/SourceCompilerExtensions.kt b/processor/src/test/kotlin/co/anitrend/support/query/builder/processor/extensions/SourceCompilerExtensions.kt new file mode 100644 index 00000000..f965fbca --- /dev/null +++ b/processor/src/test/kotlin/co/anitrend/support/query/builder/processor/extensions/SourceCompilerExtensions.kt @@ -0,0 +1,40 @@ +package co.anitrend.support.query.builder.processor.extensions + +import co.anitrend.support.query.builder.processor.Provider +import com.tschuchort.compiletesting.JvmCompilationResult +import com.tschuchort.compiletesting.KotlinCompilation +import com.tschuchort.compiletesting.SourceFile +import com.tschuchort.compiletesting.configureKsp +import org.jetbrains.kotlin.compiler.plugin.ExperimentalCompilerApi +import java.io.File + +private const val KOTLIN_COMPILER_VERSION = "1.9" + +@OptIn(ExperimentalCompilerApi::class) +fun SourceFile.compilation( + temporaryFolder: File, + useKsp2: Boolean = true, +) = KotlinCompilation().let { kotlinCompilation -> + kotlinCompilation.workingDir = temporaryFolder + kotlinCompilation.inheritClassPath = true + kotlinCompilation.sources = listOf(this) + kotlinCompilation.verbose = true + kotlinCompilation.configureKsp(useKsp2 = useKsp2) { + symbolProcessorProviders += Provider() + incremental = true // The default now + if (!useKsp2) { + withCompilation = true // Only necessary for KSP1 + kotlinCompilation.languageVersion = KOTLIN_COMPILER_VERSION + } + } + kotlinCompilation +} + +@OptIn(ExperimentalCompilerApi::class) +fun JvmCompilationResult.generatedKotlinSources(): List { + val kspSourcesDir = outputDirectory.resolve("../ksp/sources") + .walkTopDown() + .filter { it.isFile && it.extension == "kt" } + .toList() + return kspSourcesDir +} diff --git a/processor/src/test/kotlin/co/anitrend/support/query/builder/processor/util/TestExt.kt b/processor/src/test/kotlin/co/anitrend/support/query/builder/processor/util/TestExt.kt new file mode 100644 index 00000000..bb407c93 --- /dev/null +++ b/processor/src/test/kotlin/co/anitrend/support/query/builder/processor/util/TestExt.kt @@ -0,0 +1,57 @@ +package co.anitrend.support.query.builder.processor.util + +import co.anitrend.support.query.builder.processor.extensions.compilation +import co.anitrend.support.query.builder.processor.extensions.generatedKotlinSources +import com.tschuchort.compiletesting.KotlinCompilation +import com.tschuchort.compiletesting.SourceFile +import org.intellij.lang.annotations.Language +import org.jetbrains.kotlin.compiler.plugin.ExperimentalCompilerApi +import org.junit.jupiter.api.Assertions +import java.io.File + +fun template(@Language("kotlin") content: String) = content.trimIndent() + +@OptIn(ExperimentalCompilerApi::class) +fun verifyPassing( + temporaryFolder: File, + source: SourceFile, + @Language("kotlin") output: String +) { + val result = source.compilation( + temporaryFolder = temporaryFolder, + ).compile() + + Assertions.assertEquals(KotlinCompilation.ExitCode.OK, result.exitCode) + + val generatedFiles = result.generatedKotlinSources() + Assertions.assertTrue( + generatedFiles.isNotEmpty(), + "`generatedFiles` cannot be empty, make sure that files are being written" + ) + + val generatedFile = generatedFiles.find { it.name.contains("EntitySchema") } + Assertions.assertNotNull( + generatedFile, + "No file matching `*EntitySchema.kt` exists in `generatedFiles`" + ) + + Assertions.assertEquals(output, generatedFile?.readText()?.trim()) +} + +@OptIn(ExperimentalCompilerApi::class) +fun verifyFailing( + temporaryFolder: File, + source: SourceFile, +) { + val result = source.compilation( + temporaryFolder = temporaryFolder, + ).compile() + + Assertions.assertEquals(KotlinCompilation.ExitCode.OK, result.exitCode) + + val generatedFiles = result.generatedKotlinSources() + Assertions.assertTrue( + generatedFiles.isEmpty(), + "`generatedFiles` should be empty" + ) +} diff --git a/sample/build.gradle.kts b/sample/build.gradle.kts index c0956288..3cfd25fe 100644 --- a/sample/build.gradle.kts +++ b/sample/build.gradle.kts @@ -1,40 +1,47 @@ +import com.google.devtools.ksp.gradle.KspAATask +import org.gradle.kotlin.dsl.withType +import org.jetbrains.kotlin.gradle.tasks.KotlinCompile +import com.google.devtools.ksp.gradle.KspTask +import org.jetbrains.kotlin.gradle.tasks.AbstractKotlinCompileTool + plugins { id("co.anitrend.support.query.builder.plugin") + alias(libs.plugins.google.devtools.ksp) } - android { namespace = "co.anitrend.support.query.builder.sample" } -tasks.withType { - dependsOn(":annotations:classesJar", ":processor:classesJar", ":core:classesJar", ":core:ext:classesJar") -} - dependencies { - implementation(libs.androidx.activityKtx) - implementation(libs.androidx.fragmentKtx) + implementation(project(":annotations")) + implementation(project(":core")) + implementation(project(":core:ext")) + ksp(project(":processor")) + + implementation(libs.androidx.activity) + implementation(libs.androidx.activity.ktx) + implementation(libs.androidx.fragment) + implementation(libs.androidx.fragment.ktx) implementation(libs.androidx.appcompat) implementation(libs.androidx.appcompatResources) implementation(libs.androidx.constraintLayout) - implementation(libs.androidx.navigation.fragmentKtx) - implementation(libs.androidx.navigation.uiKtx) + implementation(libs.androidx.navigation.fragment) + implementation(libs.androidx.navigation.fragment.ktx) + implementation(libs.androidx.navigation.ui) + implementation(libs.androidx.navigation.ui.ktx) implementation(libs.google.android.material) - implementation(libs.androidx.room.ktx) implementation(libs.androidx.room.runtime) - kapt(libs.androidx.room.compiler) + implementation(libs.androidx.room.common) + implementation(libs.androidx.room.ktx) + ksp(libs.androidx.room.compiler) - androidTestImplementation(libs.androidx.test.coreKtx) + androidTestImplementation(libs.androidx.test.core) androidTestImplementation(libs.androidx.test.rules) androidTestImplementation(libs.androidx.test.runner) androidTestImplementation(libs.mockk.android) - - implementation(project(":annotations")) - implementation(project(":core")) - implementation(project(":core:ext")) - kapt(project(":processor")) } diff --git a/sample/src/main/kotlin/co/anitrend/support/query/builder/sample/data/entity/person/PersonEntity.kt b/sample/src/main/kotlin/co/anitrend/support/query/builder/sample/data/entity/person/PersonEntity.kt index 47dbd501..71a1b837 100644 --- a/sample/src/main/kotlin/co/anitrend/support/query/builder/sample/data/entity/person/PersonEntity.kt +++ b/sample/src/main/kotlin/co/anitrend/support/query/builder/sample/data/entity/person/PersonEntity.kt @@ -20,4 +20,4 @@ internal data class PersonEntity( @ColumnInfo(name = "region") val region: String, @ColumnInfo(name = "country") val country: String ) -} \ No newline at end of file +} diff --git a/sample/src/main/kotlin/co/anitrend/support/query/builder/sample/data/entity/pet/PetEntity.kt b/sample/src/main/kotlin/co/anitrend/support/query/builder/sample/data/entity/pet/PetEntity.kt index ea440cc6..d1f162c6 100644 --- a/sample/src/main/kotlin/co/anitrend/support/query/builder/sample/data/entity/pet/PetEntity.kt +++ b/sample/src/main/kotlin/co/anitrend/support/query/builder/sample/data/entity/pet/PetEntity.kt @@ -1,6 +1,11 @@ package co.anitrend.support.query.builder.sample.data.entity.pet -import androidx.room.* +import androidx.room.Embedded +import androidx.room.ColumnInfo +import androidx.room.ForeignKey +import androidx.room.PrimaryKey +import androidx.room.Entity +import androidx.room.Index import co.anitrend.support.query.builder.annotation.EntitySchema import co.anitrend.support.query.builder.sample.data.entity.person.PersonEntity @@ -39,4 +44,4 @@ internal data class PetEntity( @ColumnInfo(name = "option_a") val optionA: String ) } -} \ No newline at end of file +}