[+] Wacca database models

This commit is contained in:
Azalea 2024-03-25 03:06:28 -04:00
parent 89461893a4
commit 484bb758ae
8 changed files with 510 additions and 48 deletions

View File

@ -83,45 +83,6 @@ operator fun Int.minus(message: String): Nothing {
val emailRegex = "^(?=.{1,64}@)[\\p{L}0-9_-]+(\\.[\\p{L}0-9_-]+)*@[^-][\\p{L}0-9-]+(\\.[\\p{L}0-9-]+)*(\\.[\\p{L}]{2,})$".toRegex()
fun Str.isValidEmail(): Bool = emailRegex.matches(this)
// JSON
val ACCEPTABLE_FALSE = setOf("0", "false", "no", "off", "False", "None", "null")
val ACCEPTABLE_TRUE = setOf("1", "true", "yes", "on", "True")
val JSON_FUZZY_BOOLEAN = SimpleModule().addDeserializer(Boolean::class.java, object : JsonDeserializer<Boolean>() {
override fun deserialize(parser: JsonParser, context: DeserializationContext) = when(parser.text) {
in ACCEPTABLE_FALSE -> false
in ACCEPTABLE_TRUE -> true
else -> 400 - "Invalid boolean value ${parser.text}"
}
})
val JSON_DATETIME = SimpleModule().addDeserializer(LocalDateTime::class.java, object : JsonDeserializer<LocalDateTime>() {
override fun deserialize(parser: JsonParser, context: DeserializationContext) =
parser.text.asDateTime() ?: (400 - "Invalid date time value ${parser.text}")
})
val JACKSON = ObjectMapper().apply {
setSerializationInclusion(JsonInclude.Include.NON_NULL)
setDefaultPropertyInclusion(JsonInclude.Include.NON_NULL)
findAndRegisterModules()
registerModule(JSON_FUZZY_BOOLEAN)
registerModule(JSON_DATETIME)
configure(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES, false)
}
inline fun <reified T> ObjectMapper.readValue(str: Str) = readValue(str, T::class.java)
// TODO: https://stackoverflow.com/q/78197784/7346633
inline fun <reified T> Str.parseJackson() = if (contains("null")) {
val map = JACKSON.readValue<MutableMap<String, Any>>(this)
JACKSON.convertValue(map.recursiveNotNull(), T::class.java)
}
else JACKSON.readValue(this, T::class.java)
fun <T> T.toJson() = JACKSON.writeValueAsString(this)
@OptIn(ExperimentalSerializationApi::class)
val JSON = Json {
ignoreUnknownKeys = true
isLenient = true
namingStrategy = JsonNamingStrategy.SnakeCase
explicitNulls = false
coerceInputValues = true
}
inline fun <reified T> Json.parse(str: Str) = decodeFromString<T>(str)
// Global Tools
val HTTP = HttpClient(CIO) {
install(ContentNegotiation) {

53
src/main/java/ext/Json.kt Normal file
View File

@ -0,0 +1,53 @@
package ext
import com.fasterxml.jackson.annotation.JsonInclude
import com.fasterxml.jackson.core.JsonParser
import com.fasterxml.jackson.databind.*
import com.fasterxml.jackson.databind.module.SimpleModule
import com.fasterxml.jackson.module.kotlin.jacksonObjectMapper
import kotlinx.serialization.ExperimentalSerializationApi
import kotlinx.serialization.json.Json
import kotlinx.serialization.json.JsonNamingStrategy
// Jackson
val ACCEPTABLE_FALSE = setOf("0", "false", "no", "off", "False", "None", "null")
val ACCEPTABLE_TRUE = setOf("1", "true", "yes", "on", "True")
val JSON_FUZZY_BOOLEAN = SimpleModule().addDeserializer(Boolean::class.java, object : JsonDeserializer<Boolean>() {
override fun deserialize(parser: JsonParser, context: DeserializationContext) = when(parser.text) {
in ACCEPTABLE_FALSE -> false
in ACCEPTABLE_TRUE -> true
else -> 400 - "Invalid boolean value ${parser.text}"
}
})
val JSON_DATETIME = SimpleModule().addDeserializer(java.time.LocalDateTime::class.java, object : JsonDeserializer<java.time.LocalDateTime>() {
override fun deserialize(parser: JsonParser, context: DeserializationContext) =
parser.text.asDateTime() ?: (400 - "Invalid date time value ${parser.text}")
})
val JACKSON = jacksonObjectMapper().apply {
setSerializationInclusion(JsonInclude.Include.NON_NULL)
setDefaultPropertyInclusion(JsonInclude.Include.NON_NULL)
findAndRegisterModules()
registerModule(JSON_FUZZY_BOOLEAN)
registerModule(JSON_DATETIME)
configure(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES, false)
setPropertyNamingStrategy(PropertyNamingStrategy.LOWER_CAMEL_CASE);
}
inline fun <reified T> ObjectMapper.readValue(str: Str) = readValue(str, T::class.java)
// TODO: https://stackoverflow.com/q/78197784/7346633
inline fun <reified T> Str.parseJackson() = if (contains("null")) {
val map = JACKSON.readValue<MutableMap<String, Any>>(this)
JACKSON.convertValue(map.recursiveNotNull(), T::class.java)
}
else JACKSON.readValue(this, T::class.java)
fun <T> T.toJson() = JACKSON.writeValueAsString(this)
// KotlinX Serialization
@OptIn(ExperimentalSerializationApi::class)
val JSON = Json {
ignoreUnknownKeys = true
isLenient = true
namingStrategy = JsonNamingStrategy.SnakeCase
explicitNulls = false
coerceInputValues = true
}

View File

@ -3,9 +3,11 @@ package icu.samnyan.aqua.net.games
import com.fasterxml.jackson.annotation.JsonIgnore
import com.fasterxml.jackson.annotation.JsonInclude
import com.fasterxml.jackson.annotation.JsonProperty
import com.fasterxml.jackson.databind.annotation.JsonSerialize
import ext.JACKSON
import ext.JavaSerializable
import icu.samnyan.aqua.sega.general.model.Card
import icu.samnyan.aqua.sega.util.jackson.AccessCodeSerializer
import jakarta.persistence.*
import kotlinx.serialization.Serializable
import org.springframework.data.domain.Page
@ -104,7 +106,6 @@ interface IGenericGamePlaylog {
val isAllPerfect: Boolean
}
@Serializable
@MappedSuperclass
open class BaseEntity(
@Id
@ -115,6 +116,15 @@ open class BaseEntity(
override fun toString() = JACKSON.writeValueAsString(this)
}
@MappedSuperclass
open class UserDataEntity : BaseEntity() {
@JsonSerialize(using = AccessCodeSerializer::class)
@JsonProperty(value = "accessCode", access = JsonProperty.Access.READ_ONLY)
@OneToOne
@JoinColumn(name = "aime_card_id", unique = true)
var card: Card? = null
}
@NoRepositoryBean
interface GenericUserDataRepo<T : IGenericUserData> : JpaRepository<T, Long> {
fun findByCard(card: Card): T?

View File

@ -3,8 +3,6 @@ package icu.samnyan.aqua.sega.general.model
import com.fasterxml.jackson.annotation.JsonIgnore
import icu.samnyan.aqua.net.db.AquaNetUser
import jakarta.persistence.*
import java.io.Serial
import java.io.Serializable
import java.time.LocalDateTime
/**
@ -43,12 +41,7 @@ class Card(
// Whether the card is a ghost card
@Column(name = "is_ghost")
var isGhost: Boolean = false,
): Serializable {
companion object {
@Serial
private val serialVersionUID = 1L
}
) {
@Suppress("unused") // Used by serialization
val isLinked get() = aquaUser != null
}

View File

@ -0,0 +1,30 @@
package icu.samnyan.aqua.sega.wacca.model.db
import jakarta.transaction.Transactional
import org.springframework.data.jpa.repository.JpaRepository
import org.springframework.data.repository.NoRepositoryBean
interface WcUserRepo : JpaRepository<WaccaUser, Long> {
fun findByCardExtId(extId: Long): WaccaUser?
}
@NoRepositoryBean
interface IWaccaUserLinked<T> : JpaRepository<T, Long> {
fun findByUser(user: WaccaUser): List<T>
fun findByUserCardExtId(userId: Long): List<T>
@Transactional
fun deleteByUser(user: WaccaUser)
}
interface WcUserOptionRepo : IWaccaUserLinked<WcUserOption>
interface WcUserBingoRepo : IWaccaUserLinked<WcUserBingo>
interface WcUserFriendRepo : IWaccaUserLinked<WcUserFriend>
interface WcUserFavoriteSongRepo : IWaccaUserLinked<WcUserFavoriteSong>
interface WcUserGateRepo : IWaccaUserLinked<WcUserGate>
interface WcUserItemRepo : IWaccaUserLinked<WcUserItem>
interface WcUserTicketRepo : IWaccaUserLinked<WcUserTicket>
interface WcUserSongUnlockRepo : IWaccaUserLinked<WcUserSongUnlock>
interface WcUserTrophyRepo : IWaccaUserLinked<WcUserTrophy>
interface WcUserBestScoreRepo : IWaccaUserLinked<WcUserScore>
interface WcUserPlayLogRepo : IWaccaUserLinked<WcUserPlayLog>
interface WcUserStageUpRepo : IWaccaUserLinked<WcUserStageUp>

View File

@ -0,0 +1,57 @@
package icu.samnyan.aqua.sega.wacca.model.db
import icu.samnyan.aqua.net.games.BaseEntity
import icu.samnyan.aqua.sega.general.model.Card
import jakarta.persistence.Column
import jakarta.persistence.Entity
import jakarta.persistence.JoinColumn
import jakarta.persistence.OneToOne
import jakarta.persistence.Table
/**
* General user information
*/
@Entity @Table(name = "wacca_user")
class WaccaUser : BaseEntity() {
@OneToOne
@JoinColumn(name = "aime_card_id", unique = true)
var card: Card? = null
@Column(length = 8)
var username = ""
var xp = 0
var wp = 0
var wpTotal = 0
var wpSpent = 0
var danType = 0
var danLevel = 0
var title0 = 0
var title1 = 0
var title2 = 0
var rating = 0
var vipExpireTime: String? = null
var alwaysVip = false
var loginCount = 0
var loginCountConsec = 0
var loginCountDays = 0
var loginCountDaysConsec = 0
var loginCountToday = 0
var playcountSingle = 0
var playcountMultiVs = 0
var playcountMultiCoop = 0
var playcountStageup = 0
var playcountTimeFree = 0
var friendView1 = 0
var friendView2 = 0
var friendView3 = 0
@Column(length = 50)
var lastGameVer = ""
var lastSongId = 0
var lastSongDifficulty = 0
var lastFolderOrder = 0
var lastFolderId = 0
var lastSongOrder = 0
var lastLoginDate: String? = null
var gateTutorialFlags: String? = null
}

View File

@ -0,0 +1,147 @@
package icu.samnyan.aqua.sega.wacca.model.db
import com.fasterxml.jackson.annotation.JsonIgnore
import icu.samnyan.aqua.net.games.BaseEntity
import jakarta.persistence.*
typealias UC = UniqueConstraint
/**
* Base entity for all wacca user-related entities
*/
@MappedSuperclass
open class WaccaUserEntity : BaseEntity() {
@JsonIgnore
@ManyToOne
@JoinColumn(name = "user_id")
open var user: WaccaUser = WaccaUser()
}
/**
* In-game option key-value storage
*/
@Entity @Table(name = "wacca_user_option", uniqueConstraints = [UC("", ["user_id", "opt_id"])])
class WcUserOption : WaccaUserEntity() {
var optId = 0
var value = 0
}
@Entity @Table(name = "wacca_user_bingo", uniqueConstraints = [UC("", ["user_id", "page_number"])])
class WcUserBingo : WaccaUserEntity() {
var pageNumber = 0
var pageProgress = ""
}
/**
* The user here is the sender of the friend request.
*/
@Entity @Table(name = "wacca_friend", uniqueConstraints = [UC("", ["user_id", "with"])])
class WcUserFriend : WaccaUserEntity() {
@ManyToOne @JoinColumn(name = "profile_reciever")
var with: WaccaUser = WaccaUser()
var isAccepted = false
}
@Entity @Table(name = "wacca_user_favorite_song", uniqueConstraints = [UC("", ["user_id", "song_id"])])
class WcUserFavoriteSong : WaccaUserEntity() {
// TODO: Make this into a list instead?
var songId = 0
}
@Entity @Table(name = "wacca_user_gate", uniqueConstraints = [UC("", ["user_id", "gate_id"])])
class WcUserGate : WaccaUserEntity() {
var gateId = 0
var page = 0
var progress = 0
var loops = 0
var missionFlag = 0
var totalPoints = 0
}
@Entity @Table(name = "wacca_user_item", uniqueConstraints = [UC("", ["user_id", "item_id", "type"])])
class WcUserItem : WaccaUserEntity() {
var itemId = 0
var type = 0
var acquireDate = ""
var useCount = 0
}
@Entity @Table(name = "wacca_user_ticket", uniqueConstraints = [UC("", ["user_id", "ticket_id"])])
class WcUserTicket : WaccaUserEntity() {
var ticketId = 0
var acquireDate = ""
var expireDate = ""
}
@Entity @Table(name = "wacca_user_song_unlock", uniqueConstraints = [UC("", ["user_id", "song_id"])])
class WcUserSongUnlock : WaccaUserEntity() {
var songId = 0
var highestDifficulty = 0
var acquireDate = ""
}
@Entity @Table(name = "wacca_user_trophy", uniqueConstraints = [UC("", ["user_id", "trophy_id", "season"])])
class WcUserTrophy : WaccaUserEntity() {
var trophyId = 0
var season = 0
var progress = 0
var badgeType = 0
}
@Entity @Table(name = "wacca_user_score", uniqueConstraints = [UC("", ["user_id", "song_id", "chart_id"])])
class WcUserScore : WaccaUserEntity() {
var songId = 0
var chartId = 0
var score = 0
var playCt = 0
var clearCt = 0
var misslessCt = 0
var fullcomboCt = 0
var allmarvCt = 0
var gradeDCt = 0
var gradeCCt = 0
var gradeBCt = 0
var gradeACt = 0
var gradeAACt = 0
var gradeAAACt = 0
var gradeSCt = 0
var gradeSSCt = 0
var gradeSSSCt = 0
var gradeMasterCt = 0
var gradeSpCt = 0
var gradeSspCt = 0
var gradeSsspCt = 0
var bestCombo = 0
var lowestMissCt = 0
var rating = 0
}
@Entity @Table(name = "wacca_user_playlog", uniqueConstraints = [UC("", ["user_id", "song_id", "chart_id", "date_scored"])])
class WcUserPlayLog : WaccaUserEntity() {
var songId = 0
var chartId = 0
var score = 0
var clear = 0
var grade = 0
var maxCombo = 0
var marvCt = 0
var greatCt = 0
var goodCt = 0
var missCt = 0
var fastCt = 0
var lateCt = 0
var season = 0
var dateScored = ""
}
@Entity @Table(name = "wacca_user_stageup", uniqueConstraints = [UC("", ["user_id", "stage_id"])])
class WcUserStageUp : WaccaUserEntity() {
var version = 0
var stageId = 0
var clearStatus = 0
var clearSongCt = 0
var song1Score = 0
var song2Score = 0
var song3Score = 0
var playCt = 0
}

View File

@ -0,0 +1,211 @@
CREATE TABLE wacca_user
(
id BIGINT AUTO_INCREMENT NOT NULL PRIMARY KEY,
aime_card_id BIGINT NOT NULL,
username VARCHAR(8) NOT NULL,
xp INT NOT NULL,
wp INT NOT NULL,
wp_total INT NOT NULL,
wp_spent INT NOT NULL,
dan_type INT NOT NULL,
dan_level INT NOT NULL,
title0 INT NOT NULL,
title1 INT NOT NULL,
title2 INT NOT NULL,
rating INT NOT NULL,
vip_expire_time VARCHAR(255) NULL,
always_vip BIT(1) NOT NULL,
login_count INT NOT NULL,
login_count_consec INT NOT NULL,
login_count_days INT NOT NULL,
login_count_days_consec INT NOT NULL,
login_count_today INT NOT NULL,
playcount_single INT NOT NULL,
playcount_multi_vs INT NOT NULL,
playcount_multi_coop INT NOT NULL,
playcount_stageup INT NOT NULL,
playcount_time_free INT NOT NULL,
friend_view1 INT NOT NULL,
friend_view2 INT NOT NULL,
friend_view3 INT NOT NULL,
last_game_ver VARCHAR(50) NULL,
last_song_id INT NOT NULL,
last_song_difficulty INT NOT NULL,
last_folder_order INT NOT NULL,
last_folder_id INT NOT NULL,
last_song_order INT NOT NULL,
last_login_date VARCHAR(255) NULL,
gate_tutorial_flags VARCHAR(255) NULL,
CONSTRAINT wacca_user_detail_unique UNIQUE (aime_card_id),
CONSTRAINT wacca_user_detail_fk FOREIGN KEY (aime_card_id) REFERENCES main.sega_card (id)
);
CREATE TABLE wacca_user_bingo
(
id BIGINT AUTO_INCREMENT NOT NULL PRIMARY KEY,
user_id BIGINT NOT NULL,
page_number INT NOT NULL,
page_progress VARCHAR(255) NULL,
CONSTRAINT wacca_user_bingo_unique UNIQUE (user_id, page_number),
CONSTRAINT fku_wacca_user_bingo FOREIGN KEY (user_id) REFERENCES wacca_user (id) ON DELETE CASCADE ON UPDATE CASCADE
);
CREATE TABLE wacca_user_favorite_song
(
id BIGINT AUTO_INCREMENT NOT NULL PRIMARY KEY,
user_id BIGINT NOT NULL,
song_id INT NOT NULL,
CONSTRAINT wacca_user_favorite_song_unique UNIQUE (user_id, song_id),
CONSTRAINT fku_wacca_user_favorite_song FOREIGN KEY (user_id) REFERENCES wacca_user (id) ON DELETE CASCADE ON UPDATE CASCADE
);
CREATE TABLE wacca_friend
(
id BIGINT AUTO_INCREMENT NOT NULL PRIMARY KEY,
user_id BIGINT NOT NULL,
`with` BIGINT NOT NULL,
is_accepted BIT(1) NOT NULL,
CONSTRAINT wacca_friend_unique UNIQUE (user_id, `with`),
CONSTRAINT fku_wacca_friend FOREIGN KEY (user_id) REFERENCES wacca_user (id) ON DELETE CASCADE ON UPDATE CASCADE,
CONSTRAINT fku_wacca_friend_2 FOREIGN KEY (`with`) REFERENCES wacca_user (id) ON DELETE CASCADE ON UPDATE CASCADE
);
CREATE TABLE wacca_user_gate
(
id BIGINT AUTO_INCREMENT NOT NULL PRIMARY KEY,
user_id BIGINT NULL,
gate_id INT NOT NULL,
page INT NOT NULL,
progress INT NOT NULL,
loops INT NOT NULL,
mission_flag INT NOT NULL,
total_points INT NOT NULL,
CONSTRAINT wacca_user_gate_unique UNIQUE (user_id, gate_id),
CONSTRAINT fku_wacca_user_gate FOREIGN KEY (user_id) REFERENCES wacca_user (id) ON DELETE CASCADE ON UPDATE CASCADE
);
CREATE TABLE wacca_user_item
(
id BIGINT AUTO_INCREMENT NOT NULL PRIMARY KEY,
user_id BIGINT NULL,
item_id INT NOT NULL,
type INT NOT NULL,
acquire_date VARCHAR(255) NULL,
use_count INT NOT NULL,
CONSTRAINT wacca_user_item_unique UNIQUE (user_id, item_id),
CONSTRAINT fku_wacca_user_item FOREIGN KEY (user_id) REFERENCES wacca_user (id) ON DELETE CASCADE ON UPDATE CASCADE
);
CREATE TABLE wacca_user_score
(
id BIGINT AUTO_INCREMENT NOT NULL PRIMARY KEY,
user_id BIGINT NULL,
song_id INT NOT NULL,
chart_id INT NOT NULL,
score INT NOT NULL,
play_ct INT NOT NULL,
clear_ct INT NOT NULL,
missless_ct INT NOT NULL,
fullcombo_ct INT NOT NULL,
allmarv_ct INT NOT NULL,
gradedct INT NOT NULL,
gradecct INT NOT NULL,
gradebct INT NOT NULL,
gradeact INT NOT NULL,
gradeaact INT NOT NULL,
gradeaaact INT NOT NULL,
gradesct INT NOT NULL,
gradessct INT NOT NULL,
gradesssct INT NOT NULL,
grade_master_ct INT NOT NULL,
grade_sp_ct INT NOT NULL,
grade_ssp_ct INT NOT NULL,
grade_sssp_ct INT NOT NULL,
best_combo INT NOT NULL,
lowest_miss_ct INT NOT NULL,
rating INT NOT NULL,
CONSTRAINT wacca_user_score_unique UNIQUE (user_id, song_id, chart_id),
CONSTRAINT fku_wacca_user_score FOREIGN KEY (user_id) REFERENCES wacca_user (id) ON DELETE CASCADE ON UPDATE CASCADE
);
CREATE TABLE wacca_user_playlog
(
id BIGINT AUTO_INCREMENT NOT NULL PRIMARY KEY,
user_id BIGINT NULL,
song_id INT NOT NULL,
chart_id INT NOT NULL,
score INT NOT NULL,
clear INT NOT NULL,
grade INT NOT NULL,
max_combo INT NOT NULL,
marv_ct INT NOT NULL,
great_ct INT NOT NULL,
good_ct INT NOT NULL,
miss_ct INT NOT NULL,
fast_ct INT NOT NULL,
late_ct INT NOT NULL,
season INT NOT NULL,
date_scored VARCHAR(255) NULL,
CONSTRAINT wacca_user_playlog_unique UNIQUE (user_id, song_id, chart_id),
CONSTRAINT fku_wacca_user_playlog FOREIGN KEY (user_id) REFERENCES wacca_user (id) ON DELETE CASCADE ON UPDATE CASCADE
);
CREATE TABLE wacca_user_stageup
(
id BIGINT AUTO_INCREMENT NOT NULL PRIMARY KEY,
user_id BIGINT NULL,
version INT NOT NULL,
stage_id INT NOT NULL,
clear_status INT NOT NULL,
clear_song_ct INT NOT NULL,
song1score INT NOT NULL,
song2score INT NOT NULL,
song3score INT NOT NULL,
play_ct INT NOT NULL,
CONSTRAINT wacca_user_stageup_unique UNIQUE (user_id, version, stage_id),
CONSTRAINT fku_wacca_user_stageup FOREIGN KEY (user_id) REFERENCES wacca_user (id) ON DELETE CASCADE ON UPDATE CASCADE
);
CREATE TABLE wacca_user_song_unlock
(
id BIGINT AUTO_INCREMENT NOT NULL PRIMARY KEY,
user_id BIGINT NULL,
song_id INT NOT NULL,
highest_difficulty INT NOT NULL,
acquire_date VARCHAR(255) NULL,
CONSTRAINT wacca_user_song_unlock_unique UNIQUE (user_id, song_id),
CONSTRAINT fku_wacca_user_song_unlock FOREIGN KEY (user_id) REFERENCES wacca_user (id) ON DELETE CASCADE ON UPDATE CASCADE
);
CREATE TABLE wacca_user_ticket
(
id BIGINT AUTO_INCREMENT NOT NULL PRIMARY KEY,
user_id BIGINT NULL,
ticket_id INT NOT NULL,
acquire_date VARCHAR(255) NULL,
expire_date VARCHAR(255) NULL,
CONSTRAINT wacca_user_ticket_unique UNIQUE (user_id, ticket_id),
CONSTRAINT fku_wacca_user_ticket FOREIGN KEY (user_id) REFERENCES wacca_user (id) ON DELETE CASCADE ON UPDATE CASCADE
);
CREATE TABLE wacca_user_trophy
(
id BIGINT AUTO_INCREMENT NOT NULL PRIMARY KEY,
user_id BIGINT NULL,
trophy_id INT NOT NULL,
season INT NOT NULL,
progress INT NOT NULL,
badge_type INT NOT NULL,
CONSTRAINT wacca_user_trophy_unique UNIQUE (user_id, trophy_id, season),
CONSTRAINT fku_wacca_user_trophy FOREIGN KEY (user_id) REFERENCES wacca_user (id) ON DELETE CASCADE ON UPDATE CASCADE
);
CREATE TABLE wacca_user_option
(
id BIGINT AUTO_INCREMENT NOT NULL PRIMARY KEY,
user_id BIGINT NULL,
opt_id INT NOT NULL,
value INT NOT NULL,
CONSTRAINT wacca_user_option_unique UNIQUE (user_id, opt_id),
CONSTRAINT fku_wacca_user_option FOREIGN KEY (user_id) REFERENCES wacca_user (id) ON DELETE CASCADE ON UPDATE CASCADE
);