All Downloads are FREE. Search and download functionalities are using the official Maven repository.

dk.cloudcreate.essentials.components.document_db.postgresql.PostgresqlDocumentDbRepository.kt Maven / Gradle / Ivy

Go to download

This kotlin library focuses purely on providing document db like support for Postgresql

The newest version!
/*
 * Copyright 2021-2024 the original author or authors.
 *
 * 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 dk.cloudcreate.essentials.components.document_db.postgresql

import dk.cloudcreate.essentials.components.document_db.*
import dk.cloudcreate.essentials.components.document_db.annotations.DocumentEntity
import dk.cloudcreate.essentials.components.foundation.json.JSONSerializer
import dk.cloudcreate.essentials.components.foundation.postgresql.PostgresqlUtil
import dk.cloudcreate.essentials.components.foundation.transaction.jdbi.HandleAwareUnitOfWork
import dk.cloudcreate.essentials.components.foundation.transaction.jdbi.HandleAwareUnitOfWorkFactory
import dk.cloudcreate.essentials.components.foundation.types.RandomIdGenerator
import dk.cloudcreate.essentials.kotlin.types.StringValueType
import dk.cloudcreate.essentials.kotlin.types.jdbi.LongValueTypeArgumentFactory
import dk.cloudcreate.essentials.kotlin.types.jdbi.LongValueTypeColumnMapper
import org.slf4j.LoggerFactory
import java.time.OffsetDateTime
import java.time.ZoneOffset.UTC
import kotlin.reflect.KClass
import kotlin.reflect.KMutableProperty1
import kotlin.reflect.full.primaryConstructor

/**
 * Postgresql specific variant of the [DocumentDbRepository] interface
 * and the default [DocumentDbRepository] returned by the [DocumentDbRepositoryFactory.create]
 *
 * ```
 * @DocumentEntity("orders")
 * data class Order(
 *     @Id
 *     val orderId: OrderId,
 *     var description: String,
 *     var amount: Amount,
 *     var additionalProperty: Int,
 *     var orderDate: LocalDateTime,
 *     @Indexed
 *     var personName: String,
 *     var invoiceAddress: Address,
 *     var contactDetails: ContactDetails,
 *     override var version: Version = Version.NOT_SAVED_YET,
 *     override var lastUpdated: OffsetDateTime = OffsetDateTime.now(UTC)
 * ) : VersionedEntity
 *
 * val repositoryFactory: DocumentDbRepositoryFactory = getDocumentDbRepositoryFactory()
 * val orderRepository: DocumentDbRepository = repositoryFactory.create(Order::class)
 * ```
 *
 * ## Security
 * To support customization of which PostgreSQL table each entity type is stored you can provide your own `tableName` through the [DocumentEntity.tableName] annotation.
 *
 * The [DocumentEntity.tableName]` and `all the names of the properties in your entity classes` will be directly used in constructing SQL statements through string concatenation.
 * This can potentially expose components, such as [PostgresqlDocumentDbRepository], to SQL injection attacks.
 *
 * **It is the responsibility of the user of this component to sanitize both the [DocumentEntity.tableName] and `all Entity property names` to ensure the security of all the SQL statements generated
 * by this component.**
 *
 * The [PostgresqlDocumentDbRepository] instance, e.g. created by [DocumentDbRepositoryFactory.create],
 * will through [EntityConfiguration.configureEntity] call the [dk.cloudcreate.essentials.components.foundation.postgresql.PostgresqlUtil.checkIsValidTableOrColumnName] method to validate the table name
 * and Entity property names as a first line of defense.
 *
 * The  [dk.cloudcreate.essentials.components.foundation.postgresql.PostgresqlUtil.checkIsValidTableOrColumnName] provides an initial layer of defense against SQL injection by applying naming conventions intended to
 * reduce the risk of malicious input.
 * **However, Essentials components as well as  [dk.cloudcreate.essentials.components.foundation.postgresql.PostgresqlUtil.checkIsValidTableOrColumnName] does not offer exhaustive protection,
 * nor does it assure the complete security of the resulting SQL against SQL injection threats.**
 * > The responsibility for implementing protective measures against SQL Injection lies exclusively with the users/developers using the Essentials components and its supporting classes.
 * > Users must ensure thorough sanitization and validation of API input parameters,  column, table, and index names.
 *
 * **Insufficient attention to these practices may leave the application vulnerable to SQL injection, potentially endangering the security and integrity of the database.**
 *
 * It is highly recommended that the [DocumentEntity.tableName] and `all the Entity property names` are only derived from controlled and trusted sources.
 *
 * To mitigate the risk of SQL injection attacks, external or untrusted inputs should never directly provide the `tableName` or entity property names.
 *
 * **See also [VersionedEntity]'s and [DocumentDbRepository]'s security warning.***
 *
 * @see VersionedEntity
 * @see DocumentDbRepository
 * @see DocumentDbRepositoryFactory
 */
class PostgresqlDocumentDbRepository, ID>(
    private val unitOfWorkFactory: HandleAwareUnitOfWorkFactory,
    entityClass: KClass,
    private val jsonSerializer: JSONSerializer,
    private val idSerializer: IdSerializer
) : DocumentDbRepository {
    private var entityConfiguration: EntityConfiguration = EntityConfiguration.configureEntity(entityClass).build()
    private val indexesAdded = mutableSetOf>()

    init {
        PostgresqlUtil.checkIsValidTableOrColumnName(entityConfiguration.tableName())
        ensureEntityTableExists()
        ensureEntityIndexesExists()
    }

    private fun ensureEntityTableExists() {
        PostgresqlUtil.checkIsValidTableOrColumnName(entityConfiguration.tableName())
        unitOfWorkFactory.usingUnitOfWork { uow ->
            uow.handle().execute(
                """
            CREATE TABLE IF NOT EXISTS ${entityConfiguration.tableName()} (
                id text PRIMARY KEY,
                data JSONB NOT NULL,
                version BIGINT,
                last_updated TIMESTAMPTZ NOT NULL DEFAULT now()
            );
        """.trimIndent()
            )
            log.info(
                "Ensured that Entity '{}' table '{}' exists",
                entityConfiguration.entityClass(),
                entityConfiguration.tableName()
            )
        }
    }

    private fun ensureEntityIndexesExists() {
        PostgresqlUtil.checkIsValidTableOrColumnName(entityConfiguration.tableName())
        val indexedFields = entityConfiguration.indexedFields()

        unitOfWorkFactory.usingUnitOfWork { uow ->
            indexedFields.forEach { field ->
                val fieldName = field.name
                PostgresqlUtil.checkIsValidTableOrColumnName(fieldName)

                val indexName = "idx_${entityConfiguration.tableName()}_${fieldName}".lowercase()
                PostgresqlUtil.checkIsValidTableOrColumnName(indexName)
                uow.handle().execute("CREATE INDEX IF NOT EXISTS $indexName ON ${entityConfiguration.tableName()} ((data ->> '$fieldName'))")
                log.info(
                    "Ensured that Entity '{}' table '{}' index '{}' exists",
                    entityConfiguration.entityClass(),
                    entityConfiguration.tableName(),
                    indexName
                )
            }
        }
    }

    override fun addIndex(index: Index): DocumentDbRepository {
        if (indexesAdded.contains(index)) {
            return this
        }
        PostgresqlUtil.checkIsValidTableOrColumnName(index.name)
        require(index.properties.isNotEmpty()) { "You have to specify at least 1 property" }
        index.properties.forEach { PostgresqlUtil.checkIsValidTableOrColumnName(it.name()) }

        indexesAdded.add(index)
        val properties = index.properties.joinToString(", ") { "(" + it.toJSONValueArrowPath() + ")" }
        val tableName = entityConfiguration().tableName()
        val createIndexSQL = """
            CREATE INDEX IF NOT EXISTS idx_${entityConfiguration.tableName()}_${index.name} ON $tableName (${properties})
        """.trimIndent()

        unitOfWorkFactory.usingUnitOfWork { uow ->
            uow.handle().execute(createIndexSQL)
            log.info(
                "Ensured index '{}' on Entity '{}' table '{}' exists",
                index.name,
                entityConfiguration.entityClass(),
                entityConfiguration.tableName()
            )
        }
        return this
    }

    override fun removeIndex(indexName: String): DocumentDbRepository {
        PostgresqlUtil.checkIsValidTableOrColumnName(indexName)
        if (indexesAdded.removeIf { it.name == indexName }) {
            unitOfWorkFactory.usingUnitOfWork { uow ->
                uow.handle().execute("DROP INDEX IF EXISTS idx_${entityConfiguration.tableName()}_$indexName")
                log.info(
                    "Ensured index '{}' on Entity '{}' table '{}' was removed",
                    indexName,
                    entityConfiguration.entityClass(),
                    entityConfiguration.tableName()
                )
            }
        }
        return this
    }

    override fun entityConfiguration(): EntityConfiguration {
        return entityConfiguration
    }

    override fun queryBuilder(): QueryBuilder {
        return QueryBuilder(entityConfiguration, this)
    }

    override fun condition(): Condition {
        return Condition(jsonSerializer)
    }

    override fun save(entity: ENTITY, initialVersion: Version): ENTITY {
        var serializedId = getEntityId(entity)
        log.trace(
            "Save '{}' with serialized-id: '{}' and initialVersion: '{}' called",
            entityConfiguration.entityClass().simpleName,
            serializedId,
            initialVersion
        )

        if (serializedId == null) {
            if (entityConfiguration.idProperty() is KMutableProperty1) {
                if (entityConfiguration.idProperty().returnType.classifier == String::class ||
                    entityConfiguration.idProperty().returnType.classifier == StringValueType::class
                ) {
                    // Assign a random id
                    val typedId = createStringOrStringValueTypeId()
                    serializedId = idSerializer(typedId)
                    (entityConfiguration.idProperty() as KMutableProperty1).setter(entity, typedId)
                    log.debug(
                        "Entity: '{}' -  Assigned random-id with id '{}'",
                        entityConfiguration.entityClass().simpleName,
                        typedId
                    )
                } else {
                    throw IllegalArgumentException("${entityConfiguration.entityClass().simpleName}.${entityConfiguration.idProperty().name} is null, but cannot assign a random id value because the property is not of type String or StringValueType.")
                }
            } else {
                throw IllegalArgumentException("Cannot assign a random id value. ${entityConfiguration.entityClass().simpleName}.${entityConfiguration.idProperty().name} is null but also read-only.")
            }
        }

        updateVersionedEntityProperties(entity, initialVersion)
        val json = jsonSerializer.serialize(entity)

        val rowsUpdated = unitOfWorkFactory.withUnitOfWork { uow ->
            uow.handle()
                .createUpdate("INSERT INTO ${entityConfiguration.tableName()} (id, data, version, last_updated) VALUES (:id, :data::jsonb, :version, :lastUpdated) ON CONFLICT DO NOTHING")
                .bind("id", serializedId)
                .bind("data", json)
                .bind("version", initialVersion)
                .bind("lastUpdated", entity.lastUpdated)
                .execute()
        }
        if (rowsUpdated == 0) {
            throw OptimisticLockingException("Failed to save entity of type '${entityConfiguration.entityClass().simpleName}' since was already saved. If you're trying to update an existing entity please use the update method.")
        }
        log.debug(
            "Saved '{}' with serialized-id: '{}' and initialVersion: '{}'",
            entityConfiguration.entityClass().simpleName,
            serializedId,
            initialVersion
        )

        return entity
    }

    private fun createStringOrStringValueTypeId() = entityConfiguration.idPropertyType().primaryConstructor!!.call(RandomIdGenerator.generate()) as ID

    override fun update(entity: ENTITY): ENTITY {
        return update(entity, getRequiredEntityVersion(entity).increment())
    }

    override fun update(entity: ENTITY, nextVersion: Version): ENTITY {
        val serializedId = getRequiredEntityId(entity)
        val loadedVersion = getRequiredEntityVersion(entity)
        log.debug(
            "Update '{}' with serialized-id: '{}', loaded-version: '{}' and next-version: '{}'",
            entityConfiguration.entityClass().simpleName,
            serializedId,
            loadedVersion,
            nextVersion
        )

        updateVersionedEntityProperties(entity, nextVersion)
        val json = jsonSerializer.serialize(entity)


        val sql =
            "UPDATE ${entityConfiguration.tableName()} SET data = :data::jsonb, version = :nextVersion, last_updated = :lastUpdated WHERE id = :id AND version = :loadedVersion"

        val rowsUpdated = unitOfWorkFactory.withUnitOfWork { uow ->
            uow.handle().createUpdate(sql)
                .bind("data", json)
                .bind("nextVersion", nextVersion)
                .bind("loadedVersion", loadedVersion)
                .bind("lastUpdated", entity.lastUpdated)
                .bind("id", serializedId)
                .execute()
        }

        if (rowsUpdated == 0) {
            throw OptimisticLockingException("Failed to update entity of type ${entityConfiguration.entityClass().simpleName} due to version mismatch.")
        }
        log.debug(
            "Updated '{}' with serialized-id: '{}' and version: '{}'",
            entityConfiguration.entityClass().simpleName,
            serializedId,
            nextVersion
        )
        return entity
    }

    override fun deleteById(id: ID) {
        val serializedId = idSerializer(id)
        unitOfWorkFactory.usingUnitOfWork { uow ->
            var rowsUpdated = uow.handle().execute("DELETE FROM ${entityConfiguration.tableName()} WHERE id = ?", serializedId)
            if (rowsUpdated == 1) {
                log.debug(
                    "Deleted Entity '{}' with serialized-id '{}'",
                    entityConfiguration.entityClass().simpleName,
                    serializedId
                )
            } else {
                log.debug(
                    "Entity '{}' with serialized-id '{}' was already deleted",
                    entityConfiguration.entityClass().simpleName,
                    serializedId
                )
            }
        }
    }

    override fun find(queryBuilder: QueryBuilder): List {
        return unitOfWorkFactory.withUnitOfWork> { uow ->
            val query = queryBuilder.build()
            log.trace(
                "Query '{}' using SQL: '{}'",
                entityConfiguration.entityClass().simpleName,
                query.sql
            )
            val result = uow.handle().createQuery(query.sql)
            query.bindings.forEach { (key, value) ->
                result.bind(key, value)
            }
            result
                .mapTo(String::class.java)
                .map { json -> jsonSerializer.deserialize(json, entityConfiguration.entityClass().java) as ENTITY }
                .list()
        }
    }

    override fun findById(id: ID): ENTITY? {
        val serializedId = idSerializer(id)
        val matchingEntity = unitOfWorkFactory.withUnitOfWork { uow ->
            uow.handle().createQuery("SELECT data FROM ${entityConfiguration.tableName()} WHERE id = :id")
                .bind("id", serializedId)
                .mapTo(String::class.java)
                .findOne()
                .map { json -> jsonSerializer.deserialize(json, entityConfiguration.entityClass().java) }
                .orElse(null)
        }
        if (matchingEntity != null) {
            log.debug(
                "Found Entity '{}' with serialized-id: '{}'",
                entityConfiguration.entityClass().simpleName,
                serializedId
            )
        } else {
            log.debug(
                "Did NOT find Entity '{}' with serialized-id: '{}'",
                entityConfiguration.entityClass().simpleName,
                serializedId
            )
        }
        return matchingEntity
    }

    override fun delete(entity: ENTITY) {
        deleteById(getRequiredTypedEntityId(entity))
    }

    override fun existsById(id: ID): Boolean {
        val serializedId = idSerializer(id)
        val exists = unitOfWorkFactory.withUnitOfWork { uow ->
            uow.handle().createQuery("SELECT COUNT(*) FROM ${entityConfiguration.tableName()} WHERE id = :id")
                .bind("id", serializedId)
                .mapTo(Int::class.java)
                .one() > 0
        }
        if (exists) {
            log.debug(
                "Exists -> Entity '{}' Entity with serialized-id: '{}'",
                entityConfiguration.entityClass().simpleName,
                serializedId
            )
        } else {
            log.debug(
                "Does NOT Exist -> Entity '{}' with serialized-id: '{}'",
                entityConfiguration.entityClass().simpleName,
                serializedId
            )
        }
        return exists
    }

    override fun saveAll(entities: Iterable): List {
        log.debug(
            "saveAll: {} '{}' entities",
            entities.count(),
            entityConfiguration.entityClass().simpleName
        )

        unitOfWorkFactory.usingUnitOfWork { uow ->
            entities.forEach(this::save)
        }
        return entities.toList()
    }

    override fun updateAll(entities: Iterable): List {
        log.debug(
            "updateAll: {} '{}' entities",
            entities.count(),
            entityConfiguration.entityClass().simpleName
        )

        unitOfWorkFactory.usingUnitOfWork { uow ->
            entities.forEach(this::update)
        }
        return entities.toList()
    }

    override fun deleteAll(entities: Iterable) {
        log.debug(
            "deleteAll: {} '{}' entities",
            entities.count(),
            entityConfiguration.entityClass().simpleName
        )

        unitOfWorkFactory.usingUnitOfWork { uow ->
            entities.forEach(this::delete)
        }
    }

    override fun findAll(): List {
        log.debug(
            "findAll: '{}' entities",
            entityConfiguration.entityClass().simpleName
        )

        val matches = executeEntityListQuery("SELECT data FROM ${entityConfiguration.tableName()}")
        log.debug(
            "findAll: Found {} '{}' entities",
            matches.size,
            entityConfiguration.entityClass().simpleName
        )
        return matches
    }

    override fun count(): Long {
        val count = unitOfWorkFactory.withUnitOfWork { uow ->
            uow.handle().createQuery("SELECT count(*) FROM ${entityConfiguration.tableName()}")
                .mapTo(Long::class.java)
                .one()
        }
        log.debug(
            "count: Found {} '{}' entities",
            count,
            entityConfiguration.entityClass().simpleName,
        )
        return count
    }

    override fun findAllById(ids: Iterable): List {
        log.debug(
            "findAllById: Find '{}' entities from {} id(s)",
            entityConfiguration.entityClass().simpleName,
            ids.count()
        )
        val matches = unitOfWorkFactory.withUnitOfWork { uow ->
            uow.handle().createQuery("SELECT data FROM ${entityConfiguration.tableName()} WHERE id IN ()")
                .bindList("ids", ids.map { idSerializer(it) })
                .mapTo(String::class.java)
                .map { json -> jsonSerializer.deserialize(json, entityConfiguration.entityClass().java) as ENTITY }
                .list()
        }
        log.debug(
            "findAllById: Found {} '{}' entities from {} id(s)",
            matches.size,
            entityConfiguration.entityClass().simpleName,
            ids.count()
        )
        return matches
    }

    override fun deleteAllById(ids: Iterable) {
        log.debug(
            "deleteAllById: Delete '{}' entities from {} id(s)",
            entityConfiguration.entityClass().simpleName,
            ids.count()
        )
        val numberOfDeletedRows = unitOfWorkFactory.withUnitOfWork { uow ->
            uow.handle()
                .createUpdate("DELETE FROM ${entityConfiguration.tableName()} WHERE id IN ()")
                .bindList("ids", ids.map { idSerializer(it) })
                .execute()
        }
        log.debug(
            "deleteAllById: Deleted {} '{}' entities from {} id(s)",
            numberOfDeletedRows,
            entityConfiguration.entityClass().simpleName,
            ids.count()
        )
    }

    override fun deleteAll() {
        val numberOfDeletedRows = unitOfWorkFactory.withUnitOfWork { uow ->
            uow.handle().createUpdate("DELETE FROM ${entityConfiguration.tableName()}")
                .execute()
        }
        log.debug(
            "deleteAll: Deleted {} '{}' entities",
            numberOfDeletedRows,
            entityConfiguration.entityClass().simpleName
        )
    }

    private fun executeEntityListQuery(sql: String): List {
        return unitOfWorkFactory.withUnitOfWork { uow ->
            uow.handle()
                .createQuery(sql)
                .mapTo(String::class.java)
                .map { json -> jsonSerializer.deserialize(json, entityConfiguration.entityClass().java) as ENTITY }
                .list()
        }
    }

    private fun updateVersionedEntityProperties(entity: ENTITY, version: Version): ENTITY {
        if (log.isTraceEnabled) {
            log.trace(
                "Entity '{}' with serialized-id '{}' setting '{}' to '{}'",
                entityConfiguration.entityClass().simpleName,
                getEntityId(entity),
                VERSION_PROPERTY_NAME,
                version
            )
        }
        entityConfiguration.versionProperty().setter.call(entity, version)
        entityConfiguration.lastUpdatedProperty().setter.call(entity, OffsetDateTime.now(UTC))
        return entity
    }

    private fun getEntityVersion(entity: ENTITY): Version? {
        return entityConfiguration.versionProperty().getter.call(entity)
    }

    private fun getEntityId(entity: ENTITY): String? {
        val id = getTypedEntityId(entity)
        return if (id != null) idSerializer(id) else null
    }

    private fun getTypedEntityId(entity: ENTITY): ID? {
        return entityConfiguration.idProperty().getter.call(entity)
    }

    private fun getRequiredEntityId(entity: ENTITY): String {
        return idSerializer(getRequiredTypedEntityId(entity))
    }

    private fun getRequiredTypedEntityId(entity: ENTITY): ID {
        return getTypedEntityId(entity)
            ?: throw IllegalArgumentException("${entityConfiguration.entityClass().simpleName}.${entityConfiguration.idProperty().name} has value null. Expected a non-null value")
    }

    private fun getRequiredEntityVersion(entity: ENTITY): Version {
        return entityConfiguration.versionProperty().getter.call(entity) as? Version
            ?: throw IllegalArgumentException("${entityConfiguration.entityClass().simpleName}.${entityConfiguration.versionProperty().name} has value null. Expected a non-null value")
    }

    companion object {
        val log = LoggerFactory.getLogger(DocumentDbRepository::class.java)
    }
}

class VersionArgumentFactory : LongValueTypeArgumentFactory() {
}

class VersionColumnMapper : LongValueTypeColumnMapper() {
}




© 2015 - 2025 Weber Informatics LLC | Privacy Policy