Skip to content

Commit

Permalink
Merge branch 'main' into release
Browse files Browse the repository at this point in the history
v0.2.0 release
  • Loading branch information
ShreckYe committed Oct 19, 2024
2 parents 1c1c842 + 7c8198b commit 9ce36d2
Show file tree
Hide file tree
Showing 14 changed files with 338 additions and 26 deletions.
122 changes: 120 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,123 @@
# Exposed GADT mapping

A library based on [Exposed](https://github.com/JetBrains/Exposed) [DSL](https://github.com/JetBrains/Exposed/wiki/DSL) providing mappings between data entities and tables with support for generic algebraic data types (nested classes, type parameters, and sealed classes)
[![Maven Central](https://img.shields.io/maven-central/v/com.huanshankeji/exposed-adt-mapping)](https://search.maven.org/artifact/com.huanshankeji/exposed-adt-mapping)

This project is an attemp to provide an alternative to [Exposed DAO](https://github.com/JetBrains/Exposed/wiki/DAO) while supporting some more advanced functional programming features. Also see JetBrains/Exposed#24 for more details.
A library based on [Exposed](https://github.com/JetBrains/Exposed) [DSL](https://github.com/JetBrains/Exposed/wiki/DSL) providing mappings between data entities and tables with support for GADT (generalized algebraic data type), aka features including nested properties of composite class types, type parameters and their type inference, and sealed classes

This project is an attempt to provide an alternative to [Exposed DAO](https://github.com/JetBrains/Exposed/wiki/DAO) while supporting some more advanced functional programming features. See JetBrains/Exposed#24 for more details.

## Basic usage guide

Please note that these APIs are far from stable. There are going to be refactors in future releases.

### Table and data definitions

#### Tables and joins

```kotlin
typealias DirectorId = Int

class Director(val directorId: DirectorId, val name: String)

class FilmDetails<DirectorT>(
val sequelId: Int,
val name: String,
val director: DirectorT
)
typealias FilmDetailsWithDirectorId = FilmDetails<DirectorId>

typealias FilmId = Int

class Film<DirectorT>(val filmId: FilmId, val filmDetails: FilmDetails<DirectorT>)
typealias FilmWithDirectorId = Film<DirectorId>
typealias FullFilm = Film<Director>
```

#### Data entities and attributes

```kotlin
typealias DirectorId = Int

class Director(val directorId: DirectorId, val name: String)

class FilmDetails<DirectorT>(
val sequelId: Int,
val name: String,
val director: DirectorT
)
typealias FilmDetailsWithDirectorId = FilmDetails<DirectorId>

typealias FilmId = Int

class Film<DirectorT>(val filmId: FilmId, val filmDetails: FilmDetails<DirectorT>)
typealias FilmWithDirectorId = Film<DirectorId>
typealias FullFilm = Film<Director>
```

A nested composite class property can either map to flattened fields or a table referenced by a foreign key: `FilmDetails` is a nested class in `Film`, but the corresponding table `Films` has the `FilmDetails` members/fields flattened directly instead of referencing a corresponding table for `FilmDetails` with a foreign key; on the contrary, a `director : Director` member of `FilmDetails<Director>` maps to the `Directors` table referenced.

As laid out above in the code, a recommended approach to define data types is to make necessary use of type parameters to improve code reuse.

### Create mappers

You can create mappers with the overloaded `reflectionBasedClassPropertyDataMapper` functions. Pass the `propertyColumnMappingConfigMapOverride` parameter to override the default options.

```kotlin
object Mappers {
val director = reflectionBasedClassPropertyDataMapper<Director>(Directors)
val filmDetailsWithDirectorId = reflectionBasedClassPropertyDataMapper<FilmDetailsWithDirectorId>(
Films,
propertyColumnMappingConfigMapOverride = mapOf(
// The default name is the property name "director", but there is no column property with such a name, therefore we need to pass a custom name.
FilmDetailsWithDirectorId::director to PropertyColumnMappingConfig.create<DirectorId>(columnPropertyName = Films::directorId.name)
)
)
val filmWithDirectorId = reflectionBasedClassPropertyDataMapper<FilmWithDirectorId>(
Films,
propertyColumnMappingConfigMapOverride = mapOf(
FilmWithDirectorId::filmDetails to PropertyColumnMappingConfig.create<FilmDetailsWithDirectorId>(
// You can pass a nested custom mapper.
customMapper = filmDetailsWithDirectorId
)
)
)
val fullFilm = reflectionBasedClassPropertyDataMapper<FullFilm>(
filmsLeftJoinDirectors,
propertyColumnMappingConfigMapOverride = mapOf(
FullFilm::filmDetails to PropertyColumnMappingConfig.create(
adt = PropertyColumnMappingConfig.Adt.Product(
mapOf(
// Because `name` is a duplicate name column so a custom mapper has to be passed here, otherwise the `CHOOSE_FIRST` option maps the data property `Director::name` to the wrong column `Films::name`.
FilmDetails<Director>::director to PropertyColumnMappingConfig.create<Director>(customMapper = director)
)
)
)
)
)
}
```

### CRUD operations

Call `updateBuilderSetter` to get a setter lambda to pass to `insert` or `update`. Call `selectWithMapper` to execute a query with a mapper (not available yet, available soon in 0.2.0).

```kotlin
val directorId = 1
val director = Director(directorId, "George Lucas")
Directors.insert(Mappers.director.updateBuilderSetter(director))

val episodeIFilmDetails = FilmDetails(1, "Star Wars: Episode I – The Phantom Menace", directorId)
Films.insert(Mappers.filmDetailsWithDirectorId.updateBuilderSetter(episodeIFilmDetails)) // insert without the ID since it's `AUTO_INCREMENT`

val filmId = 2
val episodeIIFilmDetails = FilmDetails(2, "Star Wars: Episode II – Attack of the Clones", directorId)
val filmWithDirectorId = FilmWithDirectorId(filmId, episodeIIFilmDetails)
Films.insert(Mappers.filmWithDirectorId.updateBuilderSetter(filmWithDirectorId)) // insert with the ID

val fullFilm = with(Mappers.fullFilm) {
resultRowToData(filmsLeftJoinDirectors.select(neededColumns).where(Films.filmId eq filmId).single())
}
// not available yet, available soon in 0.2.0
val fullFilms =
filmsLeftJoinDirectors.selectWithMapper(Mappers.fullFilm, Films.filmId inList listOf(1, 2)).toList()
```
7 changes: 4 additions & 3 deletions buildSrc/build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,8 @@ repositories {
}

dependencies {
implementation(kotlin("gradle-plugin", "1.9.23"))
implementation("com.huanshankeji:common-gradle-dependencies:0.7.1-20240314")
implementation("com.huanshankeji.team:gradle-plugins:0.5.1")
// With Kotlin 2.0.20, a "Could not parse POM" build error occurs in the JVM projects of some dependent projects.
implementation(kotlin("gradle-plugin", "2.0.10"))
implementation("com.huanshankeji:common-gradle-dependencies:0.8.0-20241016") // don't use a snapshot version in a main branch
implementation("com.huanshankeji.team:gradle-plugins:0.6.0") // don't use a snapshot version in a main branch
}
4 changes: 2 additions & 2 deletions buildSrc/src/main/kotlin/VersionsAndDependencies.kt
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import com.huanshankeji.CommonDependencies
import com.huanshankeji.CommonVersions

val projectVersion = "0.1.0"
val projectVersion = "0.2.0"

val commonVersions = CommonVersions()
val commonVersions = CommonVersions(kotlinCommon = "0.5.1")
val commonDependencies = CommonDependencies(commonVersions)
Binary file modified gradle/wrapper/gradle-wrapper.jar
Binary file not shown.
2 changes: 1 addition & 1 deletion gradle/wrapper/gradle-wrapper.properties
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
distributionBase=GRADLE_USER_HOME
distributionPath=wrapper/dists
distributionUrl=https\://services.gradle.org/distributions/gradle-8.6-all.zip
distributionUrl=https\://services.gradle.org/distributions/gradle-8.10.2-all.zip
networkTimeout=10000
validateDistributionUrl=true
zipStoreBase=GRADLE_USER_HOME
Expand Down
7 changes: 5 additions & 2 deletions gradlew
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,8 @@
# See the License for the specific language governing permissions and
# limitations under the License.
#
# SPDX-License-Identifier: Apache-2.0
#

##############################################################################
#
Expand Down Expand Up @@ -55,7 +57,7 @@
# Darwin, MinGW, and NonStop.
#
# (3) This script is generated from the Groovy template
# https://github.com/gradle/gradle/blob/HEAD/subprojects/plugins/src/main/resources/org/gradle/api/internal/plugins/unixStartScript.txt
# https://github.com/gradle/gradle/blob/HEAD/platforms/jvm/plugins-application/src/main/resources/org/gradle/api/internal/plugins/unixStartScript.txt
# within the Gradle project.
#
# You can find Gradle at https://github.com/gradle/gradle/.
Expand Down Expand Up @@ -84,7 +86,8 @@ done
# shellcheck disable=SC2034
APP_BASE_NAME=${0##*/}
# Discard cd standard output in case $CDPATH is set (https://github.com/gradle/gradle/issues/25036)
APP_HOME=$( cd "${APP_HOME:-./}" > /dev/null && pwd -P ) || exit
APP_HOME=$( cd -P "${APP_HOME:-./}" > /dev/null && printf '%s
' "$PWD" ) || exit

# Use the maximum available, or set MAX_FD != -1 to use that value.
MAX_FD=maximum
Expand Down
2 changes: 2 additions & 0 deletions gradlew.bat
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,8 @@
@rem See the License for the specific language governing permissions and
@rem limitations under the License.
@rem
@rem SPDX-License-Identifier: Apache-2.0
@rem

@if "%DEBUG%"=="" @echo off
@rem ##########################################################################
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -26,7 +26,7 @@ interface SimpleDataMapper<Data : Any> : SimpleDataQueryMapper<Data>, DataUpdate


interface NullableDataQueryMapper<Data> : SimpleNullableDataQueryMapper<Data> {
val neededColumns: List<Column<*>>
val neededColumns: List<Column<*>> // TODO consider refactoring to `ExpressionWithColumnType`
}

interface DataQueryMapper<Data : Any> : NullableDataQueryMapper<Data>
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
package com.huanshankeji.exposed.datamapping

import org.jetbrains.exposed.sql.ColumnSet
import org.jetbrains.exposed.sql.Op

fun <Data : Any> ColumnSet.selectWithMapper(mapper: NullableDataQueryMapper<Data>, where: Op<Boolean>? = null) =
select(mapper.neededColumns)
.run { where?.let { where(it) } ?: this }
.asSequence().map { mapper.resultRowToData(it) }
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,7 @@ sealed class PropertyColumnMapping<Data : Any, PropertyData>(val fctProperty: Fu
class SqlPrimitive<Data : Any, PropertyData>(
fctProperty: FullConcreteTypeProperty1<Data, PropertyData>,
val column: Column<PropertyData>
//val isEntityId : Boolean // TODO implement or remove, or add another separate sealed subclass
) : PropertyColumnMapping<Data, PropertyData>(fctProperty)

class NestedClass<Data : Any, PropertyData>(
Expand Down Expand Up @@ -114,7 +115,8 @@ fun KClass<*>.isExposedSqlPrimitiveType(): Boolean =
fun KType.isExposedSqlPrimitiveType() =
(classifier as KClass<*>).isExposedSqlPrimitiveType()

class ColumnWithPropertyName(val propertyName: String, val column: Column<*>)
// made a data class so it can be printed while debugging
data class ColumnWithPropertyName(val propertyName: String, val column: Column<*>)

fun getColumnsWithPropertyNamesWithoutTypeParameter(
table: Table, clazz: KClass<out Table> = table::class
Expand All @@ -125,7 +127,11 @@ fun getColumnsWithPropertyNamesWithoutTypeParameter(
}

enum class OnDuplicateColumnPropertyNames {
CHOOSE_FIRST, THROW
/**
* Use left joins only for this to work properly. Don't use right joins.
*/
CHOOSE_FIRST,
THROW
}

fun getColumnByPropertyNameMap(
Expand Down Expand Up @@ -189,7 +195,9 @@ class PropertyColumnMappingConfig<P>(
// Non-nullable properties can be skipped when updating but not when querying.
if (usedForQuery)
require(!skip)
require(whetherNullDependentColumn === null)
require(whetherNullDependentColumn === null) {
"`whetherNullDependentColumn` should be null for a not-null type $type"
}
}


Expand Down Expand Up @@ -224,6 +232,7 @@ class PropertyColumnMappingConfig<P>(

// ADT: algebraic data type
sealed class Adt<Data : Any> {
// TODO use a custom type instead of `Pair` for the map entries used to construct the map so more type-safety can be ensured
class Product<Data : Any>(val nestedConfigMap: PropertyColumnMappingConfigMap<Data>) :
Adt<Data>()

Expand Down Expand Up @@ -278,7 +287,7 @@ private fun <Data : Any> doGetDefaultClassPropertyColumnMappings(
customMappings: PropertyColumnMappings<Data> = emptyList()
/* TODO Constructing `FullConcreteTypeProperty1` seems complicated after the code is refactored.
Consider refactoring `PropertyColumnMapping` with one extra `Property` type parameter and apply simple `KProperty` for `customMappings`,
or merging it into config. */
or merging it into `propertyColumnMappingConfigMapOverride` for better usability. */
): ClassPropertyColumnMappings<Data> {
val customMappingPropertySet = customMappings.asSequence().map { it.fctProperty }.toSet()

Expand Down Expand Up @@ -347,7 +356,7 @@ private fun <Data : Any> doGetDefaultClassPropertyColumnMappings(
1. find the first non-nullable column with the suffix "id".
They all have their drawbacks.
The first approach is too unpredictable, adding or removing properties can affect which column to choose.
Both the first approach and the third approach are too unpredictable, adding or removing properties can affect which column to choose.
Both the second and the third approach can't deal with the case where the column is not within the mapped columns,
which happens when selecting a small portion of the fields as data.
*/
Expand Down Expand Up @@ -427,7 +436,8 @@ private fun <Data : Any> doGetDefaultClassPropertyColumnMappings(

fun <Data : Any> getDefaultClassPropertyColumnMappings(
fullConcreteTypeClass: FullConcreteTypeClass<Data>,
tables: List<Table>, onDuplicateColumnPropertyNames: OnDuplicateColumnPropertyNames = CHOOSE_FIRST, // TODO consider removing this default argument as there is one for joins now
tables: List<Table>,
onDuplicateColumnPropertyNames: OnDuplicateColumnPropertyNames = CHOOSE_FIRST, // TODO consider removing this default argument as there is one for joins now
propertyColumnMappingConfigMapOverride: PropertyColumnMappingConfigMap<Data> = emptyMap(),
customMappings: PropertyColumnMappings<Data> = emptyList()
): ClassPropertyColumnMappings<Data> =
Expand All @@ -439,6 +449,7 @@ fun <Data : Any> getDefaultClassPropertyColumnMappings(
)

// TODO: decouple query mapper and update mapper.
// TODO add the `ColumnSet` as a type parameter since all kinds of `ColumnSet`s will be supported in `reflectionBasedClassPropertyDataMapper`
/** Supports classes with nested composite class properties and multiple tables */
class ReflectionBasedClassPropertyDataMapper<Data : Any>(
val fullConcreteTypeClass: FullConcreteTypeClass<Data>,
Expand All @@ -459,7 +470,7 @@ private fun <Data : Any> constructDataWithResultRow(
classPropertyColumnMappings: ClassPropertyColumnMappings<Data>,
resultRow: ResultRow
): Data =
fctClass.kClass.primaryConstructor!!.call(*classPropertyColumnMappings.map {
fctClass.kClass.primaryConstructor!!.callWithCatch(*classPropertyColumnMappings.map {
fun <PropertyReturnT> typeParameterHelper(
propertyColumnMapping: PropertyColumnMapping<Data, PropertyReturnT>,
nestedFctClass: FullConcreteTypeClass<PropertyReturnT & Any>
Expand Down Expand Up @@ -511,8 +522,10 @@ fun <Data : Any> setUpdateBuilder(
fun <PropertyData> typeParameterHelper(propertyColumnMapping: PropertyColumnMapping<Data, PropertyData>) {
val propertyData = propertyColumnMapping.fctProperty.kProperty(data)
when (propertyColumnMapping) {
is SqlPrimitive ->
updateBuilder[propertyColumnMapping.column] = propertyData
is SqlPrimitive -> {
// TODO also consider judging whether it's an entity ID when constructing the `PropertyColumnMapping`
updateBuilder.setWithColumnPossiblyBeingEntityId(propertyColumnMapping.column, propertyData)
}

is NestedClass -> {
// `propertyColumnMapping.nullability` is not needed here
Expand Down Expand Up @@ -619,6 +632,9 @@ fun ClassPropertyColumnMappings<*>.getColumnSet(): Set<Column<*>> =

// TODO add a version of `reflectionBasedClassPropertyDataMapper` that takes column properties and make the following 2 functions depend on it

/**
* @param tables be sure that the tables are passed in the right order.
*/
inline fun <reified Data : Any> reflectionBasedClassPropertyDataMapper(
tables: List<Table>,
onDuplicateColumnPropertyNames: OnDuplicateColumnPropertyNames = CHOOSE_FIRST, // TODO consider removing this default argument as there is one for joins now
Expand All @@ -645,8 +661,32 @@ inline fun <reified Data : Any/*, TableT : Table*/> reflectionBasedClassProperty
* A shortcut for [Join]s.
*/
inline fun <reified Data : Any> reflectionBasedClassPropertyDataMapper(
join : Join,
join: Join,
propertyColumnMappingConfigMapOverride: PropertyColumnMappingConfigMap<Data> = emptyMap(),
customMappings: PropertyColumnMappings<Data> = emptyList()
) =
reflectionBasedClassPropertyDataMapper(join.targetTables(), CHOOSE_FIRST, propertyColumnMappingConfigMapOverride, customMappings)
reflectionBasedClassPropertyDataMapper(
join.targetTables(), CHOOSE_FIRST, propertyColumnMappingConfigMapOverride, customMappings
)

// not completely implemented yet
private inline fun <reified Data : Any> reflectionBasedClassPropertyDataMapper(
queryAlias: QueryAlias,
propertyColumnMappingConfigMapOverride: PropertyColumnMappingConfigMap<Data> = emptyMap(),
customMappings: PropertyColumnMappings<Data> = emptyList()
): ReflectionBasedClassPropertyDataMapper<Data> =
reflectionBasedClassPropertyDataMapper(
queryAlias.query.targets, CHOOSE_FIRST, propertyColumnMappingConfigMapOverride, customMappings
).run {
TODO("map the columns to alias columns")
}

/**
* @see targetTables
*/
private inline fun <reified Data : Any> reflectionBasedClassPropertyDataMapper(
columnSet: ColumnSet,
propertyColumnMappingConfigMapOverride: PropertyColumnMappingConfigMap<Data> = emptyMap(),
customMappings: PropertyColumnMappings<Data> = emptyList()
): ReflectionBasedClassPropertyDataMapper<Data> =
TODO()
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
package com.huanshankeji.exposed.datamapping.classproperty

import kotlin.reflect.KFunction

class ReflectionFunctionInvocationException(constructor: KFunction<*>, vararg args: Any?, cause: Throwable) :
Exception("calling the function $constructor with params ${args.toList()}", cause)

// also consider catching only in debug/test mode
fun <R> KFunction<R>.callWithCatch(vararg args: Any?) =
try {
call(args = args)
} catch (e: Exception) {
throw ReflectionFunctionInvocationException(this, args = args, cause = e)
}
Loading

0 comments on commit 9ce36d2

Please sign in to comment.