From c52eb9b3e406ac5b7a77a277bbc90dcfef62f103 Mon Sep 17 00:00:00 2001 From: Kasem SM Date: Mon, 22 Aug 2022 14:29:06 +0530 Subject: [PATCH] Refactor EasyStore API (#6) * Improved EasyStore API * Improved EasyStore API (2) * Code cleanup --- .gitignore | 7 +- build.gradle | 1 - buildSrc/.gitignore | 1 + buildSrc/src/main/kotlin/LibraryConfigs.kt | 2 +- .../java/kasem/sm/easystore/core/EasyStore.kt | 2 +- .../java/kasem/sm/easystore/core/Retrieve.kt | 11 + .../java/kasem/sm/easystore/core/Store.kt | 5 +- easystore-processor/build.gradle.kts | 12 +- .../sm/easystore/processor/StoreProcessor.kt | 23 +- .../sm/easystore/processor/StoreVisitor.kt | 273 ++++++++++++------ .../generator/DsFactoryClassGenerator.kt | 198 +++++++++++++ .../generator/DsFunctionsGenerator.kt | 184 +++++++----- .../processor/generator/StoreFileGenerator.kt | 5 +- .../easystore/processor/ksp/KspExtensions.kt | 23 +- .../sm/easystore/processor/ksp/Mapper.kt | 23 +- .../processor/validators/Validators.kt | 53 ++-- sample/build.gradle | 2 +- sample/src/main/AndroidManifest.xml | 2 +- .../sm/easystore/{ => sample}/MainActivity.kt | 12 +- settings.gradle | 4 + 20 files changed, 606 insertions(+), 237 deletions(-) create mode 100644 buildSrc/.gitignore create mode 100644 easystore-core/src/main/java/kasem/sm/easystore/core/Retrieve.kt create mode 100644 easystore-processor/src/main/java/kasem/sm/easystore/processor/generator/DsFactoryClassGenerator.kt rename sample/src/main/java/kasem/sm/easystore/{ => sample}/MainActivity.kt (59%) diff --git a/.gitignore b/.gitignore index aa724b7..10cfdbf 100644 --- a/.gitignore +++ b/.gitignore @@ -1,12 +1,7 @@ *.iml .gradle /local.properties -/.idea/caches -/.idea/libraries -/.idea/modules.xml -/.idea/workspace.xml -/.idea/navEditor.xml -/.idea/assetWizardSettings.xml +/.idea .DS_Store /build /captures diff --git a/build.gradle b/build.gradle index 021f9bf..f15b882 100644 --- a/build.gradle +++ b/build.gradle @@ -18,7 +18,6 @@ subprojects { kotlin { licenseHeaderFile project.rootProject.file('spotless/copyright.kt') target "**/*.kt" - ktlint().editorConfigOverride(['android': 'true', 'max_line_length': '200']) } format 'misc', { target '**/*.gradle', '**/*.md', '**/.gitignore' diff --git a/buildSrc/.gitignore b/buildSrc/.gitignore new file mode 100644 index 0000000..42afabf --- /dev/null +++ b/buildSrc/.gitignore @@ -0,0 +1 @@ +/build \ No newline at end of file diff --git a/buildSrc/src/main/kotlin/LibraryConfigs.kt b/buildSrc/src/main/kotlin/LibraryConfigs.kt index b0e313b..1525fac 100644 --- a/buildSrc/src/main/kotlin/LibraryConfigs.kt +++ b/buildSrc/src/main/kotlin/LibraryConfigs.kt @@ -1,4 +1,4 @@ object LibraryConfigs { const val groupId = "com.kasem-sm.easystore" - const val version = "0.0.1-alpha" + const val version = "0.0.2-alpha" } diff --git a/easystore-core/src/main/java/kasem/sm/easystore/core/EasyStore.kt b/easystore-core/src/main/java/kasem/sm/easystore/core/EasyStore.kt index 4928bb8..16dbb52 100644 --- a/easystore-core/src/main/java/kasem/sm/easystore/core/EasyStore.kt +++ b/easystore-core/src/main/java/kasem/sm/easystore/core/EasyStore.kt @@ -6,4 +6,4 @@ package kasem.sm.easystore.core @Target(AnnotationTarget.CLASS) @Retention(AnnotationRetention.SOURCE) -annotation class EasyStore() +annotation class EasyStore diff --git a/easystore-core/src/main/java/kasem/sm/easystore/core/Retrieve.kt b/easystore-core/src/main/java/kasem/sm/easystore/core/Retrieve.kt new file mode 100644 index 0000000..e002769 --- /dev/null +++ b/easystore-core/src/main/java/kasem/sm/easystore/core/Retrieve.kt @@ -0,0 +1,11 @@ +/* + * Copyright (C) 2022, Kasem S.M + * All rights reserved. + */ +package kasem.sm.easystore.core + +@Target(AnnotationTarget.FUNCTION) +@Retention(AnnotationRetention.SOURCE) +annotation class Retrieve( + val preferenceKeyName: String +) diff --git a/easystore-core/src/main/java/kasem/sm/easystore/core/Store.kt b/easystore-core/src/main/java/kasem/sm/easystore/core/Store.kt index a2d0965..b96c331 100644 --- a/easystore-core/src/main/java/kasem/sm/easystore/core/Store.kt +++ b/easystore-core/src/main/java/kasem/sm/easystore/core/Store.kt @@ -4,9 +4,8 @@ */ package kasem.sm.easystore.core -@Target(AnnotationTarget.FUNCTION) +@Target(AnnotationTarget.FUNCTION, AnnotationTarget.CLASS) @Retention(AnnotationRetention.SOURCE) annotation class Store( - val preferenceKeyName: String, - val getterFunctionName: String + val preferenceKeyName: String ) diff --git a/easystore-processor/build.gradle.kts b/easystore-processor/build.gradle.kts index ce2d53e..89b050e 100644 --- a/easystore-processor/build.gradle.kts +++ b/easystore-processor/build.gradle.kts @@ -7,17 +7,23 @@ sourceSets.main { java.srcDirs("src/main/kotlin") } -dependencies { - implementation("androidx.datastore:datastore-preferences-core:1.0.0") +tasks.withType().configureEach { + kotlinOptions.freeCompilerArgs += "-opt-in=kotlin.RequiresOptIn" +} +dependencies { implementation(project(":easystore-core")) - implementation("com.google.devtools.ksp:symbol-processing-api:1.7.10-1.0.6") + implementation("androidx.datastore:datastore-preferences-core:1.0.0") + implementation("com.google.devtools.ksp:symbol-processing-api:1.7.10-1.0.6") + implementation("com.squareup:kotlinpoet:1.12.0") implementation("com.squareup:kotlinpoet-ksp:1.12.0") implementation("com.google.auto.service:auto-service-annotations:1.0") ksp("dev.zacsweers.autoservice:auto-service-ksp:1.0.0") + + testImplementation("junit:junit:4.13.2") } rootProject.extra.apply { diff --git a/easystore-processor/src/main/java/kasem/sm/easystore/processor/StoreProcessor.kt b/easystore-processor/src/main/java/kasem/sm/easystore/processor/StoreProcessor.kt index d3eaed6..3092cf2 100644 --- a/easystore-processor/src/main/java/kasem/sm/easystore/processor/StoreProcessor.kt +++ b/easystore-processor/src/main/java/kasem/sm/easystore/processor/StoreProcessor.kt @@ -41,21 +41,18 @@ class StoreProcessor( .forEach { visitor = StoreVisitor(logger) it.accept(visitor, Unit) + storeFileGenerator = StoreFileGenerator(visitor) + try { + storeFileGenerator.fileSpec.build() + .writeTo(codeGenerator = codeGenerator, aggregating = false) + } catch (e: FileAlreadyExistsException) { + logger.logging(e.message.toString()) + } catch (e: Exception) { + logger.error(e.message.toString()) + } } - if (::visitor.isInitialized) { - storeFileGenerator = StoreFileGenerator(visitor) - - try { - storeFileGenerator.fileSpec.build().writeTo(codeGenerator = codeGenerator, aggregating = false) - } catch (e: FileAlreadyExistsException) { - logger.logging(e.message.toString()) - } catch (e: Exception) { - logger.error(e.message.toString()) - } - - unresolvedSymbols = resolved - validatedSymbols.toSet() - } + unresolvedSymbols = resolved - validatedSymbols.toSet() } return unresolvedSymbols } diff --git a/easystore-processor/src/main/java/kasem/sm/easystore/processor/StoreVisitor.kt b/easystore-processor/src/main/java/kasem/sm/easystore/processor/StoreVisitor.kt index edb18c0..d0759c9 100644 --- a/easystore-processor/src/main/java/kasem/sm/easystore/processor/StoreVisitor.kt +++ b/easystore-processor/src/main/java/kasem/sm/easystore/processor/StoreVisitor.kt @@ -4,36 +4,38 @@ */ package kasem.sm.easystore.processor +import com.google.devtools.ksp.innerArguments import com.google.devtools.ksp.processing.KSPLogger +import com.google.devtools.ksp.processing.Resolver import com.google.devtools.ksp.symbol.ClassKind import com.google.devtools.ksp.symbol.KSClassDeclaration import com.google.devtools.ksp.symbol.KSFunctionDeclaration +import com.google.devtools.ksp.symbol.KSType import com.google.devtools.ksp.symbol.KSVisitorVoid +import com.google.devtools.ksp.symbol.Modifier +import com.squareup.kotlinpoet.ClassName import com.squareup.kotlinpoet.FunSpec import com.squareup.kotlinpoet.PropertySpec import com.squareup.kotlinpoet.ksp.toClassName -import kasem.sm.easystore.processor.generator.DsFunctionsGenerator -import kasem.sm.easystore.processor.generator.dsImportNameGenerator +import kasem.sm.easystore.core.Retrieve +import kasem.sm.easystore.core.Store +import kasem.sm.easystore.processor.generator.DsFactoryClassGenerator import kasem.sm.easystore.processor.ksp.checkIfReturnTypeExists -import kasem.sm.easystore.processor.ksp.getStoreAnnotationArgs +import kasem.sm.easystore.processor.ksp.isDataClass import kasem.sm.easystore.processor.ksp.isEnumClass import kasem.sm.easystore.processor.ksp.supportedTypes -import kasem.sm.easystore.processor.validators.validateFunctionNameAlreadyExistsOrNot -import kasem.sm.easystore.processor.validators.validatePreferenceKeyIsUniqueOrNot -import kasem.sm.easystore.processor.validators.validateStoreArgs class StoreVisitor( private val logger: KSPLogger ) : KSVisitorVoid() { - internal lateinit var className: String + internal lateinit var className: ClassName internal lateinit var packageName: String + private val factoryClassGenerator = DsFactoryClassGenerator(logger) - internal val generatedFunctions = mutableListOf() - internal val generatedProperties = mutableListOf() - internal val generatedImportNames = mutableListOf() - - private val generator = DsFunctionsGenerator() + internal var generatedFunctions = listOf() + internal var generatedProperties = listOf() + internal var generatedImportNames = listOf() override fun visitClassDeclaration(classDeclaration: KSClassDeclaration, data: Unit) { if (classDeclaration.classKind != ClassKind.INTERFACE) { @@ -42,99 +44,196 @@ class StoreVisitor( } packageName = classDeclaration.packageName.asString() - className = "${classDeclaration.simpleName.asString()}Factory" + className = classDeclaration.toClassName() + + val functions = classDeclaration.getAllFunctions().toList() + + functions + .filter { + it.annotations.firstOrNull()?.shortName?.asString() == Store::class.simpleName + }.filter { + if (it.modifiers.firstOrNull() != Modifier.SUSPEND) { + logger.error("Functions annotated with @Store should be used with suspend keyword.") + return + } + it.modifiers.firstOrNull() == Modifier.SUSPEND + } + .forEach { + visitFunctionDeclaration(it, Unit) + } - classDeclaration - .getAllFunctions() - .toList() + functions + .filter { + it.annotations.firstOrNull()?.shortName?.asString() == Retrieve::class.simpleName + } + .filter { + if (it.modifiers.firstOrNull() == Modifier.SUSPEND) { + logger.error("Functions annotated with @Retrieve should not have the suspend keyword.") + return + } + it.modifiers.firstOrNull() != Modifier.SUSPEND + } .forEach { visitFunctionDeclaration(it, Unit) } } override fun visitFunctionDeclaration(function: KSFunctionDeclaration, data: Unit) { - val annotationArguments = function.annotations.firstOrNull()?.arguments ?: return - val (preferenceKeyName, getterFunctionName) = annotationArguments.getStoreAnnotationArgs() - val preferenceKeyPropertyName = "${preferenceKeyName.uppercase()}_KEY" - val functionName = function.simpleName.getShortName() - - validateStoreArgs( - preferenceKeyName = preferenceKeyName, - getterFunctionName = getterFunctionName, - functionName = functionName - ) { logger.error(it) } - - generatedFunctions.validateFunctionNameAlreadyExistsOrNot( - currentFunctionName = getterFunctionName - ) { logger.error(it) } - - generatedProperties.validatePreferenceKeyIsUniqueOrNot( - currentPropertyName = preferenceKeyPropertyName - ) { logger.error(it) } - - if (function.parameters.isEmpty()) { - logger.error( - "Functions annotated with @Store should have at least one parameter. " + - "The function, $functionName doesn't have any." - ) - return - } - - if (function.parameters.size > 1) { - logger.error( - "Functions annotated with @Store can only have one parameter. " + - "The function, $functionName has more than one." - ) - return - } + val functionName = function.simpleName.asString() + val functionAnnotation = function.annotations + val functionParameters = function.parameters + val functionParameter = functionParameters.firstOrNull() + val functionReturnType = function.returnType?.resolve() + val functionAnnotationName = functionAnnotation.firstOrNull()?.shortName?.asString() + + if (functionAnnotationName == Store::class.simpleName) { + val prefKeyName = functionAnnotation.first().arguments[0].value as String + if (functionParameter == null) { + logger.error( + "Functions annotated with @Store should have at least one parameter. " + + "The function, $functionName doesn't have any." + ) + return + } - function.checkIfReturnTypeExists(logger) + val parameter = functionParameter.type.resolve() - val functionParameterType = function.parameters[0].type.resolve() + if (functionParameters.size > 1) { + logger.error( + "Functions annotated with @Store can only have one parameter. " + + "The function, $functionName has more than one." + ) + return + } - val functionParameterKClass = functionParameterType.toClassName() + function.checkIfReturnTypeExists(logger) + + when { + supportedTypes.find { type -> parameter.toClassName() == type } != null -> false + parameter.isEnumClass -> false + parameter.isDataClass -> false + else -> true + }.also { + if (it) { + logger.error("Function $functionName parameter type $parameter is not supported by Datastore yet!") + return + } + } - val showError = when { - supportedTypes.find { functionParameterKClass == it } != null -> false - functionParameterType.isEnumClass -> false - else -> true - } + factoryClassGenerator + .initialize( + function = function, + preferenceKeyName = prefKeyName, + functionParameterType = parameter, + functionAnnotationName = functionAnnotationName!! + ) + .initiateFunctionWithStoreArgs() + } else if (functionAnnotationName == Retrieve::class.simpleName) { + val prefKeyName = functionAnnotation.first().arguments[0].value as String + if (functionParameter == null) { + logger.error( + "Functions annotated with @Retrieve should have a parameter that is of the same type as the function's return type. " + + "For example: If a function returns Flow then the parameter of the function should be of type String which will be used by EasyStore as the default value while retrieving nullable String data from DataStore Preferences. " + + "The function, $functionName doesn't have any." + ) + return + } - if (showError) { - logger.error("$functionName parameter type $functionParameterType is not supported by Datastore yet!") - return - } + val parameter = functionParameter.type.resolve() - functionParameterType.dsImportNameGenerator { name -> - generatedImportNames.add(name) - } + if (functionParameters.size > 1) { + logger.error( + "Functions annotated with @Retrieve can only have one parameter. " + + "The function, $functionName has more than one." + ) + return + } - generator - .generateDSAddFunction( - actualFunctionName = functionName, - actualFunctionParameterName = function.parameters[0].name?.getShortName(), - functionParamType = functionParameterType, - preferenceKeyPropertyName = preferenceKeyPropertyName - ).apply { - generatedFunctions.add(this) + // Retrieve + if (functionReturnType == null || functionReturnType.toClassName().simpleName != "Flow") { + logger.error( + "Functions annotated with @Retrieve should return Flow. " + + "($functionName)" + ) + return } - generator - .generateDSGetFunction( - functionParameterType = functionParameterType, - functionName = getterFunctionName, - preferenceKeyPropertyName = preferenceKeyPropertyName, - actualFunctionParameter = functionParameterType - ).apply { - generatedFunctions.add(this) + if (functionReturnType.toClassName().simpleName == "Flow") { + // Check for inner args + val innerType = functionReturnType.innerArguments.first().type?.resolve() ?: return + + if (innerType.toClassName().simpleName != parameter.toClassName().simpleName + ) { + logger.error("The return type for the function $functionName should be Flow<${parameter.toClassName().simpleName}> as the parameter type is ${parameter.toClassName().simpleName}.") + return + } + + if (parameter.isDataClass) { + function.initiate( + parameter = parameter, + functionName = functionName, + preferenceKeyName = prefKeyName + ) + } else factoryClassGenerator + .initialize( + function = function, + preferenceKeyName = prefKeyName, + functionParameterType = parameter, + functionAnnotationName = functionAnnotationName!! + ).initiateFunctionsWithRetrieveArgs() + } else { + logger.error("return type name is not flow, ${functionReturnType.toClassName().simpleName}") + return } + } else return - generator - .generateDSKeyProperty( - functionParameterType = functionParameterType, + generatedFunctions = (factoryClassGenerator.generatedFunctions) + generatedProperties = (factoryClassGenerator.generatedProperties.map { it.spec }) + generatedImportNames = (factoryClassGenerator.generatedImportNames) + } + + private fun KSFunctionDeclaration.initiate( + parameter: KSType, + functionName: String, + preferenceKeyName: String, + ) { + val dataClass: KSClassDeclaration = parameter.declaration as KSClassDeclaration + + val unSupportedParamName = mutableListOf() + + val areDataClassPropertiesSupported = + dataClass.getAllProperties().toList() + .map { property -> + val toClass = property.type.resolve() + if (toClass.isDataClass) { + // Nested data class + // Not supported as of now + logger.error("Nested data class is not supported by EasyStore. (${toClass.toClassName().simpleName})") + return + } + Pair( + supportedTypes.find { type -> toClass.toClassName() == type } != null || toClass.isEnumClass, + property.simpleName.asString() + ) + }.also { list -> + list.filter { (b, _) -> + !b + }.forEach { (_, s) -> + unSupportedParamName.add(s) + } + } + + if (areDataClassPropertiesSupported.any { (b, _) -> !b }) { + logger.error("$unSupportedParamName parameter(s) of the class linked to the function $functionName are not supported by Datastore yet!") + return + } + + factoryClassGenerator + .initialize( + function = this, + functionParameterType = parameter, + functionAnnotationName = Retrieve::class.simpleName!!, preferenceKeyName = preferenceKeyName - ).apply { - generatedProperties.add(this) - } + ).initiateFunctionsWithRetrieveArgs() } } diff --git a/easystore-processor/src/main/java/kasem/sm/easystore/processor/generator/DsFactoryClassGenerator.kt b/easystore-processor/src/main/java/kasem/sm/easystore/processor/generator/DsFactoryClassGenerator.kt new file mode 100644 index 0000000..696627a --- /dev/null +++ b/easystore-processor/src/main/java/kasem/sm/easystore/processor/generator/DsFactoryClassGenerator.kt @@ -0,0 +1,198 @@ +/* + * Copyright (C) 2022, Kasem S.M + * All rights reserved. + */ +package kasem.sm.easystore.processor.generator + +import com.google.devtools.ksp.processing.KSPLogger +import com.google.devtools.ksp.processing.Resolver +import com.google.devtools.ksp.symbol.KSClassDeclaration +import com.google.devtools.ksp.symbol.KSFunctionDeclaration +import com.google.devtools.ksp.symbol.KSType +import com.squareup.kotlinpoet.FunSpec +import com.squareup.kotlinpoet.PropertySpec +import com.squareup.kotlinpoet.ksp.toClassName +import kasem.sm.easystore.core.Store +import kasem.sm.easystore.processor.ksp.getAllProperties +import kasem.sm.easystore.processor.ksp.isDataClass +import kasem.sm.easystore.processor.validators.validatePreferenceKeyIsUniqueOrNot +import kasem.sm.easystore.processor.validators.validateStoreArgs + +data class PropKey( + val spec: PropertySpec, + val annotationName: String +) + +internal class DsFactoryClassGenerator( + private val logger: KSPLogger +) { + internal val generatedFunctions = mutableListOf() + internal val generatedProperties = mutableListOf() + internal val generatedImportNames = mutableListOf() + + private val generator = DsFunctionsGenerator() + private lateinit var preferenceKeyName: String + private lateinit var preferenceKeyPropertyName: String + private lateinit var functionName: String + private lateinit var function: KSFunctionDeclaration + private lateinit var functionParameterType: KSType + private lateinit var functionAnnotationName: String + + fun initialize( + function: KSFunctionDeclaration, + preferenceKeyName: String, + functionParameterType: KSType, + functionAnnotationName: String + ): DsFactoryClassGenerator = apply { + this.functionAnnotationName = functionAnnotationName + this.function = function + this.preferenceKeyName = preferenceKeyName + this.functionParameterType = functionParameterType + + preferenceKeyPropertyName = "${preferenceKeyName.uppercase()}_KEY" + functionName = function.simpleName.getShortName() + + validateStoreArgs( + preferenceKeyName = preferenceKeyName, + functionName = functionName, + logger = logger + ) + + generatedProperties.validatePreferenceKeyIsUniqueOrNot( + currentPropertyName = preferenceKeyPropertyName, + logger = logger + ) + } + + fun initiateFunctionWithStoreArgs() { + // Generate DS Key Properties + if (functionParameterType.isDataClass) { + val dataClass: KSClassDeclaration = + functionParameterType.declaration as KSClassDeclaration + + // Generate imports for all Data class properties + functionParameterType.getAllProperties().map { property -> + val type = property.type.resolve() + // Nested data class + // Not supported as of now + if (type.isDataClass) { + logger.error("Nested data class is not supported by EasyStore. (${type.toClassName().simpleName})") + return + } + type + }.forEach { + it.dsImportNameGenerator { generatedImportNames.add(it) } + } + + val properties = dataClass.getAllProperties().toList() + + val preferenceKeyTypeFromDataClass = properties.map { property -> + property.type.resolve() + } + + val preferenceKeyPropertyNameFromDataClass = properties.map { property -> + (functionParameterType.toClassName().simpleName + "_" + property.simpleName.asString() + "_key").uppercase() + } + + // Generate Property Keys + generator + .generateDSKeyProperty( + preferenceKeyType = preferenceKeyTypeFromDataClass, + preferenceKeyName = preferenceKeyPropertyNameFromDataClass + ).onEach { + generatedProperties.add(PropKey(it, functionAnnotationName)) + } + + // Generate Add Function + generator + .generateDSAddFunction( + functionName = functionName, + functionParamType = functionParameterType, + preferenceKeyPropertyName = preferenceKeyPropertyNameFromDataClass, + functionParameterName = function.parameters[0].name?.asString() ?: "value" + ).apply { + generatedFunctions.add(this) + } + } else { + functionParameterType.dsImportNameGenerator { name -> + generatedImportNames.add(name) + } + + // Generate Property Keys + generator + .generateDSKeyProperty( + functionParameterType = functionParameterType, + preferenceKeyName = preferenceKeyName + ).apply { + generatedProperties.add(PropKey(this, functionAnnotationName)) + } + + // Generate Add Function + generator + .generateDSAddFunction( + functionName = functionName, + functionParamType = functionParameterType, + preferenceKeyPropertyName = listOf(preferenceKeyPropertyName), + functionParameterName = function.parameters[0].name?.asString() ?: "value" + ).apply { + generatedFunctions.add(this) + } + } + } + + fun initiateFunctionsWithRetrieveArgs() { + if (functionParameterType.isDataClass) { + val dataClass: KSClassDeclaration = + functionParameterType.declaration as KSClassDeclaration + + val preferenceKeysFromDataClass = + dataClass.getAllProperties().toList().map { property -> + (functionParameterType.toClassName().simpleName + "_" + property.simpleName.asString() + "_key").uppercase() + } + + generatedProperties + .filter { it.annotationName == Store::class.simpleName } + .find { it.spec.name == preferenceKeysFromDataClass[0] } + .also { + if (it == null) { + // TODO("Improve Error Message") + logger.error("Before retrieving any data, please use @Store with $preferenceKeyName preferenceKeyName.") + return + } + } + + // Generate Get Function + generator + .generateDSGetFunction( + functionParameterType = functionParameterType, + functionName = functionName, + preferenceKeyPropertyName = preferenceKeysFromDataClass, + parameterName = function.parameters[0].name?.asString() ?: "defaultValue" + ).apply { + generatedFunctions.add(this) + } + } else { + generatedProperties + .filter { it.annotationName == Store::class.simpleName } + .find { it.spec.name == preferenceKeyPropertyName } + .also { + if (it == null) { + // TODO("Improve Error Message") + logger.error("Before retrieving any data, please use @Store with $preferenceKeyName preferenceKeyName.") + return + } + } + + // Generate Get Function + generator + .generateDSGetFunction( + functionParameterType = functionParameterType, + functionName = functionName, + preferenceKeyPropertyName = listOf(preferenceKeyPropertyName), + parameterName = function.parameters[0].name?.asString() ?: "defaultValue" + ).apply { + generatedFunctions.add(this) + } + } + } +} diff --git a/easystore-processor/src/main/java/kasem/sm/easystore/processor/generator/DsFunctionsGenerator.kt b/easystore-processor/src/main/java/kasem/sm/easystore/processor/generator/DsFunctionsGenerator.kt index a281299..94814fd 100644 --- a/easystore-processor/src/main/java/kasem/sm/easystore/processor/generator/DsFunctionsGenerator.kt +++ b/easystore-processor/src/main/java/kasem/sm/easystore/processor/generator/DsFunctionsGenerator.kt @@ -5,7 +5,6 @@ package kasem.sm.easystore.processor.generator import com.google.devtools.ksp.symbol.KSType -import com.google.devtools.ksp.symbol.Modifier import com.squareup.kotlinpoet.CodeBlock import com.squareup.kotlinpoet.FunSpec import com.squareup.kotlinpoet.KModifier @@ -13,125 +12,160 @@ import com.squareup.kotlinpoet.ParameterizedTypeName.Companion.parameterizedBy import com.squareup.kotlinpoet.PropertySpec import com.squareup.kotlinpoet.asClassName import com.squareup.kotlinpoet.ksp.toClassName +import kasem.sm.easystore.processor.ksp.getAllProperties +import kasem.sm.easystore.processor.ksp.isDataClass import kasem.sm.easystore.processor.ksp.isEnumClass import kasem.sm.easystore.processor.ksp.toDataStoreKey +import kasem.sm.easystore.processor.ksp.toPreferenceKeyCode import kotlinx.coroutines.flow.Flow internal class DsFunctionsGenerator { + fun generateDSKeyProperty( + preferenceKeyType: List, + preferenceKeyName: List + ): List { + return preferenceKeyType.zip(preferenceKeyName).map { (type, keyName) -> + buildPropertyType( + ksType = type, + preferenceKeyName = keyName, + propertyName = keyName.uppercase() + ) + } + } + fun generateDSKeyProperty( functionParameterType: KSType, preferenceKeyName: String ): PropertySpec { val preferenceKeyPropertyName = "${preferenceKeyName.uppercase()}_KEY" - val dataStoreKeyType = functionParameterType.toDataStoreKey().parameterizedBy( - if (functionParameterType.isEnumClass) { - String::class.asClassName() - } else functionParameterType.toClassName() + return buildPropertyType( + ksType = functionParameterType, + preferenceKeyName = preferenceKeyName, + propertyName = preferenceKeyPropertyName ) - - val codeBlock = when (functionParameterType.declaration.simpleName.asString()) { - Int::class.simpleName -> "intPreferencesKey(\"$preferenceKeyName\")" - String::class.simpleName -> "stringPreferencesKey(\"$preferenceKeyName\")" - Double::class.simpleName -> "doublePreferencesKey(\"$preferenceKeyName\")" - Boolean::class.simpleName -> "booleanPreferencesKey(\"$preferenceKeyName\")" - Float::class.simpleName -> "floatPreferencesKey(\"$preferenceKeyName\")" - Long::class.simpleName -> "longPreferencesKey(\"$preferenceKeyName\")" - else -> { - if (functionParameterType.declaration.modifiers.first() == Modifier.ENUM) { - "stringPreferencesKey(\"$preferenceKeyName\")" - } else throw Exception() - } - } - - return PropertySpec.builder( - name = preferenceKeyPropertyName, - type = dataStoreKeyType - ).apply { - addModifiers(KModifier.PRIVATE) - initializer(CodeBlock.of(codeBlock)) - }.build() } fun generateDSAddFunction( - actualFunctionName: String, - actualFunctionParameterName: String?, + functionName: String, functionParamType: KSType, - preferenceKeyPropertyName: String + preferenceKeyPropertyName: List, + functionParameterName: String ): FunSpec { val isEnum = functionParamType.isEnumClass + val isDataClass = functionParamType.isDataClass // Check if it's enum and not String::class val afterElvis = if (isEnum) { - (actualFunctionParameterName ?: "value") + ".name" - } else actualFunctionParameterName ?: "value" + "$functionParameterName.name" + } else functionParameterName + + val editBlock = if (isDataClass) { + var addBlock = "" + functionParamType.getAllProperties().zip(preferenceKeyPropertyName).forEach { (property, key) -> + val type = property.type.resolve() + val afterInnerElvis = if (type.isEnumClass) { + "$functionParameterName.${property.simpleName.asString()}.name\n" + } else "$functionParameterName.${property.simpleName.asString()}\n" + addBlock += "preferences[$key] = $afterInnerElvis" + } + addBlock + } else "preferences[${preferenceKeyPropertyName[0]}] = $afterElvis" + + val codeBlock = """ + |dataStore.edit { preferences -> + | $editBlock + |} + """.trimMargin() return FunSpec.builder( - name = actualFunctionName + name = functionName ).apply { + addModifiers(KModifier.OVERRIDE) addModifiers(KModifier.SUSPEND) addParameter( - name = actualFunctionParameterName ?: "value", + name = functionParameterName, type = functionParamType.toClassName() ) - addCode( - CodeBlock.of( - """ - dataStore.edit { preferences -> - preferences[$preferenceKeyPropertyName] = $afterElvis - } - """ - .trimIndent() - ) - ) + addCode(CodeBlock.of(codeBlock)) }.build() } fun generateDSGetFunction( functionParameterType: KSType, functionName: String, - preferenceKeyPropertyName: String, - actualFunctionParameter: KSType + preferenceKeyPropertyName: List, + parameterName: String ): FunSpec { - val paramType = if (functionParameterType.isEnumClass) { - actualFunctionParameter.toClassName() - } else functionParameterType.toClassName() - - val codeBlock = if (functionParameterType.isEnumClass) { - """ - return dataStore.data - .catch { exception -> - if (exception is IOException) { - emit(emptyPreferences()) - } else { - throw exception - } - }.map { preference -> - $paramType.valueOf(preference[$preferenceKeyPropertyName] ?: defaultValue.name) - } - """.trimIndent() + val paramType = functionParameterType.toClassName() + + val mapBlock = if (!functionParameterType.isDataClass) { + if (functionParameterType.isEnumClass) { + "$paramType.valueOf(preference[${preferenceKeyPropertyName[0]}] ?: $parameterName.name)" + } else { + "preference[${preferenceKeyPropertyName[0]}] ?: $parameterName" + } } else { - """ - return dataStore.data - .catch { exception -> - if (exception is IOException) { - emit(emptyPreferences()) - } else { - throw exception - } - }.map { preference -> - preference[$preferenceKeyPropertyName] ?: defaultValue + var codeBlock = "" + functionParameterType.getAllProperties().zip(preferenceKeyPropertyName) + .forEachIndexed { index, (property, key) -> + val type = property.type.resolve() + codeBlock += if (type.isEnumClass) { + "${type.toClassName()}.valueOf(preference[${preferenceKeyPropertyName[0]}] ?: $parameterName.${property.simpleName.asString()}.name),\n" + } else "preference[$key] ?: $parameterName.${property.simpleName.asString()},\n" } - """.trimIndent() + "$paramType($codeBlock)" } + val codeBlock = + """ |return dataStore.data + |.catch { exception -> + | if (exception is IOException) { + | emit(emptyPreferences()) + | } else { + | throw exception + | } + |}.map { preference -> + | $mapBlock + |} + """.trimMargin() + return FunSpec.builder( name = functionName ).apply { - addParameter("defaultValue", paramType) + addModifiers(KModifier.OVERRIDE) + addParameter(parameterName, paramType) returns(Flow::class.asClassName().parameterizedBy(paramType)) addCode(codeBlock) }.build() } + + companion object { + private fun buildPropertyType( + ksType: KSType, + preferenceKeyName: String, + propertyName: String + ): PropertySpec { + val dataStoreKeyType = ksType.toDataStoreKey().parameterizedBy( + if (ksType.isEnumClass) { + String::class.asClassName() + } else ksType.toClassName() + ) + + val codeBlock = ksType.declaration.simpleName.asString() + .toPreferenceKeyCode( + preferenceKeyName = preferenceKeyName, + isEnum = ksType.isEnumClass + ) + + return PropertySpec.builder( + name = propertyName, + type = dataStoreKeyType + ).apply { + addModifiers(KModifier.PRIVATE) + initializer(CodeBlock.of(codeBlock)) + }.build() + } + } } diff --git a/easystore-processor/src/main/java/kasem/sm/easystore/processor/generator/StoreFileGenerator.kt b/easystore-processor/src/main/java/kasem/sm/easystore/processor/generator/StoreFileGenerator.kt index 638cfeb..25d6ed6 100644 --- a/easystore-processor/src/main/java/kasem/sm/easystore/processor/generator/StoreFileGenerator.kt +++ b/easystore-processor/src/main/java/kasem/sm/easystore/processor/generator/StoreFileGenerator.kt @@ -19,7 +19,7 @@ internal class StoreFileGenerator( visitor: StoreVisitor ) { private val packageName: String = visitor.packageName - private val className: String = visitor.className + private val className: String = "${visitor.className.simpleName}Impl" private val generatedImports: List = visitor.generatedImportNames private val generatedFunctions: List = visitor.generatedFunctions private val generatedProperties: List = visitor.generatedProperties @@ -31,7 +31,7 @@ internal class StoreFileGenerator( init { fileSpec.apply { - addFileComment("This class is generated by EasyStore (https://github.com/kasem-sm/EasyStore)") + addFileComment("Class implementation generated by EasyStore (https://github.com/kasem-sm/EasyStore)") addNecessaryDataStoreImports() // Other optional imports @@ -41,6 +41,7 @@ internal class StoreFileGenerator( addType( TypeSpec.classBuilder(className).apply { + addSuperinterface(visitor.className) // Class constructor buildAndAddPropertiesToClassConstructor() // Generated functions diff --git a/easystore-processor/src/main/java/kasem/sm/easystore/processor/ksp/KspExtensions.kt b/easystore-processor/src/main/java/kasem/sm/easystore/processor/ksp/KspExtensions.kt index 7a703c6..bd31f56 100644 --- a/easystore-processor/src/main/java/kasem/sm/easystore/processor/ksp/KspExtensions.kt +++ b/easystore-processor/src/main/java/kasem/sm/easystore/processor/ksp/KspExtensions.kt @@ -7,22 +7,33 @@ package kasem.sm.easystore.processor.ksp import com.google.devtools.ksp.processing.KSPLogger +import com.google.devtools.ksp.symbol.KSClassDeclaration import com.google.devtools.ksp.symbol.KSFunctionDeclaration +import com.google.devtools.ksp.symbol.KSPropertyDeclaration +import com.google.devtools.ksp.symbol.KSType import com.google.devtools.ksp.symbol.KSValueArgument +import com.google.devtools.ksp.symbol.Modifier internal fun List.getStoreAnnotationArgs(): Pair { return Pair(get(0).value as String, get(1).value as String) } internal fun KSFunctionDeclaration.checkIfReturnTypeExists(logger: KSPLogger) { - val functionName = simpleName.asString() val returnTypeAsString = returnType?.resolve()?.declaration?.simpleName?.asString() if (returnTypeAsString != null && returnTypeAsString != "Unit") { - logger.error( - "You shouldn't add any return value to the function annotated with @Store. " + - "PS: The function's parameter type would also be it's return type but wrapped inside Kotlin Flow. " + - "For example, the function $functionName would return Flow<$returnTypeAsString>." - ) + logger.error("You shouldn't add any return value to the function annotated with @Store.") return } } + +fun KSType.getAllProperties(): List { + if (isDataClass) { + val dataClass: KSClassDeclaration = declaration as KSClassDeclaration + return dataClass.getAllProperties().toList() + } + return emptyList() +} + +internal val KSType.isEnumClass get() = declaration.modifiers.firstOrNull() == Modifier.ENUM + +internal val KSType.isDataClass get() = declaration.modifiers.firstOrNull() == Modifier.DATA diff --git a/easystore-processor/src/main/java/kasem/sm/easystore/processor/ksp/Mapper.kt b/easystore-processor/src/main/java/kasem/sm/easystore/processor/ksp/Mapper.kt index 2aaea87..317c75d 100644 --- a/easystore-processor/src/main/java/kasem/sm/easystore/processor/ksp/Mapper.kt +++ b/easystore-processor/src/main/java/kasem/sm/easystore/processor/ksp/Mapper.kt @@ -11,10 +11,11 @@ import androidx.datastore.preferences.core.intPreferencesKey import androidx.datastore.preferences.core.longPreferencesKey import androidx.datastore.preferences.core.stringPreferencesKey import com.google.devtools.ksp.symbol.KSType -import com.google.devtools.ksp.symbol.Modifier import com.squareup.kotlinpoet.ClassName import com.squareup.kotlinpoet.asClassName +// TODO("Add custom exceptions") + internal fun KSType.toDataStoreKey(): ClassName { return when (declaration.simpleName.asString()) { Int::class.simpleName -> intPreferencesKey("")::class @@ -24,14 +25,14 @@ internal fun KSType.toDataStoreKey(): ClassName { Float::class.simpleName -> floatPreferencesKey("")::class Long::class.simpleName -> longPreferencesKey("")::class else -> { - if (declaration.modifiers.first() == Modifier.ENUM) { + if (isEnumClass || isDataClass) { stringPreferencesKey("")::class } else throw UnknownError() } }.asClassName() } -val supportedTypes = listOf( +internal val supportedTypes = listOf( Int::class, String::class, Double::class, @@ -43,4 +44,18 @@ val supportedTypes = listOf( it.asClassName() } -internal val KSType.isEnumClass get() = declaration.modifiers.firstOrNull() == Modifier.ENUM +internal fun String.toPreferenceKeyCode(preferenceKeyName: String, isEnum: Boolean): String { + return when (this) { + Int::class.simpleName -> "intPreferencesKey(\"$preferenceKeyName\")" + String::class.simpleName -> "stringPreferencesKey(\"$preferenceKeyName\")" + Double::class.simpleName -> "doublePreferencesKey(\"$preferenceKeyName\")" + Boolean::class.simpleName -> "booleanPreferencesKey(\"$preferenceKeyName\")" + Float::class.simpleName -> "floatPreferencesKey(\"$preferenceKeyName\")" + Long::class.simpleName -> "longPreferencesKey(\"$preferenceKeyName\")" + else -> { + if (isEnum) { + "stringPreferencesKey(\"$preferenceKeyName\")" + } else throw Exception() + } + } +} diff --git a/easystore-processor/src/main/java/kasem/sm/easystore/processor/validators/Validators.kt b/easystore-processor/src/main/java/kasem/sm/easystore/processor/validators/Validators.kt index e908e5b..5edcc89 100644 --- a/easystore-processor/src/main/java/kasem/sm/easystore/processor/validators/Validators.kt +++ b/easystore-processor/src/main/java/kasem/sm/easystore/processor/validators/Validators.kt @@ -4,53 +4,44 @@ */ package kasem.sm.easystore.processor.validators -import com.squareup.kotlinpoet.FunSpec -import com.squareup.kotlinpoet.PropertySpec +import com.google.devtools.ksp.processing.KSPLogger +import kasem.sm.easystore.core.Store +import kasem.sm.easystore.processor.generator.PropKey internal fun validateStoreArgs( preferenceKeyName: String, - getterFunctionName: String, functionName: String, - errorMessage: (String) -> Unit + logger: KSPLogger ) { if (preferenceKeyName.isEmpty()) { - errorMessage("preferenceKeyName for $functionName is empty.") + logger.error("preferenceKeyName for $functionName is empty.") return } - if (getterFunctionName.isEmpty()) { - errorMessage("getterFunctionName for $functionName is empty.") - return - } - - if (preferenceKeyName.containsSpecialChars() || getterFunctionName.containsSpecialChars()) { - errorMessage("preferenceKeyName or the getterFunctionName should not contain any special characters.") + if (preferenceKeyName.containsSpecialChars()) { + logger.error("preferenceKeyName should not contain any special characters.") return } } -internal fun List.validateFunctionNameAlreadyExistsOrNot( - currentFunctionName: String, - errorMessage: (String) -> Unit +internal fun List.validatePreferenceKeyIsUniqueOrNot( + currentPropertyName: String, + logger: KSPLogger ) { - val spec = find { - it.name == currentFunctionName - } - if (spec != null) { - errorMessage("The getterFunctionName is same for two or more functions annotated with @Store.") - return + filter { + it.annotationName == Store::class.simpleName && it.spec.name == currentPropertyName + }.also { + if (it.size > 1) { + logger.error("preferenceKeyName is not unique. Every function that are annotated with @Store should have a unique key name.") + return + } } -} -internal fun List.validatePreferenceKeyIsUniqueOrNot( - currentPropertyName: String, - errorMessage: (String) -> Unit -) { - find { - it.name == currentPropertyName - }.apply { - if (this != null) { - errorMessage("preferenceKeyName is not unique. Every function that are annotated with @Store should have a unique key name.") + filter { + it.annotationName == Retention::class.simpleName && it.spec.name == currentPropertyName + }.also { + if (it.size > 1) { + logger.error("preferenceKeyName is not unique. Every function that are annotated with @Retrieve should have a unique key name.") return } } diff --git a/sample/build.gradle b/sample/build.gradle index 9853e12..a2bae5e 100644 --- a/sample/build.gradle +++ b/sample/build.gradle @@ -8,7 +8,7 @@ android { compileSdk 32 defaultConfig { - applicationId "kasem.sm.easystore" + applicationId "kasem.sm.easystore.sample" minSdk 21 targetSdk 32 versionCode 1 diff --git a/sample/src/main/AndroidManifest.xml b/sample/src/main/AndroidManifest.xml index a158657..97762b3 100644 --- a/sample/src/main/AndroidManifest.xml +++ b/sample/src/main/AndroidManifest.xml @@ -1,7 +1,7 @@ + package="kasem.sm.easystore.sample"> - by preferencesDataStore(name = "my_prefs") + by preferencesDataStore(name = "my_prefs") override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) diff --git a/settings.gradle b/settings.gradle index fb5299d..b5190f3 100644 --- a/settings.gradle +++ b/settings.gradle @@ -16,3 +16,7 @@ rootProject.name = "EasyStore" include ':sample' include ':easystore-core' include ':easystore-processor' + +//v2 test +include ':easystore-core' +include ':easystore-processor' \ No newline at end of file