diff --git a/yawn-api/src/main/kotlin/com/faire/yawn/YawnDef.kt b/yawn-api/src/main/kotlin/com/faire/yawn/YawnDef.kt index 08e2c2b..fac946e 100644 --- a/yawn-api/src/main/kotlin/com/faire/yawn/YawnDef.kt +++ b/yawn-api/src/main/kotlin/com/faire/yawn/YawnDef.kt @@ -22,6 +22,10 @@ abstract class YawnDef { abstract inner class YawnColumnDef : YawnQueryProjection { abstract fun generatePath(context: YawnCompilationContext): String + open fun adaptValue(value: F): Any? { + return value + } + override fun compile(context: YawnCompilationContext): Projection { return Projections.property(generatePath(context)) } diff --git a/yawn-api/src/main/kotlin/com/faire/yawn/YawnTableDef.kt b/yawn-api/src/main/kotlin/com/faire/yawn/YawnTableDef.kt index f0285e5..0299ceb 100644 --- a/yawn-api/src/main/kotlin/com/faire/yawn/YawnTableDef.kt +++ b/yawn-api/src/main/kotlin/com/faire/yawn/YawnTableDef.kt @@ -1,5 +1,6 @@ package com.faire.yawn +import com.faire.yawn.adapter.YawnValueAdapter import com.faire.yawn.project.YawnQueryProjection import com.faire.yawn.query.YawnCompilationContext import org.hibernate.criterion.Projection @@ -52,10 +53,17 @@ abstract class YawnTableDef( * * @param F the type of the column. */ - inner class ColumnDef(private vararg val path: String?) : YawnColumnDef() { + inner class ColumnDef( + private vararg val path: String?, + private val adapter: YawnValueAdapter? = null, + ) : YawnColumnDef() { override fun generatePath(context: YawnCompilationContext): String { return listOfNotNull(context.generateAlias(parent), *path).joinToString(".") } + + override fun adaptValue(value: F): Any? { + return adapter?.adapt(value) ?: super.adaptValue(value) + } } /** diff --git a/yawn-api/src/main/kotlin/com/faire/yawn/adapter/YawnValueAdapter.kt b/yawn-api/src/main/kotlin/com/faire/yawn/adapter/YawnValueAdapter.kt new file mode 100644 index 0000000..ea971a8 --- /dev/null +++ b/yawn-api/src/main/kotlin/com/faire/yawn/adapter/YawnValueAdapter.kt @@ -0,0 +1,12 @@ +package com.faire.yawn.adapter + +/** + * An optional adapter to be used when querying with the type of this column. + * This allows Yawn to be smarter about the type-system than the underlying Hibernate is. + * + * For example, if you have a value class wrapping a primitive, the generate metamodel will automatically un-wrap it + * so it works with Hibernate. + */ +fun interface YawnValueAdapter { + fun adapt(value: T): Any? +} diff --git a/yawn-api/src/main/kotlin/com/faire/yawn/query/YawnQueryRestriction.kt b/yawn-api/src/main/kotlin/com/faire/yawn/query/YawnQueryRestriction.kt index 3751ac6..bde535c 100644 --- a/yawn-api/src/main/kotlin/com/faire/yawn/query/YawnQueryRestriction.kt +++ b/yawn-api/src/main/kotlin/com/faire/yawn/query/YawnQueryRestriction.kt @@ -19,7 +19,7 @@ interface YawnQueryRestriction { ) : YawnQueryRestriction { override fun compile( context: YawnCompilationContext, - ): Criterion = Restrictions.eq(property.generatePath(context), value) + ): Criterion = Restrictions.eq(property.generatePath(context), property.adaptValue(value)) } class EqualsProperty( @@ -37,7 +37,7 @@ interface YawnQueryRestriction { ) : YawnQueryRestriction { override fun compile( context: YawnCompilationContext, - ): Criterion = Restrictions.ne(property.generatePath(context), value) + ): Criterion = Restrictions.ne(property.generatePath(context), property.adaptValue(value)) } class NotEqualsProperty( @@ -55,7 +55,7 @@ interface YawnQueryRestriction { ) : YawnQueryRestriction { override fun compile( context: YawnCompilationContext, - ): Criterion = Restrictions.gt(property.generatePath(context), value) + ): Criterion = Restrictions.gt(property.generatePath(context), property.adaptValue(value)) } class GreaterThanProperty( @@ -73,7 +73,7 @@ interface YawnQueryRestriction { ) : YawnQueryRestriction { override fun compile( context: YawnCompilationContext, - ): Criterion = Restrictions.ge(property.generatePath(context), value) + ): Criterion = Restrictions.ge(property.generatePath(context), property.adaptValue(value)) } class GreaterThanOrEqualToProperty( @@ -91,7 +91,7 @@ interface YawnQueryRestriction { ) : YawnQueryRestriction { override fun compile( context: YawnCompilationContext, - ): Criterion = Restrictions.lt(property.generatePath(context), value) + ): Criterion = Restrictions.lt(property.generatePath(context), property.adaptValue(value)) } class LessThanProperty( @@ -109,7 +109,7 @@ interface YawnQueryRestriction { ) : YawnQueryRestriction { override fun compile( context: YawnCompilationContext, - ): Criterion = Restrictions.le(property.generatePath(context), value) + ): Criterion = Restrictions.le(property.generatePath(context), property.adaptValue(value)) } class LessThanOrEqualToProperty( @@ -128,7 +128,11 @@ interface YawnQueryRestriction { ) : YawnQueryRestriction { override fun compile( context: YawnCompilationContext, - ): Criterion = Restrictions.between(property.generatePath(context), lo, hi) + ): Criterion = Restrictions.between( + property.generatePath(context), + property.adaptValue(lo), + property.adaptValue(hi), + ) } class Not( @@ -147,9 +151,7 @@ interface YawnQueryRestriction { internal constructor(vararg criteria: YawnQueryCriterion) : this(criteria.toList()) override fun compile(context: YawnCompilationContext): Criterion = Restrictions.or( - *criteria.map { - it.yawnRestriction.compile(context) - }.toTypedArray(), + *criteria.map { it.yawnRestriction.compile(context) }.toTypedArray(), ) } @@ -159,9 +161,7 @@ interface YawnQueryRestriction { constructor(vararg criteria: YawnQueryCriterion) : this(criteria.toList()) override fun compile(context: YawnCompilationContext): Criterion = Restrictions.and( - *criteria.map { - it.yawnRestriction.compile(context) - }.toTypedArray(), + *criteria.map { it.yawnRestriction.compile(context) }.toTypedArray(), ) } @@ -172,7 +172,9 @@ interface YawnQueryRestriction { ) : YawnQueryRestriction { override fun compile( context: YawnCompilationContext, - ): Criterion = Restrictions.like(column.generatePath(context), value, matchMode) + ): Criterion { + return Restrictions.like(column.generatePath(context), column.adaptAsString(value), matchMode) + } } class ILike( @@ -182,7 +184,7 @@ interface YawnQueryRestriction { ) : YawnQueryRestriction { override fun compile( context: YawnCompilationContext, - ): Criterion = Restrictions.ilike(column.generatePath(context), value, matchMode) + ): Criterion = Restrictions.ilike(column.generatePath(context), column.adaptAsString(value), matchMode) } class IsNotNull( @@ -207,7 +209,7 @@ interface YawnQueryRestriction { ) : YawnQueryRestriction { override fun compile( context: YawnCompilationContext, - ): Criterion = Restrictions.eqOrIsNull(column.generatePath(context), value) + ): Criterion = Restrictions.eqOrIsNull(column.generatePath(context), column.adaptValue(value)) } class In( @@ -218,7 +220,7 @@ interface YawnQueryRestriction { return if (values.isEmpty()) { Restrictions.sqlRestriction("0=1") } else { - Restrictions.`in`(column.generatePath(context), values) + Restrictions.`in`(column.generatePath(context), values.map { column.adaptValue(it) }) } } } @@ -231,7 +233,7 @@ interface YawnQueryRestriction { return if (values.isEmpty()) { Restrictions.sqlRestriction("1=1") } else { - Restrictions.not(Restrictions.`in`(column.generatePath(context), values)) + Restrictions.not(Restrictions.`in`(column.generatePath(context), values.map { column.adaptValue(it) })) } } } @@ -252,3 +254,18 @@ interface YawnQueryRestriction { ): Criterion = Restrictions.isNotEmpty(joinColumn.path(context)) } } + +private fun YawnDef.YawnColumnDef.adaptAsString(value: F): String? { + val adaptedValue = adaptValue(value) + if (adaptedValue !is String?) { + error( + """ + Like restriction can only be applied to String values, + but got: ${adaptedValue.javaClass} due to adapter on column $this. + This means a wrong adapter was code-generated into the metamodel. + Please open an issue on GitHub with your schema definition. + """.trimIndent(), + ) + } + return adaptedValue +} diff --git a/yawn-database-test/src/main/kotlin/com/faire/yawn/setup/entities/BookFixtures.kt b/yawn-database-test/src/main/kotlin/com/faire/yawn/setup/entities/BookFixtures.kt index 014b88c..7904012 100644 --- a/yawn-database-test/src/main/kotlin/com/faire/yawn/setup/entities/BookFixtures.kt +++ b/yawn-database-test/src/main/kotlin/com/faire/yawn/setup/entities/BookFixtures.kt @@ -142,6 +142,7 @@ internal class BookFixtures( val paul = createPerson { name = "Paul Duchesne" email = EmailAddress("paul.duchesne@faire.com") + phone = PhoneNumber("(555) 123-4567") favoriteBook = lordOfTheRings favoriteAuthor = andersen } @@ -149,9 +150,19 @@ internal class BookFixtures( val luan = createPerson { name = "Luan Nico" email = EmailAddress("luan@faire.com") + phone = PhoneNumber( + areaCode = "555", + centralOfficeCode = "987", + lineNumber = "6543", + ) favoriteBook = hp favoriteAuthor = tolkien } + createPerson { + name = "Quinn Budan" + email = EmailAddress("quinn@faire.com") + phone = PhoneNumber("(333) 000-1111") + } update(rowling) { favoriteBook = lordOfTheRings favoriteAuthor = tolkien diff --git a/yawn-database-test/src/main/kotlin/com/faire/yawn/setup/entities/Person.kt b/yawn-database-test/src/main/kotlin/com/faire/yawn/setup/entities/Person.kt index d8e84a7..3bd9e2a 100644 --- a/yawn-database-test/src/main/kotlin/com/faire/yawn/setup/entities/Person.kt +++ b/yawn-database-test/src/main/kotlin/com/faire/yawn/setup/entities/Person.kt @@ -36,6 +36,12 @@ internal class Person : TimestampedEntity(), PersonInterface { @Column lateinit var email: EmailAddress + /** + * Test for value/inline classes that resolve as primitives for Hibernate + */ + @Column + var phone: PhoneNumber? = null + @ManyToOne(fetch = FetchType.LAZY) var favoriteBook: Book? = null diff --git a/yawn-database-test/src/main/kotlin/com/faire/yawn/setup/entities/PhoneNumber.kt b/yawn-database-test/src/main/kotlin/com/faire/yawn/setup/entities/PhoneNumber.kt new file mode 100644 index 0000000..29b5f95 --- /dev/null +++ b/yawn-database-test/src/main/kotlin/com/faire/yawn/setup/entities/PhoneNumber.kt @@ -0,0 +1,29 @@ +package com.faire.yawn.setup.entities + +@JvmInline +internal value class PhoneNumber( + val value: String, +) { + init { + require(regex.matches(value)) { "Phone number must match pattern (XXX) XXX-XXXX" } + } + + constructor( + areaCode: String, + centralOfficeCode: String, + lineNumber: String, + ) : this("($areaCode) $centralOfficeCode-$lineNumber") + + val areaCode: String + get() = value.substring(1, 4) + + val centralOfficeCode: String + get() = value.substring(6, 9) + + val lineNumber: String + get() = value.substring(10, 14) + + override fun toString(): String = value +} + +private val regex = Regex("""^\(\d{3}\) \d{3}-\d{4}$""") diff --git a/yawn-database-test/src/test/kotlin/com/faire/yawn/database/ValueClassTest.kt b/yawn-database-test/src/test/kotlin/com/faire/yawn/database/ValueClassTest.kt new file mode 100644 index 0000000..8adc566 --- /dev/null +++ b/yawn-database-test/src/test/kotlin/com/faire/yawn/database/ValueClassTest.kt @@ -0,0 +1,132 @@ +package com.faire.yawn.database + +import com.faire.yawn.setup.entities.PersonTable +import com.faire.yawn.setup.entities.PhoneNumber +import org.assertj.core.api.Assertions.assertThat +import org.junit.jupiter.api.Test + +internal class ValueClassTest : BaseYawnDatabaseTest() { + @Test + fun `can fetch and unwrap`() { + transactor.open { session -> + val person = session.query(PersonTable) { people -> + addEq(people.name, "Luan Nico") + }.uniqueResult()!! + with(person.phone!!) { + assertThat(this).isEqualTo(PhoneNumber("(555) 987-6543")) + assertThat(areaCode).isEqualTo("555") + assertThat(centralOfficeCode).isEqualTo("987") + assertThat(lineNumber).isEqualTo("6543") + } + } + } + + @Test + fun `query with eq`() { + transactor.open { session -> + val person = session.query(PersonTable) { people -> + addEq(people.phone, PhoneNumber("(333) 000-1111")) + }.uniqueResult()!! + assertThat(person.name).isEqualTo("Quinn Budan") + } + } + + @Test + fun `query with addNotEq`() { + transactor.open { session -> + val people = session.query(PersonTable) { people -> + addNotEq(people.phone, PhoneNumber("(555) 987-6543")) + addIsNotNull(people.phone) + }.list() + assertThat(people.map { it.name }).containsExactlyInAnyOrder("Paul Duchesne", "Quinn Budan") + } + } + + @Test + fun `query with addGt`() { + transactor.open { session -> + val people = session.query(PersonTable) { people -> + addGt(people.phone, PhoneNumber("(555) 000-0000")) + }.list() + assertThat(people.map { it.name }).containsExactlyInAnyOrder("Luan Nico", "Paul Duchesne") + } + } + + @Test + fun `query with addGe`() { + transactor.open { session -> + val people = session.query(PersonTable) { people -> + addGe(people.phone, PhoneNumber("(555) 987-6543")) + }.list() + assertThat(people.single().name).isEqualTo("Luan Nico") + } + } + + @Test + fun `query with addLt`() { + transactor.open { session -> + val people = session.query(PersonTable) { people -> + addLt(people.phone, PhoneNumber("(555) 123-4567")) + addIsNotNull(people.phone) + }.list() + assertThat(people.single().name).isEqualTo("Quinn Budan") + } + } + + @Test + fun `query with addLe`() { + transactor.open { session -> + val people = session.query(PersonTable) { people -> + addLe(people.phone, PhoneNumber("(555) 123-4567")) + addIsNotNull(people.phone) + }.list() + assertThat(people.map { it.name }).containsExactlyInAnyOrder("Paul Duchesne", "Quinn Budan") + } + } + + @Test + fun `query with addBetween`() { + transactor.open { session -> + val people = session.query(PersonTable) { people -> + addBetween(people.phone, PhoneNumber("(555) 100-0000"), PhoneNumber("(555) 900-0000")) + }.list() + assertThat(people.single().name).isEqualTo("Paul Duchesne") + } + } + + @Test + fun `query with addEqOrIsNull`() { + transactor.open { session -> + val people = session.query(PersonTable) { people -> + addEqOrIsNull(people.phone, PhoneNumber("(555) 987-6543")) + }.list() + assertThat(people.map { it.name }).contains("Luan Nico") + } + } + + @Test + fun `query with addIn`() { + transactor.open { session -> + val phoneNumbers = listOf( + PhoneNumber("(555) 123-4567"), + PhoneNumber("(555) 987-6543"), + ) + val people = session.query(PersonTable) { people -> + addIn(people.phone, phoneNumbers) + }.list() + assertThat(people.map { it.name }).containsExactlyInAnyOrder("Luan Nico", "Paul Duchesne") + } + } + + @Test + fun `query with addNotIn`() { + transactor.open { session -> + val phoneNumbers = listOf(PhoneNumber("(555) 987-6543"), PhoneNumber("(555) 123-4567")) + val people = session.query(PersonTable) { people -> + addNotIn(people.phone, phoneNumbers) + addIsNotNull(people.phone) + }.list() + assertThat(people.single().name).isEqualTo("Quinn Budan") + } + } +} diff --git a/yawn-database-test/src/test/kotlin/com/faire/yawn/database/YawnJoinsTest.kt b/yawn-database-test/src/test/kotlin/com/faire/yawn/database/YawnJoinsTest.kt index 7e46ea5..26fb20e 100644 --- a/yawn-database-test/src/test/kotlin/com/faire/yawn/database/YawnJoinsTest.kt +++ b/yawn-database-test/src/test/kotlin/com/faire/yawn/database/YawnJoinsTest.kt @@ -57,6 +57,7 @@ internal class YawnJoinsTest : BaseYawnDatabaseTest() { "J.K. Rowling" to "Lord of the Rings", "Paul Duchesne" to "Lord of the Rings", "Luan Nico" to "Harry Potter", + "Quinn Budan" to null, "Jane Doe" to null, "John Doe" to null, "Hans Christian Andersen" to null, diff --git a/yawn-processor/src/main/kotlin/com/faire/yawn/generators/adapters/ValueAdapterGenerator.kt b/yawn-processor/src/main/kotlin/com/faire/yawn/generators/adapters/ValueAdapterGenerator.kt new file mode 100644 index 0000000..6eca467 --- /dev/null +++ b/yawn-processor/src/main/kotlin/com/faire/yawn/generators/adapters/ValueAdapterGenerator.kt @@ -0,0 +1,10 @@ +package com.faire.yawn.generators.adapters + +import com.faire.yawn.util.YawnContext +import com.faire.yawn.util.YawnParameter +import com.google.devtools.ksp.symbol.KSType + +internal interface ValueAdapterGenerator { + fun qualifies(yawnContext: YawnContext, fieldType: KSType): Boolean + fun generate(yawnContext: YawnContext, fieldType: KSType): YawnParameter +} diff --git a/yawn-processor/src/main/kotlin/com/faire/yawn/generators/adapters/ValueClassAdapterGenerator.kt b/yawn-processor/src/main/kotlin/com/faire/yawn/generators/adapters/ValueClassAdapterGenerator.kt new file mode 100644 index 0000000..320369a --- /dev/null +++ b/yawn-processor/src/main/kotlin/com/faire/yawn/generators/adapters/ValueClassAdapterGenerator.kt @@ -0,0 +1,36 @@ +package com.faire.yawn.generators.adapters + +import com.faire.yawn.util.YawnContext +import com.faire.yawn.util.YawnParameter +import com.faire.yawn.util.YawnProcessorException +import com.faire.yawn.util.isValueClass +import com.google.devtools.ksp.symbol.KSClassDeclaration +import com.google.devtools.ksp.symbol.KSType + +internal class ValueClassAdapterGenerator : ValueAdapterGenerator { + override fun qualifies( + yawnContext: YawnContext, + fieldType: KSType, + ): Boolean { + return fieldType.isValueClass() + } + + override fun generate( + yawnContext: YawnContext, + fieldType: KSType, + ): YawnParameter { + val declaration = fieldType.declaration as? KSClassDeclaration + ?: fail("Expected a class declaration for value class, but found ${fieldType.declaration}") + val primaryConstructor = declaration.primaryConstructor + ?: fail("Value class ${declaration.qualifiedName?.asString()} must have a primary constructor") + val valueClassProperty = primaryConstructor.parameters.singleOrNull() + ?: fail("Value class ${declaration.qualifiedName?.asString()} must have a single property in its primary constructor") + val valueClassPropertyName = valueClassProperty.name?.asString() + ?: fail("Value class property must have a name") + return YawnParameter("adapter = { it?.%N }", listOf(valueClassPropertyName)) + } +} + +private fun fail(message: String): Nothing { + throw YawnProcessorException(message) +} diff --git a/yawn-processor/src/main/kotlin/com/faire/yawn/generators/properties/ColumnDefGenerator.kt b/yawn-processor/src/main/kotlin/com/faire/yawn/generators/properties/ColumnDefGenerator.kt index e2ba691..1fed49e 100644 --- a/yawn-processor/src/main/kotlin/com/faire/yawn/generators/properties/ColumnDefGenerator.kt +++ b/yawn-processor/src/main/kotlin/com/faire/yawn/generators/properties/ColumnDefGenerator.kt @@ -1,6 +1,7 @@ package com.faire.yawn.generators.properties import com.faire.yawn.YawnTableDef +import com.faire.yawn.generators.adapters.ValueClassAdapterGenerator import com.faire.yawn.util.ForeignKeyReference import com.faire.yawn.util.YawnContext import com.faire.yawn.util.YawnParameter @@ -65,8 +66,10 @@ internal object ColumnDefGenerator : YawnPropertyGenerator() { } catch (e: IllegalArgumentException) { throw YawnProcessorException("Failed to get type name for ${yawnContext.superClassName}.$fieldName", e) } - val parameters = pathPrefixes + listOf( + val adapter = generateAdapterForPropertyIfNeeded(yawnContext, fieldType) + val parameters = pathPrefixes + listOfNotNull( YawnParameter.string(fieldName), // "token" + adapter, // optional, e.g. for a value class: adapter = { it?.value } ) return generatePropertySpec( @@ -76,4 +79,24 @@ internal object ColumnDefGenerator : YawnPropertyGenerator() { typeArguments, ) } + + private fun generateAdapterForPropertyIfNeeded( + yawnContext: YawnContext, + fieldType: KSType, + ): YawnParameter? { + val matchingAdapters = VALUE_ADAPTER_GENERATORS.filter { it.qualifies(yawnContext, fieldType) } + return when (matchingAdapters.size) { + 0 -> null + 1 -> matchingAdapters.single().generate(yawnContext, fieldType) + else -> { + val fieldName = fieldType.declaration.qualifiedName?.asString() + val adapterNames = matchingAdapters.joinToString { "${it::class.qualifiedName}" } + throw YawnProcessorException("Multiple adapters qualified for type $fieldName: $adapterNames") + } + } + } + + private val VALUE_ADAPTER_GENERATORS = listOf( + ValueClassAdapterGenerator(), + ) } diff --git a/yawn-processor/src/main/kotlin/com/faire/yawn/util/KspSymbolExtensions.kt b/yawn-processor/src/main/kotlin/com/faire/yawn/util/KspSymbolExtensions.kt index d19bb8f..4e644f2 100644 --- a/yawn-processor/src/main/kotlin/com/faire/yawn/util/KspSymbolExtensions.kt +++ b/yawn-processor/src/main/kotlin/com/faire/yawn/util/KspSymbolExtensions.kt @@ -9,6 +9,7 @@ import com.google.devtools.ksp.symbol.KSDeclaration import com.google.devtools.ksp.symbol.KSPropertyDeclaration import com.google.devtools.ksp.symbol.KSType import com.google.devtools.ksp.symbol.KSTypeReference +import com.google.devtools.ksp.symbol.Modifier import com.squareup.kotlinpoet.ClassName import com.squareup.kotlinpoet.TypeName import com.squareup.kotlinpoet.ksp.toClassName @@ -154,3 +155,7 @@ internal fun KSPropertyDeclaration.getHibernateForeignKeyReference(): ForeignKey internal fun KSDeclaration.isYawnEntity(): Boolean { return isAnnotationPresent() } + +internal fun KSType.isValueClass(): Boolean { + return declaration is KSClassDeclaration && Modifier.VALUE in declaration.modifiers +}