diff --git a/src/main/kotlin/com/ex_dock/ex_dock/database/scope/ScopeClasses.kt b/src/main/kotlin/com/ex_dock/ex_dock/database/scope/ScopeClasses.kt index 371fa491..daff8254 100644 --- a/src/main/kotlin/com/ex_dock/ex_dock/database/scope/ScopeClasses.kt +++ b/src/main/kotlin/com/ex_dock/ex_dock/database/scope/ScopeClasses.kt @@ -2,6 +2,7 @@ package com.ex_dock.ex_dock.database.scope import io.vertx.core.json.JsonObject +@Deprecated("Scope() is deprecated, use JsonObject instead") data class Scope( var scopeId: String?, var websiteName: String, diff --git a/src/main/kotlin/com/ex_dock/ex_dock/database/scope/ScopeJdbcVerticle.kt b/src/main/kotlin/com/ex_dock/ex_dock/database/scope/ScopeJdbcVerticle.kt index a31454c0..27c5d9b7 100644 --- a/src/main/kotlin/com/ex_dock/ex_dock/database/scope/ScopeJdbcVerticle.kt +++ b/src/main/kotlin/com/ex_dock/ex_dock/database/scope/ScopeJdbcVerticle.kt @@ -1,12 +1,11 @@ package com.ex_dock.ex_dock.database.scope +import com.ex_dock.ex_dock.MainVerticle import com.ex_dock.ex_dock.database.connection.getConnection -import com.ex_dock.ex_dock.frontend.cache.setCacheFlag -import com.ex_dock.ex_dock.helper.replyListMessage -import com.ex_dock.ex_dock.helper.replySingleMessage +import com.ex_dock.ex_dock.global.cachedScopes +import com.ex_dock.ex_dock.helper.messages.errorResponse import io.vertx.core.Future import io.vertx.core.VerticleBase -import io.vertx.core.eventbus.DeliveryOptions import io.vertx.core.eventbus.EventBus import io.vertx.core.json.JsonObject import io.vertx.ext.mongo.MongoClient @@ -14,10 +13,9 @@ import io.vertx.ext.mongo.MongoClient class ScopeJdbcVerticle: VerticleBase() { private lateinit var client: MongoClient private lateinit var eventBus: EventBus - private val fullScopeDeliveryOptions: DeliveryOptions = DeliveryOptions().setCodecName("ScopeCodec") companion object { - private const val CACHE_ADDRESS = "scopes" + const val CACHE_ADDRESS = "scopes" } override fun start(): Future<*>? { @@ -25,128 +23,29 @@ class ScopeJdbcVerticle: VerticleBase() { eventBus = vertx.eventBus() // Initialize all eventbus connections for basic scopes - getAllScopes() - getScopeById() - getScopesByWebsiteName() - getScopesByStoreViewName() - createScope() - editScope() + eventBus.getAllScopes(client) + eventBus.getScopeById(client) + eventBus.getScopesByWebsiteId(client) + eventBus.createWebsite(client) + eventBus.createStoreView(client) deleteScope() return Future.succeededFuture() } - private fun getAllScopes() { - val getAllScopesConsumer = eventBus.consumer("process.scope.getAllScopes") - getAllScopesConsumer.handler { message -> - val query = JsonObject() - - client.find("scopes", query).replyListMessage(message) - } - } - - private fun getScopeById() { - val getScopeByWebsiteIdConsumer = eventBus.consumer("process.scope.getScopeByWebsiteId") - getScopeByWebsiteIdConsumer.handler { message -> - val websiteId = message.body() - val query = JsonObject() - .put("_id", websiteId) - - client.find("scopes", query).replySingleMessage(message) - } - } - - private fun getScopesByWebsiteName() { - val getScopesByWebsiteNameConsumer = eventBus.consumer("process.scope.getScopesByWebsiteName") - getScopesByWebsiteNameConsumer.handler { message -> - val websiteName = message.body() - val query = JsonObject() - .put("website_name", websiteName) - - client.find("scopes", query).replyListMessage(message) - } - } - - private fun getScopesByStoreViewName() { - val getScopesByStoreViewNameConsumer = eventBus.consumer("process.scope.getScopesByStoreViewName") - getScopesByStoreViewNameConsumer.handler { message -> - val storeViewName = message.body() - val query = JsonObject() - .put("store_view_name", storeViewName) - - client.find("scopes", query).replyListMessage(message) - } - } - - private fun createScope() { - val createScopeConsumer = eventBus.consumer("process.scope.createScope") - createScopeConsumer.handler { message -> - println("Received createScope message in ScopeJdbcVerticle") - val scope = message.body() - val document = scope.toDocument() - - val rowsFuture = client.save("scopes", document) - - rowsFuture.onFailure { res -> - println("Failed to execute query: $res") - message.fail(500, "Failed to execute query: $res") - } - - rowsFuture.onSuccess { res -> - val lastInsertID: String? = res - if (lastInsertID != null) { - scope.scopeId = lastInsertID - } - - setCacheFlag(eventBus, CACHE_ADDRESS) - message.reply(scope, fullScopeDeliveryOptions) - } - } - } - - private fun editScope() { - val editScopeConsumer = eventBus.consumer("process.scope.editScope") - editScopeConsumer.handler { message -> - val body = message.body() - if (body.scopeId == null) { - message.fail(400, "No scope ID provided") - return@handler - } - val document = body.toDocument() - val rowsFuture = client.save("scopes", document) - - rowsFuture.onFailure { res -> - println("Failed to execute query: $res") - message.fail(500, "Failed to execute query: $res") - } - - rowsFuture.onSuccess { res -> - val lastInsertID: String? = res - if (lastInsertID != null) { - body.scopeId = lastInsertID - } - - setCacheFlag(eventBus, CACHE_ADDRESS) - message.reply(body, fullScopeDeliveryOptions) - } - } - } + // TODO: create editScope functions private fun deleteScope() { - val deleteScopeConsumer = eventBus.consumer("process.scope.deleteScope") - deleteScopeConsumer.handler { message -> + // TODO: remove all data associated with the scope + eventBus.consumer("process.scope.deleteScope").handler { message -> val scopeId = message.body() val query = JsonObject() .put("_id", scopeId) - val rowsFuture = client.removeDocument("scopes", query) - - rowsFuture.onFailure { res -> - println("Failed to execute query: $res") - message.fail(500, "Failed to execute query: $res") - } - - rowsFuture.onSuccess { res -> + client.removeDocument("scopes", query).onFailure { err -> + message.errorResponse(500, "Failed to execute query: $err") + }.onSuccess { _ -> + cachedScopes.remove(scopeId) message.reply("Scope deleted successfully") } } diff --git a/src/main/kotlin/com/ex_dock/ex_dock/database/scope/createScopes.kt b/src/main/kotlin/com/ex_dock/ex_dock/database/scope/createScopes.kt new file mode 100644 index 00000000..32a9cc81 --- /dev/null +++ b/src/main/kotlin/com/ex_dock/ex_dock/database/scope/createScopes.kt @@ -0,0 +1,98 @@ +package com.ex_dock.ex_dock.database.scope + +import com.ex_dock.ex_dock.global.cachedScopes +import com.ex_dock.ex_dock.helper.messages.errorResponse +import io.vertx.core.eventbus.EventBus +import io.vertx.core.json.JsonObject +import io.vertx.ext.mongo.MongoClient +import org.bson.types.ObjectId + +internal fun EventBus.createWebsite(client: MongoClient) { + this.localConsumer("process.scope.create.website").handler { message -> + val data = message.body() + + val keyString = data.getString("scopeKey") ?: return@handler message.fail(400, "The key of the website (scope) is required.") + val key = ObjectId(keyString) + val name = + data.getString("scopeName") ?: return@handler message.fail(400, "The name of the website (scope) is required.") + + client.findOne( + ScopeJdbcVerticle.CACHE_ADDRESS, + JsonObject().put("_id", key), + JsonObject().put("_id", 1), + ).onFailure { err -> + message.errorResponse(400, err) + }.onSuccess { res -> + if (res != null) return@onSuccess message.fail( + 409, + "This function is for creating websites (scope), not editing them." + ) + + val document = JsonObject().put("_id", key).put("scopeName", name).put("scopeType", "website") + + client.insert(ScopeJdbcVerticle.CACHE_ADDRESS, document).onFailure { err -> + message.errorResponse(err) + }.onSuccess { res -> + cachedScopes.put(keyString, document) + message.reply(res ?: key) + } + } + } +} + +internal fun EventBus.createStoreView(client: MongoClient) { + this.localConsumer("process.scope.create.store-view").handler { message -> + val data = message.body() + + val name = + data.getString("name") ?: return@handler message.fail(400, "The name of the store-view (scope) is required.") + val keyString = data.getString("key") ?: return@handler message.fail(400, "The key of the store-view (scope) is required.") + val key = ObjectId(keyString) + val websiteId = data.getString("websiteId") ?: return@handler message.fail( + 400, + "The websiteId of the parent website (scope) is required." + ) + + client.findOne( + ScopeJdbcVerticle.CACHE_ADDRESS, + JsonObject().put("_id", key), + JsonObject().put("_id", 1), + ).onFailure { err -> + message.errorResponse(400, err) + }.onSuccess { res -> + if (res != null) return@onSuccess message.fail( + 409, + "This function is for creating store-views (scope), not editing them." + ) + + val searchWebsiteQuery = JsonObject().put("scopeType", "website").put("_id", websiteId) + client.find(ScopeJdbcVerticle.CACHE_ADDRESS, searchWebsiteQuery).onFailure { err -> + message.errorResponse(err) + }.onSuccess { res -> + if (res.isEmpty()) return@onSuccess message.fail( + 400, + "The websiteId of the parent website (scope) does not exist" + ) + + client.find(ScopeJdbcVerticle.CACHE_ADDRESS, JsonObject().put("_id", key)).onFailure { err -> + message.errorResponse(err) + }.onSuccess { res -> + if (res.isNotEmpty()) return@onSuccess message.fail( + 400, + "The key of the store-view (scope) already exists for a scope" + ) + + val document = JsonObject().put("_id", key).put("scopeName", name).put("scopeType", "store-view") + .put("websiteId", websiteId) + + client.insert(ScopeJdbcVerticle.CACHE_ADDRESS, document).onFailure { err -> + message.errorResponse(err) + }.onSuccess { res -> + cachedScopes.put(keyString, document) + message.reply(res ?: key) + } + } + } + } + } +} diff --git a/src/main/kotlin/com/ex_dock/ex_dock/database/scope/getScopes.kt b/src/main/kotlin/com/ex_dock/ex_dock/database/scope/getScopes.kt new file mode 100644 index 00000000..e20bd3c3 --- /dev/null +++ b/src/main/kotlin/com/ex_dock/ex_dock/database/scope/getScopes.kt @@ -0,0 +1,45 @@ +package com.ex_dock.ex_dock.database.scope + +import com.ex_dock.ex_dock.global.cachedScopes +import com.ex_dock.ex_dock.helper.replyListMessage +import com.ex_dock.ex_dock.helper.replySingleMessage +import io.vertx.core.eventbus.EventBus +import io.vertx.core.json.JsonObject +import io.vertx.ext.mongo.MongoClient + + +internal fun EventBus.getAllScopes(client: MongoClient) { + this.consumer("process.scope.getAllScopes").handler { message -> + val query = JsonObject() + + client.find("scopes", query).onSuccess { res -> + val newCachedScopes = JsonObject() + for (scope in res) newCachedScopes.put(scope.getString("_id"), scope) + cachedScopes = newCachedScopes + }.replyListMessage(message) + } +} + +internal fun EventBus.getScopeById(client: MongoClient) { + this.consumer("process.scope.getScopeById").handler { message -> + val websiteId = message.body() + val query = JsonObject() + .put("_id", websiteId) + + client.find("scopes", query).onSuccess { res -> + cachedScopes.put(websiteId, res.first()) + }.replySingleMessage(message) + } +} + +internal fun EventBus.getScopesByWebsiteId(client: MongoClient) { + this.consumer("process.scope.getScopesByWebsiteId").handler { message -> + val websiteName = message.body() + val query = JsonObject() + .put("websiteId", websiteName) + + client.find("scopes", query).onSuccess { res -> + for (scope in res) cachedScopes.put(scope.getString("_id"), scope) + }.replyListMessage(message) + } +} diff --git a/src/main/kotlin/com/ex_dock/ex_dock/global/scopes.kt b/src/main/kotlin/com/ex_dock/ex_dock/global/scopes.kt new file mode 100644 index 00000000..f79786ab --- /dev/null +++ b/src/main/kotlin/com/ex_dock/ex_dock/global/scopes.kt @@ -0,0 +1,5 @@ +package com.ex_dock.ex_dock.global + +import io.vertx.core.json.JsonObject + +var cachedScopes: JsonObject = JsonObject() diff --git a/src/main/kotlin/com/ex_dock/ex_dock/helper/attributes/AttributeConfiguration.kt b/src/main/kotlin/com/ex_dock/ex_dock/helper/attributes/AttributeConfiguration.kt new file mode 100644 index 00000000..8b1f3f2e --- /dev/null +++ b/src/main/kotlin/com/ex_dock/ex_dock/helper/attributes/AttributeConfiguration.kt @@ -0,0 +1,19 @@ +package com.ex_dock.ex_dock.helper.attributes + +import com.ex_dock.ex_dock.helper.scopes.ScopeLevel +import io.vertx.core.json.JsonObject + +data class AttributeConfiguration( + val attributeKey: String, + val attributeName: String, + val attributeDataType: String, + val scopeLevel: ScopeLevel, +) { + fun toDocument(): JsonObject { + return JsonObject() + .put("_id", attributeKey) + .put("attributeName", attributeName) + .put("attributeType", attributeDataType) + .put("attributeScopeLevel", scopeLevel.name) + } +} \ No newline at end of file diff --git a/src/main/kotlin/com/ex_dock/ex_dock/helper/attributes/Attributes.kt b/src/main/kotlin/com/ex_dock/ex_dock/helper/attributes/Attributes.kt new file mode 100644 index 00000000..6aefe265 --- /dev/null +++ b/src/main/kotlin/com/ex_dock/ex_dock/helper/attributes/Attributes.kt @@ -0,0 +1,502 @@ +package com.ex_dock.ex_dock.helper.attributes + +import com.ex_dock.ex_dock.global.cachedScopes +import com.ex_dock.ex_dock.helper.futures.onComplete +import com.ex_dock.ex_dock.helper.futures.onFailure +import com.ex_dock.ex_dock.helper.futures.onSuccess +import com.ex_dock.ex_dock.helper.scopes.ScopeLevel +import io.vertx.core.Future +import io.vertx.core.json.JsonArray +import io.vertx.core.json.JsonObject +import io.vertx.ext.mongo.FindOptions +import io.vertx.ext.mongo.MongoClient +import kotlin.reflect.KClass + + +/** + * The [Attributes] abstract class dictates the way that attributes are handled in exDock. + * It is the basis for all different types of attributes. + */ +abstract class Attributes(internal val client: MongoClient) { + abstract val collection: String + val collectionConfigKey = "$collection-attributes" + abstract val allowedTypes: Map> + abstract val systemAttributes: List + + internal fun getCollectionKey(scopeKey: String): String { + if (scopeKey == "global") return collection + return "$collection-$scopeKey" + } + + internal fun isValidAttributeKey(attributeKey: String): Boolean { + if (attributeKey.length < 4) return false + + // Regular expression explanation: + // ^[a-zA-Z] - Must start with a letter (upper or lower case). + // [a-zA-Z0-9_-]* - Followed by zero or more of: letters, numbers, hyphen, or underscore. + // [a-zA-Z0-9]$ - MUST end with a letter or a number. + // This ensures the key cannot end with a hyphen or an underscore. + val regex = Regex("^[a-zA-Z][a-zA-Z0-9_-]*[a-zA-Z0-9]$") + return attributeKey.matches(regex) + } + + internal fun getScopedDataSingle( + scopeKey: String, + query: JsonObject, + fields: JsonObject? = null + ): Future { + var globalData: JsonObject? = null + var websiteData: JsonObject? = null + var scopeData: JsonObject? = null + val allFutures = mutableListOf>() + + val scope = cachedScopes.getJsonObject(scopeKey) ?: return Future.failedFuture("Scope not found") + + allFutures.add( + Future.future { promise -> + client.findOne(getCollectionKey("global"), query, fields).onFailure { err -> + promise.fail(err) + }.onSuccess { res -> + globalData = res + promise.complete() + } + } + ) + + if (scopeKey != "global") { + allFutures.add( + Future.future { promise -> + client.findOne(getCollectionKey(scopeKey), query, fields).onFailure { err -> + promise.fail(err) + }.onSuccess { res -> + scopeData = res + } + } + ) + + if (scope.getString("scopeType") == "store-view") { + allFutures.add( + Future.future { promise -> + client.findOne(getCollectionKey(scope.getString("websiteId")), query, fields).onFailure { err -> + promise.fail(err) + }.onSuccess { res -> + websiteData = res + promise.complete() + } + } + ) + } + } + + return Future.future { promise -> + Future.all(allFutures).onFailure { err -> + promise.fail(err) + }.onSuccess { _ -> + if (globalData == null && websiteData == null && scopeData == null) return@onSuccess promise.complete(null) + + promise.complete( + (globalData ?: JsonObject()).apply { + if (websiteData != null) mergeIn(websiteData) + if (scopeData != null) mergeIn(scopeData) + } + ) + } + } + } + + internal fun getScopedData( + scopeKey: String, + query: JsonObject, + fields: JsonObject? = null + ): Future> { + var globalData: List? = null + var websiteData: List? = null + var scopeData: List? = null + val allFutures = mutableListOf>() + + val scope = cachedScopes.getJsonObject(scopeKey) ?: return Future.failedFuture("Scope not found") + val findOptions = FindOptions().setFields(fields ?: JsonObject()) + + allFutures.add( + Future.future { promise -> + client.findWithOptions( + getCollectionKey("global"), + query, + findOptions, + ).onFailure { err -> + promise.fail(err) + }.onSuccess { res -> + globalData = res + promise.complete() + } + } + ) + + if (scopeKey != "global") { + allFutures.add( + Future.future { promise -> + client.findWithOptions(getCollectionKey(scopeKey), query, findOptions).onFailure { err -> + promise.fail(err) + }.onSuccess { res -> + scopeData = res + } + } + ) + + if (scope.getString("scopeType") == "store-view") { + allFutures.add( + Future.future { promise -> + client.findWithOptions(getCollectionKey(scope.getString("websiteId")), query, findOptions) + .onFailure { err -> + promise.fail(err) + }.onSuccess { res -> + websiteData = res + promise.complete() + } + } + ) + } + } + + return Future.future { promise -> + Future.all(allFutures).onFailure { err -> + promise.fail(err) + }.onSuccess { _ -> + // Should never happen, but just to be sure + if (globalData == null && websiteData == null && scopeData == null) return@onSuccess promise.complete(null) + + val globalDataMap = mutableMapOf() + val websiteDataMap = mutableMapOf() + val scopeDataMap = mutableMapOf() + val allIds = mutableListOf() + + globalData?.forEach { data -> + val id = data.getString("_id") + allIds.add(id) + globalDataMap[id] = data + } + websiteData?.forEach { data -> + val id = data.getString("_id") + allIds.add(id) + websiteDataMap[id] = data + } + scopeData?.forEach { data -> + val id = data.getString("_id") + allIds.add(id) + scopeDataMap[id] = data + } + + val result = mutableListOf() + + allIds.forEach { id -> + var websiteDone = false + var scopeDone = false + var data: JsonObject? = globalDataMap[id] + if (data == null) { + data = websiteDataMap[id] + websiteDone = true + if (data == null) { + data = scopeDataMap[id] + scopeDone = true + } + } + + val websiteData = websiteDataMap[id] + if (!websiteDone && websiteData != null) { + data!!.mergeIn(websiteData) + } + + val scopeData = scopeDataMap[id] + if (!scopeDone && scopeData != null) { + data!!.mergeIn(scopeData) + } + + if (data != null) result.add(data) + } + + promise.complete(result) + } + } + } + + fun getAttributeValue(entityId: String, attributeKey: String, scopeKey: String): Future { + return Future.future { promise -> + getScopedDataSingle( + scopeKey, + JsonObject().put("_id", entityId), + JsonObject().put(attributeKey, 1) + ).onFailure { err -> + promise.fail(err) + }.onSuccess { res -> + promise.complete(res) + } + } + } + + fun getAttributeValue(entityIds: List, attributeKey: String, scopeKey: String): Future> { + return Future.future { promise -> + getScopedData( + getCollectionKey(scopeKey), + JsonObject().put("_id", JsonObject().put($$"$in", JsonArray(entityIds))), + JsonObject().put(attributeKey, 1), + ).onFailure { err -> + promise.fail(err) + }.onSuccess { res -> + promise.complete(res) + } + } + } + + fun getAttributesValue(entityId: String, attributeKeys: List, scopeKey: String): Future { + return Future.future { promise -> + val fields = JsonObject() + for (key in attributeKeys) fields.put(key, 1) + getScopedDataSingle( + getCollectionKey(scopeKey), + JsonObject().put("_id", entityId), + fields, + ).onFailure { err -> + promise.fail(err) + }.onSuccess { res -> + promise.complete(res) + } + } + } + + fun getAttributesValue(entityId: String, scopeKey: String): Future { + return Future.future { promise -> + getScopedDataSingle( + getCollectionKey(scopeKey), + JsonObject().put("_id", entityId), + null, + ).onFailure { err -> + promise.fail(err) + }.onSuccess { res -> + promise.complete(res) + } + } + } + + fun getAttributeType(attributeKey: String): Future> { + return Future.future { promise -> + client.findOne( + collectionConfigKey, + JsonObject().put("_id", attributeKey), + JsonObject().put("attributeType", 1) + ).onFailure(promise).onSuccess { res -> + promise.complete( + allowedTypes[res.getString("attributeType")] + ?: throw IllegalStateException("For some reason, the attribute type for this attributeKey is not in the allowedTypes map... This means that the KClass can't be matched and returned. A database repair is required") + ) + } + } + } + + fun checkValueType(attributeKey: String, kClass: KClass<*>): Future { + return Future.future { promise -> + getAttributeType(attributeKey).onFailure(promise).onSuccess { res -> + promise.complete(res == kClass) + } + } + } + + fun checkValueType(attributeKey: String, value: Any): Future { + return checkValueType(attributeKey, value::class) + } + + fun setAttributeValue(entityId: String, attributeKey: String, value: Any, scopeKey: String): Future { + return Future.future { promise -> + checkValueType(attributeKey, value).onFailure(promise).onSuccess { res -> + if (!res) { + getAttributeType(attributeKey).onComplete { asyncRes -> + promise.fail( + "$value (type: ${value::class.simpleName}) is not the correct type for $attributeKey (type: ${asyncRes.result() ?: "type name could not be retrieved"})" + ) + } + return@onSuccess + } + + client.findOneAndUpdate( + getCollectionKey(scopeKey), + JsonObject().put("_id", entityId), + JsonObject().put($$"$set", JsonObject().put(attributeKey, value)), + ).onFailure(promise).onSuccess { _ -> + promise.complete(value) + } + } + } + } + + fun setAttributesValue(entityId: String, attributes: Map, scopeKey: String): Future> { + return Future.future { promise -> + val checkValueType: List> = attributes.map { (attributeKey, value) -> + checkValueType(attributeKey, value) + } + + val attributeKeyList = attributes.keys.toList() + val wrongTypeAttributeValues = mutableMapOf() + + Future.all(checkValueType).onFailure(promise).onSuccess { asyncRes -> + asyncRes.list().forEachIndexed { index, res -> + if (!res) { + val attributeKey = attributeKeyList[index] + wrongTypeAttributeValues[attributeKey] = attributes[attributeKey]!! + } + } + + if (wrongTypeAttributeValues.isNotEmpty()) { + val errorMessages = mutableListOf() + + val typeNameFutures: List?>> = wrongTypeAttributeValues.keys.map { attributeKey -> + Future.future { promise -> + getAttributeType(attributeKey).onComplete { asyncRes -> + promise.complete(asyncRes.result()) + } + } + } + + Future.all>(typeNameFutures).onFailure(promise).onSuccess { res -> + wrongTypeAttributeValues.entries.forEachIndexed { index, (key, value) -> + val expectedTypeName = + (res.resultAt(index) as KClass<*>)::class.simpleName ?: "type name could not be retrieved" + errorMessages.add( + "$value (type: ${value::class.simpleName}) is not the correct type for $key (type: $expectedTypeName)" + ) + } + } + + promise.fail( + "The following type errors occurred while trying to set multiple attributes: \n- " + + errorMessages.joinToString("\n- ") + + "\n\nThe complete set attributes operation was aborted." + ) + + return@onSuccess + } + + client.findOneAndUpdate( + getCollectionKey(scopeKey), + JsonObject().put("_id", entityId), + JsonObject().put($$"$set", JsonObject(attributes)), + ).onFailure { err -> promise.fail(err) }.onSuccess { _ -> + promise.complete(attributes) + } + + return@onSuccess + } + } + } + + /** + * Clears the attribute for the entity. + * + * @throws IllegalArgumentException When you try to clear a required attribute. + */ + fun clearAttributeValue(entityId: String, attributeKey: String, scopeKey: String): Future { + return Future.future { promise -> + client.findOneAndUpdate( + getCollectionKey(scopeKey), + JsonObject().put("_id", entityId), + JsonObject().put($$"$unset", JsonObject().put(attributeKey, null)), + ).onFailure { err -> promise.fail(err) }.onSuccess { _ -> promise.complete() } + } + } + + /** + * Clears the attributes for the entity. + * + * @throws IllegalArgumentException When you try to clear a required attribute. + */ + fun clearAttributesValue(entityId: String, attributeKeys: List, scopeKey: String): Future { + val attributes = mutableMapOf() + for (key in attributeKeys) attributes[key] = null + + return Future.future { promise -> + client.findOneAndUpdate( + getCollectionKey(scopeKey), + JsonObject().put("_id", entityId), + JsonObject().put($$"$unset", JsonObject(attributes)), + ).onFailure { err -> promise.fail(err) }.onSuccess { _ -> promise.complete() } + } + } + + /** + * Clears all the attributes for the entity. This is meant as an assist for the removal of the entityId. + * + * Attention: Also removes the required attributes! + */ + fun clearAllAttributesValue(entityId: String): Future { + val query = JsonObject().put("_id", entityId) + val allFutures = mutableListOf>() + for ((key, _) in cachedScopes) allFutures.add(client.removeDocument(getCollectionKey(key), query)) + allFutures.add(client.removeDocument(collection, query)) + return Future.future { promise -> + Future.all(allFutures).onFailure { err -> + promise.fail(err) + }.onSuccess { _ -> + promise.complete() + } + } + } + + /** + * Clears all the values for a certain attribute across all scopes. This is meant as an assist for the removal of an attribute. + */ + fun clearAttributeAllValues(attributeKey: String): Future { + return Future.future { promise -> + val allFutures = mutableListOf>() + for ((key, _) in cachedScopes) allFutures.add( + client.updateCollection( + getCollectionKey(key), + JsonObject(), + JsonObject().put($$"$unset", JsonObject().put(attributeKey, null)) + ) + ) + allFutures.add( + client.updateCollection( + collection, + JsonObject(), + JsonObject().put($$"$unset", JsonObject().put(attributeKey, null)) + ) + ) + Future.all(allFutures).onFailure(promise).onSuccess(promise) + } + } + + fun createAttribute( + attributeName: String, + attributeKey: String, + dataType: String, + scopeLevel: ScopeLevel + ): Future { + if (!isValidAttributeKey(attributeKey)) return Future.failedFuture("Invalid attribute key") + if (allowedTypes[dataType] == null) return Future.failedFuture("Invalid data type") + return Future.future { promise -> + val document = JsonObject() + .put("_id", attributeKey) + .put("attributeName", attributeName) + .put("attributeType", dataType) + .put("attributeScopeLevel", scopeLevel.name) + client.insert(collectionConfigKey, document).onFailure(promise).onSuccess(promise) + } + } + + // TODO: fun editAttribute() + + fun deleteAttribute(attributeKey: String): Future { + return Future.future { promise -> + clearAttributeAllValues(attributeKey).onFailure(promise).onSuccess { _ -> + client.removeDocument(collectionConfigKey, JsonObject().put("_id", attributeKey)).onComplete(promise) + } + } + } + + /** + * This function checks if the system attributes are present, and if not, add all the missing attributes. + * + * Future will fail when system attributes are present, but the config doesn't match or on database error. + */ + fun initialiseSystemAttributes(): Future { + TODO("Not yet implemented") + } +} diff --git a/src/main/kotlin/com/ex_dock/ex_dock/helper/attributes/ProductAttributes.kt b/src/main/kotlin/com/ex_dock/ex_dock/helper/attributes/ProductAttributes.kt new file mode 100644 index 00000000..a8923cb2 --- /dev/null +++ b/src/main/kotlin/com/ex_dock/ex_dock/helper/attributes/ProductAttributes.kt @@ -0,0 +1,20 @@ +package com.ex_dock.ex_dock.helper.attributes + +import com.ex_dock.ex_dock.helper.scopes.ScopeLevel +import io.vertx.ext.mongo.MongoClient +import kotlin.reflect.KClass + +class ProductAttributes(client: MongoClient) : Attributes(client) { + override val collection: String = "products" + override val allowedTypes: Map> = mapOf( + "string" to String::class, + "integer" to Int::class, + "number" to Number::class, + "boolean" to Boolean::class, + ) + override val systemAttributes: List = listOf( + AttributeConfiguration("name", "Product name", "string", ScopeLevel.STORE_VIEW), + AttributeConfiguration("description", "Product description", "string", ScopeLevel.STORE_VIEW), + // TODO: think about all system attributes for the products + ) +} diff --git a/src/main/kotlin/com/ex_dock/ex_dock/helper/futures/future_handle_parent_future.kt b/src/main/kotlin/com/ex_dock/ex_dock/helper/futures/future_handle_parent_future.kt new file mode 100644 index 00000000..548d98f7 --- /dev/null +++ b/src/main/kotlin/com/ex_dock/ex_dock/helper/futures/future_handle_parent_future.kt @@ -0,0 +1,47 @@ +package com.ex_dock.ex_dock.helper.futures + +import io.vertx.core.Future +import io.vertx.core.Promise + +/** + * This onFailure method replaces the standard onFailure { promise.fail(it) } + */ +fun Future.onFailure(promise: Promise<*>): Future { + return this.onFailure { err -> + promise.fail(err) + } +} + +/** + * This onSuccess method replaces the standard onSuccess { promise.complete(it) } + */ +fun Future.onSuccess(promise: Promise): Future { + return this.onSuccess { res -> + promise.complete(res) + } +} + +/** + * This onSuccess method replaces the standard onSuccess { promise.complete() } + */ +@JvmName("onSuccessUnit") // Make the JVM name unique for the Unit version +fun Future.onSuccess(promise: Promise): Future { + return this.onSuccess { _ -> + promise.complete() + } +} + +/** + * This onComplete method replaces the standard .onFailure { promise.fail(it) }.onSuccess { promise.complete(it) } + */ +fun Future.onComplete(promise: Promise): Future { + return this.onFailure(promise).onSuccess(promise) +} + +/** + * This onComplete method replaces the standard .onFailure { promise.fail(it) }.onSuccess { promise.complete() } + */ +@JvmName("onCompleteUnit") // Make the JVM name unique for the Unit version +fun Future.onComplete(promise: Promise): Future { + return this.onFailure(promise).onSuccess(promise) +} diff --git a/src/main/kotlin/com/ex_dock/ex_dock/helper/messages/messageErrorResponse.kt b/src/main/kotlin/com/ex_dock/ex_dock/helper/messages/messageErrorResponse.kt new file mode 100644 index 00000000..aeeec000 --- /dev/null +++ b/src/main/kotlin/com/ex_dock/ex_dock/helper/messages/messageErrorResponse.kt @@ -0,0 +1,18 @@ +package com.ex_dock.ex_dock.helper.messages + +import com.ex_dock.ex_dock.MainVerticle +import com.ex_dock.ex_dock.helper.errors.failureCode +import io.vertx.core.eventbus.Message + +fun Message<*>.errorResponse(throwable: Throwable) { + this.errorResponse(throwable.failureCode(), throwable) +} + +fun Message<*>.errorResponse(failureCode: Int, throwable: Throwable) { + this.errorResponse(failureCode, throwable.message ?: "Missing throwable error message") +} + +fun Message<*>.errorResponse(failureCode: Int, message: String) { + MainVerticle.logger.error { "message.errorResponse() [${failureCode}] $message" } + this.fail(failureCode, message) +} diff --git a/src/main/kotlin/com/ex_dock/ex_dock/helper/scopes/ScopeLevel.kt b/src/main/kotlin/com/ex_dock/ex_dock/helper/scopes/ScopeLevel.kt new file mode 100644 index 00000000..0ed5e4da --- /dev/null +++ b/src/main/kotlin/com/ex_dock/ex_dock/helper/scopes/ScopeLevel.kt @@ -0,0 +1,7 @@ +package com.ex_dock.ex_dock.helper.scopes + +enum class ScopeLevel { + GLOBAL, + WEBSITE, + STORE_VIEW; +} \ No newline at end of file diff --git a/src/test/kotlin/com/ex_dock/ex_dock/database/scope/ScopeJdbcVerticleTest.kt b/src/test/kotlin/com/ex_dock/ex_dock/database/scope/ScopeJdbcVerticleTest.kt index 0a77181b..1e2fe657 100644 --- a/src/test/kotlin/com/ex_dock/ex_dock/database/scope/ScopeJdbcVerticleTest.kt +++ b/src/test/kotlin/com/ex_dock/ex_dock/database/scope/ScopeJdbcVerticleTest.kt @@ -1,60 +1,34 @@ package com.ex_dock.ex_dock.database.scope -import com.ex_dock.ex_dock.helper.deployWorkerVerticleHelper import com.ex_dock.ex_dock.helper.codecs.registerGenericCodec +import com.ex_dock.ex_dock.helper.deployWorkerVerticleHelper import io.vertx.core.Vertx -import io.vertx.core.eventbus.DeliveryOptions import io.vertx.core.eventbus.EventBus import io.vertx.core.json.JsonObject -import io.vertx.ext.unit.TestSuite import io.vertx.junit5.VertxExtension import io.vertx.junit5.VertxTestContext -import org.junit.jupiter.api.AfterEach -import org.junit.jupiter.api.BeforeEach -import org.junit.jupiter.api.DisplayName -import org.junit.jupiter.api.Test +import org.junit.jupiter.api.* import org.junit.jupiter.api.extension.ExtendWith @ExtendWith(VertxExtension::class) +@TestMethodOrder(MethodOrderer.OrderAnnotation::class) class ScopeJdbcVerticleTest { + private lateinit var eventBus: EventBus - private val scopeDeliveryOptions = DeliveryOptions().setCodecName("ScopeCodec") - private var testScope = Scope( - scopeId = "123", - websiteName = "testWebsite", - storeViewName = "testStoreView" - ) + private val idsToCleanup = mutableListOf() - @Test - @DisplayName("Test the scope classes functions") - fun testScopeClassesFunctions(vertx: Vertx, context: VertxTestContext) { - val suite = TestSuite.create("testScopeClassesFunctions") - - suite.test("testScopeToJson") { testContext -> - val result = testScope.toDocument() - testContext.assertEquals(testScope.scopeId, result.getString("_id")) - testContext.assertEquals(testScope.websiteName, result.getString("website_name")) - }.test("testScopeFromJson") { testContext -> - val scopeJson = testScope.toDocument() - val scope = Scope.fromJson(scopeJson) - testContext.assertEquals(testScope.scopeId, scope.scopeId) - testContext.assertEquals(testScope.websiteName, scope.websiteName) - } + // Test data + private val websiteJson = JsonObject() + .put("scopeName", "Test Website") + .put("scopeKey", "test_website") - suite.run(vertx).handler { res -> - if (res.succeeded()) { - context.completeNow() - } else { - context.failNow(res.cause()) - } - } - } + private lateinit var testWebsiteId: String @BeforeEach - @DisplayName("Add the scope to the database") - fun setup(vertx: Vertx, vertxTestContext: VertxTestContext) { + @DisplayName("Deploy Verticle and Create a Base Website Scope") + fun setup(vertx: Vertx, context: VertxTestContext) { eventBus = vertx.eventBus() - eventBus.registerGenericCodec(Scope::class) + // The List codec is still needed for functions that return multiple results eventBus.registerGenericCodec(List::class) deployWorkerVerticleHelper( @@ -64,64 +38,135 @@ class ScopeJdbcVerticleTest { 1, 1 ).onFailure { err -> - vertxTestContext.failNow(err) + context.failNow(err) }.onSuccess { - eventBus.request("process.scope.createScope", testScope, scopeDeliveryOptions).onFailure { - vertxTestContext.failNow(it) + // Create a base website for other tests to use + eventBus.request("process.scope.create.website", websiteJson).onFailure { + context.failNow(it) }.onSuccess { message -> - val result = message.body() - testScope.scopeId = result.scopeId // Update testScope with the generated ID - vertxTestContext.verify { -> - assert(result.scopeId != null) - assert(result.websiteName == testScope.websiteName) - assert(result.storeViewName == testScope.storeViewName) - vertxTestContext.completeNow() - } + testWebsiteId = message.body() + idsToCleanup.add(testWebsiteId) // Ensure it gets cleaned up + context.completeNow() + } + } + } + + @AfterEach + @DisplayName("Remove Scopes from the Database") + fun tearDown(vertx: Vertx, context: VertxTestContext) { + val checkpoint = context.checkpoint(idsToCleanup.size) + if (idsToCleanup.isEmpty()) { + context.completeNow() + return + } + + idsToCleanup.forEach { scopeId -> + eventBus.request("process.scope.deleteScope", scopeId).onComplete { + checkpoint.flag() } } } @Test - @DisplayName("Test getting a scope by id from the database") - fun testGetScopeById(vertx: Vertx, vertxTestContext: VertxTestContext) { - eventBus.request("process.scope.getScopeByWebsiteId", testScope.scopeId).onFailure { - vertxTestContext.failNow(it) - }.onSuccess { message -> - val result = Scope.fromJson(message.body()) - vertxTestContext.verify { -> - assert(result.scopeId == testScope.scopeId) - assert(result.websiteName == testScope.websiteName) - assert(result.storeViewName == testScope.storeViewName) - vertxTestContext.completeNow() - } + @Order(1) + @DisplayName("Test creating a valid website scope") + fun testCreateWebsite(context: VertxTestContext) { + val newWebsite = JsonObject() + .put("scopeName", "Another Website") + .put("scopeKey", "another_website") + + eventBus.request("process.scope.create.website", newWebsite).onFailure { + context.failNow(it) + }.onSuccess { message -> + val newId = message.body() + context.verify { + Assertions.assertNotNull(newId) } + idsToCleanup.add(newId) // Add for cleanup + context.completeNow() + } + } + + @Test + @Order(2) + @DisplayName("Test creating a valid store-view scope") + fun testCreateStoreView(context: VertxTestContext) { + val storeViewJson = JsonObject() + .put("name", "Test Store View") + .put("key", "test_store_view") + .put("websiteId", testWebsiteId) + + eventBus.request("process.scope.create.store-view", storeViewJson).onFailure { + context.failNow(it) + }.onSuccess { message -> + val newId = message.body() + context.verify { + Assertions.assertNotNull(newId) + } + idsToCleanup.add(newId) + context.completeNow() + } + } + + @Test + @Order(3) + @DisplayName("Test creating a store-view with a non-existent websiteId fails") + fun testCreateStoreViewWithInvalidWebsiteId(context: VertxTestContext) { + val storeViewJson = JsonObject() + .put("name", "Invalid Store View") + .put("key", "invalid_store_view") + .put("websiteId", "nonExistentId123") + + eventBus.request("process.scope.create.store-view", storeViewJson).onSuccess { + context.failNow("Should have failed for invalid websiteId") + }.onFailure { + context.verify { + Assertions.assertTrue(it.message?.contains("does not exist") ?: false) + context.completeNow() + } + } } @Test - @DisplayName("Test updating a scope in the database") - fun testUpdateScope(vertx: Vertx, vertxTestContext: VertxTestContext) { - val updatedScope = testScope.copy(websiteName = "updatedWebsite") - eventBus.request("process.scope.editScope", updatedScope, scopeDeliveryOptions).onFailure { - vertxTestContext.failNow(it) + @Order(4) + @DisplayName("Test getting a scope by its ID") + fun testGetScopeById(context: VertxTestContext) { + eventBus.request("process.scope.getScopeById", testWebsiteId).onFailure { + context.failNow(it) }.onSuccess { message -> val result = message.body() - vertxTestContext.verify { -> - assert(result.websiteName == updatedScope.websiteName) - vertxTestContext.completeNow() + context.verify { + Assertions.assertEquals(testWebsiteId, result.getString("_id")) + Assertions.assertEquals(websiteJson.getString("scopeName"), result.getString("scopeName")) + Assertions.assertEquals("website", result.getString("scopeType")) + context.completeNow() } } } - @AfterEach - @DisplayName("Remove the scope from the database") - fun tearDown(vertx: Vertx, vertxTestContext: VertxTestContext) { - eventBus.request("process.scope.deleteScope", testScope.scopeId).onFailure { - vertxTestContext.failNow(it) + @Test + @Order(5) + @DisplayName("Test getting all scopes") + fun testGetAllScopes(context: VertxTestContext) { + eventBus.request>("process.scope.getAllScopes", null).onFailure { + context.failNow(it) }.onSuccess { message -> - vertxTestContext.verify { -> - assert(message.body() == "Scope deleted successfully") - vertxTestContext.completeNow() + val results = message.body() + results.size + context.verify { + // At least the website from setup should be present + Assertions.assertTrue(results.isNotEmpty()) + val firstResult = results[0] + Assertions.assertNotNull(firstResult.getString("_id")) + context.completeNow() } } } -} + + /* + * NOTE: A test for 'editScope' has been omitted. + * The `editScope` function in `ScopeJdbcVerticle` has not yet been refactored. + * It still expects a `Scope` object, which is deprecated. + * A new test should be written once `editScope` is updated to work with JsonObject payloads. + */ +} \ No newline at end of file