Compare commits

..

No commits in common. "7ad8b7039b45d02098d39aad55115b0e4f7b6785" and "a4bf998c5b6a09741ee7d7ec9ffaf02097734027" have entirely different histories.

59 changed files with 1226 additions and 602 deletions

View File

@ -1 +1,6 @@
# mariadb
MYSQL_ROOT_PASSWORD=
MYSQL_PASSWORD=
# simplenotes
DB_PASSWORD=
JWT_SECRET= JWT_SECRET=

View File

@ -12,15 +12,18 @@ FROM alpine
RUN apk add --no-cache curl RUN apk add --no-cache curl
ENV APPLICATION_USER simplenotes
RUN adduser -D -g '' $APPLICATION_USER
RUN mkdir /app RUN mkdir /app
RUN mkdir /app/data RUN chown -R $APPLICATION_USER /app
USER $APPLICATION_USER
COPY --from=jdkbuilder /myjdk /myjdk COPY --from=jdkbuilder /myjdk /myjdk
COPY app/build/libs/app-with-dependencies*.jar /app/simplenotes.jar COPY app/build/libs/app-with-dependencies*.jar /app/simplenotes.jar
WORKDIR /app WORKDIR /app
VOLUME /app/data
ENV SERVER_HOST 0.0.0.0 ENV SERVER_HOST 0.0.0.0
CMD [ \ CMD [ \

View File

@ -4,11 +4,7 @@ import io.micronaut.context.ApplicationContext
import java.lang.Runtime.getRuntime import java.lang.Runtime.getRuntime
fun main() { fun main() {
val env = if (System.getenv("ENV") == "dev") "dev" else "prod" val ctx = ApplicationContext.run()
val ctx = ApplicationContext.builder()
.deduceEnvironment(false)
.environments(env)
.start()
ctx.createBean(Server::class.java) ctx.createBean(Server::class.java)
getRuntime().addShutdownHook(Thread { ctx.stop() }) getRuntime().addShutdownHook(Thread { ctx.stop() })
} }

View File

@ -0,0 +1,14 @@
package be.simplenotes.app.controllers
import be.simplenotes.domain.HealthCheckService
import org.http4k.core.Request
import org.http4k.core.Response
import org.http4k.core.Status.Companion.OK
import org.http4k.core.Status.Companion.SERVICE_UNAVAILABLE
import javax.inject.Singleton
@Singleton
class HealthCheckController(private val healthCheckService: HealthCheckService) {
fun healthCheck(@Suppress("UNUSED_PARAMETER") request: Request) =
if (healthCheckService.isOk()) Response(OK) else Response(SERVICE_UNAVAILABLE)
}

View File

@ -1,6 +1,7 @@
package be.simplenotes.app.routes package be.simplenotes.app.routes
import be.simplenotes.app.controllers.BaseController import be.simplenotes.app.controllers.BaseController
import be.simplenotes.app.controllers.HealthCheckController
import be.simplenotes.app.controllers.NoteController import be.simplenotes.app.controllers.NoteController
import be.simplenotes.app.controllers.UserController import be.simplenotes.app.controllers.UserController
import be.simplenotes.app.filters.ImmutableFilter import be.simplenotes.app.filters.ImmutableFilter
@ -18,6 +19,7 @@ import javax.inject.Singleton
@Singleton @Singleton
class BasicRoutes( class BasicRoutes(
private val healthCheckController: HealthCheckController,
private val baseCtrl: BaseController, private val baseCtrl: BaseController,
private val userCtrl: UserController, private val userCtrl: UserController,
private val noteCtrl: NoteController, private val noteCtrl: NoteController,
@ -50,6 +52,8 @@ class BasicRoutes(
"/notes/public/{uuid}" bind GET to noteCtrl::public, "/notes/public/{uuid}" bind GET to noteCtrl::public,
) )
), ),
"/health" bind GET to healthCheckController::healthCheck,
staticHandler staticHandler
) )
} }

View File

@ -16,10 +16,13 @@ object Libs {
object Drivers { object Drivers {
const val h2 = "com.h2database:h2:1.4.200" const val h2 = "com.h2database:h2:1.4.200"
const val mariadb = "org.mariadb.jdbc:mariadb-java-client:2.7.2"
} }
object Ktorm { object Ktorm {
const val core = "org.ktorm:ktorm-core:3.3.0" private const val version = "3.0.0"
const val core = "me.liuwj.ktorm:ktorm-core:$version"
const val mysql = "me.liuwj.ktorm:ktorm-support-mysql:$version"
} }
} }

View File

@ -1,3 +1,10 @@
db:
jdbc-url: jdbc:h2:./notes-db;
username: h2
password: ''
connection-timeout: 3000
maximum-pool-size: 10
jwt: jwt:
secret: 'PliLvfk7l4WF+cZJk66LR5Mpnh+ocbvJ2wfUCK2UCms=' secret: 'PliLvfk7l4WF+cZJk66LR5Mpnh+ocbvJ2wfUCK2UCms='
validity: 24 validity: 24
@ -6,5 +13,3 @@ jwt:
server: server:
host: localhost host: localhost
port: 8080 port: 8080
data-dir: ./data

32
config/src/Config.kt Normal file
View File

@ -0,0 +1,32 @@
package be.simplenotes.config
import io.micronaut.context.annotation.ConfigurationInject
import io.micronaut.context.annotation.ConfigurationProperties
import java.util.concurrent.TimeUnit
@ConfigurationProperties("db")
data class DataSourceConfig @ConfigurationInject constructor(
val jdbcUrl: String,
val username: String,
val password: String,
val maximumPoolSize: Int,
val connectionTimeout: Long,
) {
override fun toString() = "DataSourceConfig(jdbcUrl='$jdbcUrl', username='$username', password='***', " +
"maximumPoolSize=$maximumPoolSize, connectionTimeout=$connectionTimeout)"
}
@ConfigurationProperties("jwt")
data class JwtConfig @ConfigurationInject constructor(
val secret: String,
val validity: Long,
val timeUnit: TimeUnit,
) {
override fun toString() = "JwtConfig(secret='***', validity=$validity, timeUnit=$timeUnit)"
}
@ConfigurationProperties("server")
data class ServerConfig @ConfigurationInject constructor(
val host: String,
val port: Int,
)

View File

@ -1,58 +0,0 @@
package be.simplenotes.config
import io.micronaut.context.annotation.*
import java.nio.file.Path
import java.util.concurrent.TimeUnit
import javax.inject.Singleton
data class DataConfig(val dataDir: String)
data class DataSourceConfig(
val jdbcUrl: String,
val maximumPoolSize: Int,
val connectionTimeout: Long,
)
@ConfigurationProperties("jwt")
data class JwtConfig @ConfigurationInject constructor(
val secret: String,
val validity: Long,
val timeUnit: TimeUnit,
) {
override fun toString() = "JwtConfig(secret='***', validity=$validity, timeUnit=$timeUnit)"
}
@ConfigurationProperties("server")
data class ServerConfig @ConfigurationInject constructor(
val host: String,
val port: Int,
)
@Factory
class ConfigFactory {
@Singleton
@Requires(notEnv = ["test"])
fun datasourceConfig(dataConfig: DataConfig) = DataSourceConfig(
jdbcUrl = "jdbc:h2:" + Path.of(dataConfig.dataDir, "simplenotes").normalize().toAbsolutePath(),
maximumPoolSize = 10,
connectionTimeout = 1000
)
@Singleton
@Requires(env = ["test"])
fun testDatasourceConfig() = DataSourceConfig(
jdbcUrl = "jdbc:h2:mem:regular;DB_CLOSE_DELAY=-1",
maximumPoolSize = 2,
connectionTimeout = 1000
)
@Singleton
@Requires(notEnv = ["test"])
fun dataConfig(@Property(name = "data-dir") dataDir: String) = DataConfig(dataDir)
@Singleton
@Requires(env = ["test"])
fun testDataConfig() = DataConfig("/tmp")
}

View File

@ -1,19 +1,54 @@
version: '2.2' version: '2.2'
services: services:
simplenotes:
image: hubv/simplenotes db:
container_name: simplenotes image: mariadb:10.5.5
container_name: simplenotes-mariadb
env_file: env_file:
- .env - .env
environment: environment:
- PUID=1000 - PUID=1000
- PGID=1000 - PGID=1000
- TZ=Europe/Brussels - TZ=Europe/Brussels
- MYSQL_DATABASE=simplenotes
- MYSQL_USER=simplenotes
# .env:
# - MYSQL_ROOT_PASSWORD
# - MYSQL_PASSWORD
volumes:
- notes-db-volume:/var/lib/mysql
healthcheck:
test: "mysql --protocol=tcp -u simplenotes -p$MYSQL_PASSWORD -e 'show databases'"
interval: 5s
timeout: 1s
start_period: 2s
retries: 10
simplenotes:
image: hubv/simplenotes
container_name: simplenotes
env_file:
- .env
environment:
- TZ=Europe/Brussels
- DB_JDBC_URL=jdbc:mariadb://db:3306/simplenotes
- DB_USERNAME=simplenotes
# .env: # .env:
# - JWT_SECRET # - JWT_SECRET
# - DB_PASSWORD
ports: ports:
- 127.0.0.1:8080:8080 - 127.0.0.1:8080:8080
volumes: healthcheck:
- ./simplenotes-data:/app/data test: "curl --fail -s http://localhost:8080/health"
interval: 5s
timeout: 1s
start_period: 2s
retries: 3
depends_on:
db:
condition: service_healthy
volumes:
notes-db-volume:

View File

@ -0,0 +1,13 @@
package be.simplenotes.domain
import be.simplenotes.persistence.DbHealthCheck
import javax.inject.Singleton
interface HealthCheckService {
fun isOk(): Boolean
}
@Singleton
class HealthCheckServiceImpl(private val dbHealthCheck: DbHealthCheck) : HealthCheckService {
override fun isOk() = dbHealthCheck.isOk()
}

View File

@ -5,6 +5,7 @@ import be.simplenotes.domain.security.HtmlSanitizer
import be.simplenotes.domain.utils.parseSearchTerms import be.simplenotes.domain.utils.parseSearchTerms
import be.simplenotes.persistence.repositories.NoteRepository import be.simplenotes.persistence.repositories.NoteRepository
import be.simplenotes.persistence.repositories.UserRepository import be.simplenotes.persistence.repositories.UserRepository
import be.simplenotes.persistence.transactions.TransactionService
import be.simplenotes.search.NoteSearcher import be.simplenotes.search.NoteSearcher
import be.simplenotes.types.LoggedInUser import be.simplenotes.types.LoggedInUser
import be.simplenotes.types.Note import be.simplenotes.types.Note
@ -21,34 +22,31 @@ class NoteService(
private val noteRepository: NoteRepository, private val noteRepository: NoteRepository,
private val userRepository: UserRepository, private val userRepository: UserRepository,
private val searcher: NoteSearcher, private val searcher: NoteSearcher,
private val htmlSanitizer: HtmlSanitizer private val htmlSanitizer: HtmlSanitizer,
private val transaction: TransactionService,
) { ) {
fun create(user: LoggedInUser, markdownText: String) = either.eager<MarkdownParsingError, PersistedNote> { fun create(user: LoggedInUser, markdownText: String) = transaction.use {
markdownService.renderDocument(markdownText) either.eager<MarkdownParsingError, PersistedNote> {
.map { it.copy(html = htmlSanitizer.sanitize(user, it.html)) } markdownService.renderDocument(markdownText)
.map { Note(title = it.metadata.title, tags = it.metadata.tags, markdown = markdownText, html = it.html) } .map { it.copy(html = htmlSanitizer.sanitize(user, it.html)) }
.map { noteRepository.create(user.userId, it) } .map { Note(it.metadata, markdown = markdownText, html = it.html) }
.bind() .map { noteRepository.create(user.userId, it) }
.also { searcher.indexNote(user.userId, it) } .bind()
.also { searcher.indexNote(user.userId, it) }
}
} }
fun update(user: LoggedInUser, uuid: UUID, markdownText: String) = fun update(user: LoggedInUser, uuid: UUID, markdownText: String) = transaction.use {
either.eager<MarkdownParsingError, PersistedNote?> { either.eager<MarkdownParsingError, PersistedNote?> {
markdownService.renderDocument(markdownText) markdownService.renderDocument(markdownText)
.map { it.copy(html = htmlSanitizer.sanitize(user, it.html)) } .map { it.copy(html = htmlSanitizer.sanitize(user, it.html)) }
.map { .map { Note(it.metadata, markdown = markdownText, html = it.html) }
Note(
title = it.metadata.title,
tags = it.metadata.tags,
markdown = markdownText,
html = it.html
)
}
.map { noteRepository.update(user.userId, uuid, it) } .map { noteRepository.update(user.userId, uuid, it) }
.bind() .bind()
?.also { searcher.updateIndex(user.userId, it) } ?.also { searcher.updateIndex(user.userId, it) }
} }
}
fun paginatedNotes( fun paginatedNotes(
userId: Int, userId: Int,
@ -66,22 +64,22 @@ class NoteService(
fun find(userId: Int, uuid: UUID) = noteRepository.find(userId, uuid) fun find(userId: Int, uuid: UUID) = noteRepository.find(userId, uuid)
fun trash(userId: Int, uuid: UUID): Boolean { fun trash(userId: Int, uuid: UUID): Boolean = transaction.use {
val res = noteRepository.delete(userId, uuid, permanent = false) val res = noteRepository.delete(userId, uuid, permanent = false)
if (res) searcher.deleteIndex(userId, uuid) if (res) searcher.deleteIndex(userId, uuid)
return res res
} }
fun restore(userId: Int, uuid: UUID): Boolean { fun restore(userId: Int, uuid: UUID): Boolean = transaction.use {
val res = noteRepository.restore(userId, uuid) val res = noteRepository.restore(userId, uuid)
if (res) find(userId, uuid)?.let { note -> searcher.indexNote(userId, note) } if (res) find(userId, uuid)?.let { note -> searcher.indexNote(userId, note) }
return res res
} }
fun delete(userId: Int, uuid: UUID): Boolean { fun delete(userId: Int, uuid: UUID): Boolean = transaction.use {
val res = noteRepository.delete(userId, uuid, permanent = true) val res = noteRepository.delete(userId, uuid, permanent = true)
if (res) searcher.deleteIndex(userId, uuid) if (res) searcher.deleteIndex(userId, uuid)
return res res
} }
fun countDeleted(userId: Int) = noteRepository.count(userId, deleted = true) fun countDeleted(userId: Int) = noteRepository.count(userId, deleted = true)
@ -101,8 +99,8 @@ class NoteService(
@PreDestroy @PreDestroy
fun dropAllIndexes() = searcher.dropAll() fun dropAllIndexes() = searcher.dropAll()
fun makePublic(userId: Int, uuid: UUID) = noteRepository.makePublic(userId, uuid) fun makePublic(userId: Int, uuid: UUID) = transaction.use { noteRepository.makePublic(userId, uuid) }
fun makePrivate(userId: Int, uuid: UUID) = noteRepository.makePrivate(userId, uuid) fun makePrivate(userId: Int, uuid: UUID) = transaction.use { noteRepository.makePrivate(userId, uuid) }
fun findPublic(uuid: UUID) = noteRepository.findPublic(uuid) fun findPublic(uuid: UUID) = noteRepository.findPublic(uuid)
} }

View File

@ -9,6 +9,7 @@ import be.simplenotes.domain.security.PasswordHash
import be.simplenotes.domain.security.SimpleJwt import be.simplenotes.domain.security.SimpleJwt
import be.simplenotes.domain.validation.UserValidations import be.simplenotes.domain.validation.UserValidations
import be.simplenotes.persistence.repositories.UserRepository import be.simplenotes.persistence.repositories.UserRepository
import be.simplenotes.persistence.transactions.TransactionService
import be.simplenotes.search.NoteSearcher import be.simplenotes.search.NoteSearcher
import be.simplenotes.types.LoggedInUser import be.simplenotes.types.LoggedInUser
import be.simplenotes.types.PersistedUser import be.simplenotes.types.PersistedUser
@ -28,13 +29,16 @@ internal class UserServiceImpl(
private val passwordHash: PasswordHash, private val passwordHash: PasswordHash,
private val jwt: SimpleJwt<LoggedInUser>, private val jwt: SimpleJwt<LoggedInUser>,
private val searcher: NoteSearcher, private val searcher: NoteSearcher,
private val transactionService: TransactionService,
) : UserService { ) : UserService {
override fun register(form: RegisterForm) = UserValidations.validateRegister(form) override fun register(form: RegisterForm) = transactionService.use {
.filterOrElse({ !userRepository.exists(it.username) }, { RegisterError.UserExists }) UserValidations.validateRegister(form)
.map { it.copy(password = passwordHash.crypt(it.password)) } .filterOrElse({ !userRepository.exists(it.username) }, { RegisterError.UserExists })
.map { userRepository.create(it) } .map { it.copy(password = passwordHash.crypt(it.password)) }
.leftIfNull { RegisterError.UserExists } .map { userRepository.create(it) }
.leftIfNull { RegisterError.UserExists }
}
override fun login(form: LoginForm) = either.eager<LoginError, Token> { override fun login(form: LoginForm) = either.eager<LoginError, Token> {
UserValidations.validateLogin(form) UserValidations.validateLogin(form)
@ -46,16 +50,18 @@ internal class UserServiceImpl(
.bind() .bind()
} }
override fun delete(form: DeleteForm) = either.eager<DeleteError, Unit> { override fun delete(form: DeleteForm) = transactionService.use {
val user = !UserValidations.validateDelete(form) either.eager<DeleteError, Unit> {
val persistedUser = !userRepository.find(user.username).rightIfNotNull { DeleteError.Unregistered } val user = !UserValidations.validateDelete(form)
!Either.conditionally( val persistedUser = !userRepository.find(user.username).rightIfNotNull { DeleteError.Unregistered }
passwordHash.verify(user.password, persistedUser.password), !Either.conditionally(
{ DeleteError.WrongPassword }, passwordHash.verify(user.password, persistedUser.password),
{ } { DeleteError.WrongPassword },
) { }
!Either.conditionally(userRepository.delete(persistedUser.id), { DeleteError.Unregistered }, { }) )
searcher.dropIndex(persistedUser.id) !Either.conditionally(userRepository.delete(persistedUser.id), { DeleteError.Unregistered }, { })
searcher.dropIndex(persistedUser.id)
}
} }
} }

View File

@ -9,6 +9,7 @@ import be.simplenotes.domain.security.UserJwtMapper
import be.simplenotes.domain.testutils.isLeftOfType import be.simplenotes.domain.testutils.isLeftOfType
import be.simplenotes.domain.testutils.isRight import be.simplenotes.domain.testutils.isRight
import be.simplenotes.persistence.repositories.UserRepository import be.simplenotes.persistence.repositories.UserRepository
import be.simplenotes.persistence.transactions.TransactionService
import be.simplenotes.types.PersistedUser import be.simplenotes.types.PersistedUser
import com.natpryce.hamkrest.assertion.assertThat import com.natpryce.hamkrest.assertion.assertThat
import com.natpryce.hamkrest.equalTo import com.natpryce.hamkrest.equalTo
@ -22,12 +23,16 @@ internal class UserServiceTest {
val passwordHash = BcryptPasswordHash(test = true) val passwordHash = BcryptPasswordHash(test = true)
val jwtConfig = JwtConfig("a secret", 1, TimeUnit.HOURS) val jwtConfig = JwtConfig("a secret", 1, TimeUnit.HOURS)
val simpleJwt = SimpleJwt(jwtConfig, UserJwtMapper()) val simpleJwt = SimpleJwt(jwtConfig, UserJwtMapper())
val noopTransactionService = object : TransactionService {
override fun <T> use(block: () -> T) = block()
}
val userService = UserServiceImpl( val userService = UserServiceImpl(
userRepository = userRepository, userRepository = userRepository,
passwordHash = passwordHash, passwordHash = passwordHash,
jwt = simpleJwt, jwt = simpleJwt,
searcher = mockk(), searcher = mockk(),
transactionService = noopTransactionService
) )
@BeforeEach @BeforeEach

View File

@ -12,10 +12,12 @@ dependencies {
implementation(project(":types")) implementation(project(":types"))
implementation(project(":config")) implementation(project(":config"))
implementation(Libs.Database.Drivers.mariadb)
implementation(Libs.Database.Drivers.h2) implementation(Libs.Database.Drivers.h2)
implementation(Libs.Database.flyway) implementation(Libs.Database.flyway)
implementation(Libs.Database.hikariCP) implementation(Libs.Database.hikariCP)
implementation(Libs.Database.Ktorm.core) implementation(Libs.Database.Ktorm.core)
runtimeOnly(Libs.Database.Ktorm.mysql)
implementation(Libs.Slf4J.api) implementation(Libs.Slf4J.api)
runtimeOnly(Libs.Slf4J.logback) runtimeOnly(Libs.Slf4J.logback)

View File

@ -1,5 +0,0 @@
CREATE VIEW NotesWithTags AS
SELECT n.*, coalesce(ARRAY_AGG(t.NAME), ARRAY[]) as tags
FROM Notes n
LEFT JOIN Tags t ON n.uuid = t.note_uuid
GROUP BY n.uuid

View File

@ -0,0 +1,36 @@
create table Users
(
id int auto_increment primary key,
username varchar(50) not null,
password varchar(255) not null,
constraint username unique (username)
) character set 'utf8mb4'
collate 'utf8mb4_general_ci';
create table Notes
(
uuid binary(16) not null primary key,
title varchar(50) not null,
markdown mediumtext not null,
html mediumtext not null,
user_id int not null,
updated_at datetime null,
constraint Notes_fk_user foreign key (user_id) references Users (id) on delete cascade
) character set 'utf8mb4'
collate 'utf8mb4_general_ci';
create index user_id on Notes (user_id);
create table Tags
(
id int auto_increment primary key,
name varchar(50) not null,
note_uuid binary(16) not null,
constraint Tags_fk_note foreign key (note_uuid) references Notes (uuid) on delete cascade
) character set 'utf8mb4'
collate 'utf8mb4_general_ci';
create index note_uuid on Tags (note_uuid);

View File

@ -9,7 +9,7 @@ create table Users
create table Notes create table Notes
( (
uuid uuid not null primary key, uuid binary(16) not null primary key,
title varchar(50) not null, title varchar(50) not null,
markdown mediumtext not null, markdown mediumtext not null,
html mediumtext not null, html mediumtext not null,
@ -17,6 +17,7 @@ create table Notes
updated_at datetime null, updated_at datetime null,
constraint Notes_fk_user foreign key (user_id) references Users (id) on delete cascade constraint Notes_fk_user foreign key (user_id) references Users (id) on delete cascade
); );
create index user_id on Notes (user_id); create index user_id on Notes (user_id);
@ -25,7 +26,7 @@ create table Tags
( (
id int auto_increment primary key, id int auto_increment primary key,
name varchar(50) not null, name varchar(50) not null,
note_uuid uuid not null, note_uuid binary(16) not null,
constraint Tags_fk_note foreign key (note_uuid) references Notes (uuid) on delete cascade constraint Tags_fk_note foreign key (note_uuid) references Notes (uuid) on delete cascade
); );

View File

@ -0,0 +1,2 @@
alter table Notes
add column deleted bool not null default false

View File

@ -0,0 +1,2 @@
alter table Notes
add column public bool not null default false

View File

@ -1,55 +0,0 @@
package be.simplenotes.persistence
import org.ktorm.database.Database
import org.ktorm.entity.Entity
import org.ktorm.entity.sequenceOf
import java.time.LocalDateTime
import java.util.*
internal val Database.users get() = sequenceOf(Users, withReferences = false)
internal val Database.notes get() = sequenceOf(Notes, withReferences = false)
internal val Database.tags get() = sequenceOf(Tags, withReferences = false)
internal val Database.noteWithTags get() = sequenceOf(NotesWithTags, withReferences = false)
internal interface UserEntity : Entity<UserEntity> {
companion object : Entity.Factory<UserEntity>()
var id: Int
var username: String
var password: String
}
internal interface NoteEntity : Entity<NoteEntity> {
companion object : Entity.Factory<NoteEntity>()
var uuid: UUID
var title: String
var markdown: String
var html: String
var updatedAt: LocalDateTime
var deleted: Boolean
var public: Boolean
var user: UserEntity
}
internal interface TagEntity : Entity<TagEntity> {
companion object : Entity.Factory<TagEntity>()
val id: Int
var name: String
var note: NoteEntity
}
internal interface NoteWithTagsEntity : Entity<NoteWithTagsEntity> {
companion object : Entity.Factory<NoteWithTagsEntity>()
var uuid: UUID
var title: String
var markdown: String
var html: String
var updatedAt: LocalDateTime
var deleted: Boolean
var public: Boolean
var user: UserEntity
val tags: List<String>
}

View File

@ -0,0 +1,31 @@
package be.simplenotes.persistence
import be.simplenotes.config.DataSourceConfig
import be.simplenotes.persistence.utils.DbType
import be.simplenotes.persistence.utils.type
import me.liuwj.ktorm.database.Database
import me.liuwj.ktorm.database.asIterable
import me.liuwj.ktorm.database.use
import java.sql.SQLException
import javax.inject.Singleton
interface DbHealthCheck {
fun isOk(): Boolean
}
@Singleton
internal class DbHealthCheckImpl(
private val db: Database,
private val dataSourceConfig: DataSourceConfig,
) : DbHealthCheck {
override fun isOk() = if (dataSourceConfig.type() == DbType.H2) true
else try {
db.useConnection { connection ->
connection.prepareStatement("""SHOW DATABASES""").use {
it.executeQuery().asIterable().map { it.getString(1) }
}
}.any { it in dataSourceConfig.jdbcUrl }
} catch (e: SQLException) {
false
}
}

View File

@ -1,5 +1,8 @@
package be.simplenotes.persistence package be.simplenotes.persistence
import be.simplenotes.config.DataSourceConfig
import be.simplenotes.persistence.utils.DbType
import be.simplenotes.persistence.utils.type
import org.flywaydb.core.Flyway import org.flywaydb.core.Flyway
import javax.inject.Singleton import javax.inject.Singleton
import javax.sql.DataSource import javax.sql.DataSource
@ -9,11 +12,20 @@ interface DbMigrations {
} }
@Singleton @Singleton
internal class DbMigrationsImpl(private val dataSource: DataSource) : DbMigrations { internal class DbMigrationsImpl(
private val dataSource: DataSource,
private val dataSourceConfig: DataSourceConfig,
) : DbMigrations {
override fun migrate() { override fun migrate() {
val migrationDir = when (dataSourceConfig.type()) {
DbType.H2 -> "db/migration/other"
DbType.MariaDb -> "db/migration/mariadb"
}
Flyway.configure() Flyway.configure()
.dataSource(dataSource) .dataSource(dataSource)
.locations("db/migration") .locations(migrationDir)
.load() .load()
.migrate() .migrate()
} }

View File

@ -1,13 +1,11 @@
package be.simplenotes.persistence package be.simplenotes.persistence
import be.simplenotes.config.DataSourceConfig import be.simplenotes.config.DataSourceConfig
import be.simplenotes.persistence.extensions.CustomSqlFormatter
import com.zaxxer.hikari.HikariConfig import com.zaxxer.hikari.HikariConfig
import com.zaxxer.hikari.HikariDataSource import com.zaxxer.hikari.HikariDataSource
import io.micronaut.context.annotation.Bean import io.micronaut.context.annotation.Bean
import io.micronaut.context.annotation.Factory import io.micronaut.context.annotation.Factory
import org.ktorm.database.Database import me.liuwj.ktorm.database.Database
import org.ktorm.database.SqlDialect
import javax.inject.Singleton import javax.inject.Singleton
import javax.sql.DataSource import javax.sql.DataSource
@ -17,10 +15,7 @@ class PersistenceModule {
@Singleton @Singleton
internal fun database(migrations: DbMigrations, dataSource: DataSource): Database { internal fun database(migrations: DbMigrations, dataSource: DataSource): Database {
migrations.migrate() migrations.migrate()
return Database.connect(dataSource, dialect = object : SqlDialect { return Database.connect(dataSource)
override fun createSqlFormatter(database: Database, beautifySql: Boolean, indentSize: Int) =
CustomSqlFormatter(database, beautifySql, indentSize)
})
} }
@Singleton @Singleton
@ -28,12 +23,15 @@ class PersistenceModule {
internal fun dataSource(conf: DataSourceConfig): HikariDataSource { internal fun dataSource(conf: DataSourceConfig): HikariDataSource {
val hikariConfig = HikariConfig().also { val hikariConfig = HikariConfig().also {
it.jdbcUrl = conf.jdbcUrl it.jdbcUrl = conf.jdbcUrl
it.driverClassName = "org.h2.Driver" it.driverClassName = when {
it.username = "" conf.jdbcUrl.startsWith("jdbc:mariadb") -> "org.mariadb.jdbc.Driver"
it.password = "" conf.jdbcUrl.startsWith("jdbc:h2") -> "org.h2.Driver"
else -> error("Unsupported database")
}
it.username = conf.username
it.password = conf.password
it.maximumPoolSize = conf.maximumPoolSize it.maximumPoolSize = conf.maximumPoolSize
it.connectionTimeout = conf.connectionTimeout it.connectionTimeout = conf.connectionTimeout
it.dataSourceProperties["CASE_INSENSITIVE_IDENTIFIERS"] = "TRUE"
} }
return HikariDataSource(hikariConfig) return HikariDataSource(hikariConfig)
} }

View File

@ -1,42 +0,0 @@
package be.simplenotes.persistence
import be.simplenotes.persistence.extensions.varcharArray
import org.ktorm.schema.*
internal object Users : Table<UserEntity>("Users") {
val id = int("id").primaryKey().bindTo { it.id }
val username = varchar("username").bindTo { it.username }
val password = varchar("password").bindTo { it.password }
}
internal object Notes : Table<NoteEntity>("Notes") {
val uuid = uuid("uuid").primaryKey().bindTo { it.uuid }
val title = varchar("title").bindTo { it.title }
val markdown = text("markdown").bindTo { it.markdown }
val html = text("html").bindTo { it.html }
val userId = int("user_id").references(Users) { it.user }
val updatedAt = datetime("updated_at").bindTo { it.updatedAt }
val deleted = boolean("deleted").bindTo { it.deleted }
val public = boolean("public").bindTo { it.public }
val user get() = userId.referenceTable as Users
}
internal object Tags : Table<TagEntity>("Tags") {
val id = int("id").primaryKey().bindTo { it.id }
val name = varchar("name").bindTo { it.name }
val noteUuid = uuid("note_uuid").references(Notes) { it.note }
val note: Notes get() = noteUuid.referenceTable as Notes
}
internal object NotesWithTags : Table<NoteWithTagsEntity>("NotesWithTags") {
val uuid = uuid("uuid").primaryKey().bindTo { it.uuid }
val title = varchar("title").bindTo { it.title }
val markdown = text("markdown").bindTo { it.markdown }
val html = text("html").bindTo { it.html }
val userId = int("user_id").references(Users) { it.user }
val updatedAt = datetime("updated_at").bindTo { it.updatedAt }
val deleted = boolean("deleted").bindTo { it.deleted }
val public = boolean("public").bindTo { it.public }
val tags = varcharArray("tags").bindTo { it.tags }
val user get() = userId.referenceTable as Users
}

View File

@ -1,34 +0,0 @@
package be.simplenotes.persistence.converters
import be.simplenotes.persistence.NoteEntity
import be.simplenotes.persistence.NoteWithTagsEntity
import be.simplenotes.persistence.UserEntity
import be.simplenotes.types.*
import org.mapstruct.Mapper
import org.mapstruct.Mapping
import org.mapstruct.ReportingPolicy
import java.time.LocalDateTime
import java.util.*
@Mapper(unmappedTargetPolicy = ReportingPolicy.IGNORE, componentModel = "jsr330")
internal interface UserConverter {
fun toPersistedUser(userEntity: UserEntity?): PersistedUser?
}
@Mapper(unmappedTargetPolicy = ReportingPolicy.IGNORE, componentModel = "jsr330")
internal abstract class NoteConverter {
@Mapping(target = "trash", source = "deleted")
abstract fun toExportedNote(entity: NoteWithTagsEntity?): ExportedNote?
abstract fun toPersistedNote(entity: NoteWithTagsEntity?): PersistedNote?
abstract fun toPersistedNoteMetadata(entity: NoteWithTagsEntity?): PersistedNoteMetadata?
fun toEntity(note: Note, uuid: UUID, userId: Int, updatedAt: LocalDateTime) = NoteEntity {
this.title = note.title
this.markdown = note.markdown
this.html = note.html
this.uuid = uuid
this.user.id = userId
this.updatedAt = updatedAt
}
}

View File

@ -0,0 +1,85 @@
package be.simplenotes.persistence.converters
import be.simplenotes.persistence.notes.NoteEntity
import be.simplenotes.types.*
import me.liuwj.ktorm.entity.Entity
import org.mapstruct.Mapper
import org.mapstruct.Mapping
import org.mapstruct.Mappings
import org.mapstruct.ReportingPolicy
import java.time.LocalDateTime
import java.util.*
import javax.inject.Singleton
@Mapper(
uses = [NoteEntityFactory::class, UserEntityFactory::class],
unmappedTargetPolicy = ReportingPolicy.IGNORE,
componentModel = "jsr330"
)
internal abstract class NoteConverter {
fun toNote(entity: NoteEntity, tags: Tags) =
Note(NoteMetadata(title = entity.title, tags = tags), entity.markdown, entity.html)
@Mappings(
Mapping(target = ".", source = "entity"),
Mapping(target = "tags", source = "tags"),
)
abstract fun toNoteMetadata(entity: NoteEntity, tags: Tags): NoteMetadata
@Mappings(
Mapping(target = ".", source = "entity"),
Mapping(target = "tags", source = "tags"),
)
abstract fun toPersistedNoteMetadata(entity: NoteEntity, tags: Tags): PersistedNoteMetadata
fun toPersistedNote(entity: NoteEntity, tags: Tags) = PersistedNote(
NoteMetadata(title = entity.title, tags = tags),
entity.markdown,
entity.html,
entity.updatedAt,
entity.uuid,
entity.public
)
@Mappings(
Mapping(target = ".", source = "entity"),
Mapping(target = "trash", source = "entity.deleted"),
Mapping(target = "tags", source = "tags"),
)
abstract fun toExportedNote(entity: NoteEntity, tags: Tags): ExportedNote
fun toEntity(note: Note, uuid: UUID, userId: Int, updatedAt: LocalDateTime) = NoteEntity {
this.title = note.meta.title
this.markdown = note.markdown
this.html = note.html
this.uuid = uuid
this.deleted = false
this.public = false
this.user.id = userId
this.updatedAt = updatedAt
}
@Mappings(
Mapping(target = ".", source = "note"),
Mapping(target = "updatedAt", source = "updatedAt"),
Mapping(target = "uuid", source = "uuid"),
Mapping(target = "public", constant = "false"),
)
abstract fun toPersistedNote(note: Note, updatedAt: LocalDateTime, uuid: UUID): PersistedNote
abstract fun toEntity(persistedNoteMetadata: PersistedNoteMetadata): NoteEntity
abstract fun toEntity(noteMetadata: NoteMetadata): NoteEntity
@Mapping(target = "title", source = "meta.title")
abstract fun toEntity(persistedNote: PersistedNote): NoteEntity
@Mapping(target = "deleted", source = "trash")
abstract fun toEntity(exportedNote: ExportedNote): NoteEntity
}
typealias Tags = List<String>
@Singleton
internal class NoteEntityFactory : Entity.Factory<NoteEntity>()

View File

@ -0,0 +1,24 @@
package be.simplenotes.persistence.converters
import be.simplenotes.persistence.users.UserEntity
import be.simplenotes.types.PersistedUser
import be.simplenotes.types.User
import me.liuwj.ktorm.entity.Entity
import org.mapstruct.Mapper
import org.mapstruct.ReportingPolicy
import javax.inject.Singleton
@Mapper(
uses = [UserEntityFactory::class],
unmappedTargetPolicy = ReportingPolicy.IGNORE,
componentModel = "jsr330"
)
internal interface UserConverter {
fun toUser(userEntity: UserEntity): User
fun toPersistedUser(userEntity: UserEntity): PersistedUser
fun toEntity(user: User): UserEntity
fun toEntity(user: PersistedUser): UserEntity
}
@Singleton
internal class UserEntityFactory : Entity.Factory<UserEntity>()

View File

@ -1,77 +1,26 @@
package be.simplenotes.persistence.extensions package be.simplenotes.persistence.extensions
import org.ktorm.database.Database import me.liuwj.ktorm.schema.BaseTable
import org.ktorm.expression.* import me.liuwj.ktorm.schema.SqlType
import org.ktorm.schema.* import java.nio.ByteBuffer
import java.sql.PreparedStatement import java.sql.PreparedStatement
import java.sql.ResultSet import java.sql.ResultSet
import java.sql.Types import java.sql.Types
import java.util.*
internal class VarcharArraySqlType : SqlType<List<String>>(Types.ARRAY, typeName = "varchar[]") { internal class UuidBinarySqlType : SqlType<UUID>(Types.BINARY, typeName = "uuidBinary") {
override fun doGetResult(rs: ResultSet, index: Int): List<String>? { override fun doGetResult(rs: ResultSet, index: Int): UUID? {
return when (val array = rs.getObject(index)) { val value = rs.getBytes(index) ?: return null
null -> null return ByteBuffer.wrap(value).let { b -> UUID(b.long, b.long) }
is Array<*> -> array.map { it.toString() }
else -> error("")
}
} }
override fun doSetParameter(ps: PreparedStatement, index: Int, parameter: List<String>) { override fun doSetParameter(ps: PreparedStatement, index: Int, parameter: UUID) {
throw UnsupportedOperationException() val bytes = ByteBuffer.allocate(16)
.putLong(parameter.mostSignificantBits)
.putLong(parameter.leastSignificantBits)
.array()
ps.setBytes(index, bytes)
} }
} }
internal fun <E : Any> BaseTable<E>.varcharArray(name: String) = registerColumn(name, VarcharArraySqlType()) internal fun <E : Any> BaseTable<E>.uuidBinary(name: String) = registerColumn(name, UuidBinarySqlType())
data class ArrayContainsExpression(
val left: ScalarExpression<*>,
val right: ScalarExpression<*>,
override val sqlType: SqlType<Boolean> = BooleanSqlType,
override val isLeafNode: Boolean = false
) : ScalarExpression<Boolean>() {
override val extraProperties: Map<String, Any> get() = emptyMap()
}
infix fun ColumnDeclaring<*>.arrayContains(arguments: String): ArrayContainsExpression {
return ArrayContainsExpression(asExpression(), ArgumentExpression(arguments, VarcharSqlType))
}
class CustomSqlFormatter(database: Database, beautifySql: Boolean, indentSize: Int) :
SqlFormatter(database, beautifySql, indentSize) {
override fun visitUnknown(expr: SqlExpression): SqlExpression {
return if (expr is ArrayContainsExpression) {
write("ARRAY_CONTAINS(")
if (expr.left.removeBrackets) {
visit(expr.left)
} else {
write("(")
visit(expr.left)
removeLastBlank()
write(") ")
}
write(", ")
if (expr.right.removeBrackets) {
visit(expr.right)
} else {
write("(")
visit(expr.right)
removeLastBlank()
write(") ")
}
write(")")
return expr
} else super.visitUnknown(expr)
}
override fun writePagination(expr: QueryExpression) {
newLine(Indentation.SAME)
writeKeyword("limit ?, ? ")
_parameters += ArgumentExpression(expr.offset ?: 0, IntSqlType)
_parameters += ArgumentExpression(expr.limit ?: Int.MAX_VALUE, IntSqlType)
}
}

View File

@ -0,0 +1,219 @@
package be.simplenotes.persistence.notes
import be.simplenotes.persistence.converters.NoteConverter
import be.simplenotes.persistence.repositories.NoteRepository
import be.simplenotes.types.ExportedNote
import be.simplenotes.types.Note
import be.simplenotes.types.PersistedNote
import be.simplenotes.types.PersistedNoteMetadata
import me.liuwj.ktorm.database.Database
import me.liuwj.ktorm.dsl.*
import me.liuwj.ktorm.entity.*
import java.time.LocalDateTime
import java.util.*
import javax.inject.Singleton
import kotlin.collections.HashMap
@Singleton
internal class NoteRepositoryImpl(
private val db: Database,
private val converter: NoteConverter,
) : NoteRepository {
@Throws(IllegalArgumentException::class)
override fun findAll(
userId: Int,
limit: Int,
offset: Int,
tag: String?,
deleted: Boolean,
): List<PersistedNoteMetadata> {
require(limit > 0) { "limit should be positive" }
require(offset >= 0) { "offset should not be negative" }
val uuids1: List<UUID>? = if (tag != null) {
db.from(Tags)
.leftJoin(Notes, on = Notes.uuid eq Tags.noteUuid)
.select(Notes.uuid)
.where { (Notes.userId eq userId) and (Tags.name eq tag) and (Notes.deleted eq deleted) }
.map { it[Notes.uuid]!! }
} else null
var query = db.notes
.filterColumns { listOf(it.uuid, it.title, it.updatedAt) }
.filter { (it.userId eq userId) and (it.deleted eq deleted) }
if (uuids1 != null) query = query.filter { it.uuid inList uuids1 }
val notes = query
.sortedByDescending { it.updatedAt }
.take(limit)
.drop(offset)
.toList()
val tagsByUuid = notes.tagsByUuid()
return notes.map { note ->
val tags = tagsByUuid[note.uuid] ?: emptyList()
converter.toPersistedNoteMetadata(note, tags)
}
}
override fun exists(userId: Int, uuid: UUID): Boolean {
return db.notes.any { (it.userId eq userId) and (it.uuid eq uuid) and (it.deleted eq false) }
}
override fun create(userId: Int, note: Note): PersistedNote {
val uuid = UUID.randomUUID()
val entity = converter.toEntity(note, uuid, userId, LocalDateTime.now())
db.notes.add(entity)
db.batchInsert(Tags) {
note.meta.tags.forEach { tagName ->
item {
it.noteUuid to uuid
it.name to tagName
}
}
}
return converter.toPersistedNote(entity, note.meta.tags)
}
override fun find(userId: Int, uuid: UUID): PersistedNote? {
val note = db.notes
.filterColumns { it.columns - it.userId }
.filter { it.uuid eq uuid }
.find { (it.userId eq userId) and (it.deleted eq false) }
?: return null
val tags = db.from(Tags)
.select(Tags.name)
.where { Tags.noteUuid eq uuid }
.map { it[Tags.name]!! }
return converter.toPersistedNote(note, tags)
}
override fun update(userId: Int, uuid: UUID, note: Note): PersistedNote? {
val now = LocalDateTime.now()
val count = db.update(Notes) {
it.title to note.meta.title
it.markdown to note.markdown
it.html to note.html
it.updatedAt to now
where { (it.uuid eq uuid) and (it.userId eq userId) and (it.deleted eq false) }
}
if (count == 0) return null
// delete all tags
db.delete(Tags) {
it.noteUuid eq uuid
}
// put new ones
note.meta.tags.forEach { tagName ->
db.insert(Tags) {
it.name to tagName
it.noteUuid to uuid
}
}
return converter.toPersistedNote(note, now, uuid)
}
override fun delete(userId: Int, uuid: UUID, permanent: Boolean): Boolean {
return if (!permanent) {
db.update(Notes) {
it.deleted to true
it.updatedAt to LocalDateTime.now()
where { it.userId eq userId and (it.uuid eq uuid) }
} == 1
} else
db.delete(Notes) { it.uuid eq uuid and (it.userId eq userId) } == 1
}
override fun restore(userId: Int, uuid: UUID): Boolean {
return db.update(Notes) {
it.deleted to false
where { (it.userId eq userId) and (it.uuid eq uuid) }
} == 1
}
override fun getTags(userId: Int): List<String> =
db.from(Tags)
.leftJoin(Notes, on = Notes.uuid eq Tags.noteUuid)
.selectDistinct(Tags.name)
.where { (Notes.userId eq userId) and (Notes.deleted eq false) }
.map { it[Tags.name]!! }
override fun count(userId: Int, tag: String?, deleted: Boolean): Int {
return if (tag == null) db.notes.count { (it.userId eq userId) and (Notes.deleted eq deleted) }
else db.sequenceOf(Tags).count {
(it.name eq tag) and (it.note.userId eq userId) and (it.note.deleted eq deleted)
}
}
override fun export(userId: Int): List<ExportedNote> {
val notes = db.notes
.filterColumns { it.columns - it.userId }
.filter { it.userId eq userId }
.sortedByDescending { it.updatedAt }
.toList()
val tagsByUuid = notes.tagsByUuid()
return notes.map { note ->
val tags = tagsByUuid[note.uuid] ?: emptyList()
converter.toExportedNote(note, tags)
}
}
override fun findAllDetails(userId: Int): List<PersistedNote> {
val notes = db.notes
.filterColumns { it.columns - it.deleted }
.filter { (it.userId eq userId) and (it.deleted eq false) }
.toList()
val tagsByUuid = notes.tagsByUuid()
return notes.map { note ->
val tags = tagsByUuid[note.uuid] ?: emptyList()
converter.toPersistedNote(note, tags)
}
}
override fun makePublic(userId: Int, uuid: UUID) = db.update(Notes) {
it.public to true
where { Notes.userId eq userId and (Notes.uuid eq uuid) and (it.deleted eq false) }
} == 1
override fun makePrivate(userId: Int, uuid: UUID) = db.update(Notes) {
it.public to false
where { Notes.userId eq userId and (Notes.uuid eq uuid) and (it.deleted eq false) }
} == 1
override fun findPublic(uuid: UUID): PersistedNote? {
val note = db.notes
.filterColumns { it.columns - it.userId }
.filter { it.uuid eq uuid }
.filter { it.public eq true }
.find { it.deleted eq false }
?: return null
val tags = db.from(Tags)
.select(Tags.name)
.where { Tags.noteUuid eq uuid }
.map { it[Tags.name]!! }
return converter.toPersistedNote(note, tags)
}
private fun List<NoteEntity>.tagsByUuid(): Map<UUID, List<String>> {
return if (isEmpty()) emptyMap()
else db.tags
.filterColumns { listOf(it.noteUuid, it.name) }
.filter { it.noteUuid inList map { note -> note.uuid } }
.groupByTo(HashMap(), { it.note.uuid }, { it.name })
}
}

View File

@ -0,0 +1,45 @@
@file:Suppress("unused")
package be.simplenotes.persistence.notes
import be.simplenotes.persistence.extensions.uuidBinary
import be.simplenotes.persistence.users.UserEntity
import be.simplenotes.persistence.users.Users
import me.liuwj.ktorm.database.Database
import me.liuwj.ktorm.entity.Entity
import me.liuwj.ktorm.entity.sequenceOf
import me.liuwj.ktorm.schema.*
import java.time.LocalDateTime
import java.util.*
internal open class Notes(alias: String?) : Table<NoteEntity>("Notes", alias) {
companion object : Notes(null)
override fun aliased(alias: String) = Notes(alias)
val uuid = uuidBinary("uuid").primaryKey().bindTo { it.uuid }
val title = varchar("title").bindTo { it.title }
val markdown = text("markdown").bindTo { it.markdown }
val html = text("html").bindTo { it.html }
val userId = int("user_id").references(Users) { it.user }
val updatedAt = datetime("updated_at").bindTo { it.updatedAt }
val deleted = boolean("deleted").bindTo { it.deleted }
val public = boolean("public").bindTo { it.public }
val user get() = userId.referenceTable as Users
}
internal val Database.notes get() = this.sequenceOf(Notes, withReferences = false)
internal interface NoteEntity : Entity<NoteEntity> {
companion object : Entity.Factory<NoteEntity>()
var uuid: UUID
var title: String
var markdown: String
var html: String
var updatedAt: LocalDateTime
var deleted: Boolean
var public: Boolean
var user: UserEntity
}

View File

@ -0,0 +1,30 @@
package be.simplenotes.persistence.notes
import be.simplenotes.persistence.extensions.uuidBinary
import me.liuwj.ktorm.database.Database
import me.liuwj.ktorm.entity.Entity
import me.liuwj.ktorm.entity.sequenceOf
import me.liuwj.ktorm.schema.Table
import me.liuwj.ktorm.schema.int
import me.liuwj.ktorm.schema.varchar
internal open class Tags(alias: String?) : Table<TagEntity>("Tags", alias) {
companion object : Tags(null)
override fun aliased(alias: String) = Tags(alias)
val id = int("id").primaryKey().bindTo { it.id }
val name = varchar("name").bindTo { it.name }
val noteUuid = uuidBinary("note_uuid").references(Notes) { it.note }
val note get() = noteUuid.referenceTable as Notes
}
internal val Database.tags get() = this.sequenceOf(Tags, withReferences = false)
internal interface TagEntity : Entity<TagEntity> {
companion object : Entity.Factory<TagEntity>()
val id: Int
var name: String
var note: NoteEntity
}

View File

@ -1,144 +0,0 @@
package be.simplenotes.persistence.repositories
import be.simplenotes.persistence.*
import be.simplenotes.persistence.converters.NoteConverter
import be.simplenotes.persistence.extensions.arrayContains
import be.simplenotes.types.Note
import be.simplenotes.types.PersistedNote
import org.ktorm.database.Database
import org.ktorm.dsl.*
import org.ktorm.entity.*
import java.time.LocalDateTime
import java.util.*
import javax.inject.Singleton
@Singleton
internal class NoteRepositoryImpl(
private val db: Database,
private val converter: NoteConverter,
) : NoteRepository {
override fun findAll(
userId: Int,
limit: Int,
offset: Int,
tag: String?,
deleted: Boolean,
) = db.noteWithTags
.filterColumns { listOf(it.uuid, it.title, it.updatedAt, it.tags) }
.filter { (it.userId eq userId) and (it.deleted eq deleted) }
.runIf(tag != null) { filter { it.tags.arrayContains(tag!!) } }
.sortedByDescending { it.updatedAt }
.take(limit)
.drop(offset)
.map { converter.toPersistedNoteMetadata(it)!! }
override fun exists(userId: Int, uuid: UUID) =
db.notes.any { (it.userId eq userId) and (it.uuid eq uuid) and (it.deleted eq false) }
override fun create(userId: Int, note: Note): PersistedNote = db.useTransaction {
val uuid = UUID.randomUUID()
val entity = converter.toEntity(note, uuid, userId, LocalDateTime.now())
db.notes.add(entity)
db.batchInsert(Tags) {
note.tags.forEach { tagName ->
item {
set(it.noteUuid, uuid)
set(it.name, tagName)
}
}
}
return find(userId, uuid) ?: error("Note not found")
}
override fun find(userId: Int, uuid: UUID) = db.noteWithTags
.filterColumns { NotesWithTags.columns - NotesWithTags.userId - NotesWithTags.deleted }
.find { (it.uuid eq uuid) and (it.userId eq userId) and (it.deleted eq false) }
.let { converter.toPersistedNote(it) }
override fun update(userId: Int, uuid: UUID, note: Note): PersistedNote? = db.useTransaction {
val now = LocalDateTime.now()
val count = db.update(Notes) {
set(it.title, note.title)
set(it.markdown, note.markdown)
set(it.html, note.html)
set(it.updatedAt, now)
where { (it.uuid eq uuid) and (it.userId eq userId) and (it.deleted eq false) }
}
if (count == 0) return null
// delete all tags
db.delete(Tags) {
it.noteUuid eq uuid
}
// put new ones
note.tags.forEach { tagName ->
db.insert(Tags) {
set(it.name, tagName)
set(it.noteUuid, uuid)
}
}
return find(userId, uuid)
}
override fun delete(userId: Int, uuid: UUID, permanent: Boolean) = if (!permanent) {
db.update(Notes) {
set(it.deleted, true)
set(it.updatedAt, LocalDateTime.now())
where { it.userId eq userId and (it.uuid eq uuid) }
} == 1
} else db.delete(Notes) { it.uuid eq uuid and (it.userId eq userId) } == 1
override fun restore(userId: Int, uuid: UUID) = db.update(Notes) {
set(it.deleted, false)
where { (it.userId eq userId) and (it.uuid eq uuid) }
} == 1
override fun getTags(userId: Int): List<String> = db.from(Tags)
.leftJoin(Notes, on = Notes.uuid eq Tags.noteUuid)
.selectDistinct(Tags.name)
.where { (Notes.userId eq userId) and (Notes.deleted eq false) }
.map { it.getString(1)!! }
override fun count(userId: Int, tag: String?, deleted: Boolean) = db.from(Notes)
.runIf(tag != null) { leftJoin(Tags, on = Tags.noteUuid eq Notes.uuid) }
.select(count())
.whereWithConditions {
it += Notes.userId eq userId
it += Notes.deleted eq deleted
tag?.let { tag -> it += Tags.name eq tag }
}
.map { it.getInt(1) }
.first()
override fun export(userId: Int) = db.noteWithTags
.filterColumns { it.columns - it.userId - it.public }
.filter { it.userId eq userId }
.sortedByDescending { it.updatedAt }
.map { converter.toExportedNote(it)!! }
override fun findAllDetails(userId: Int) = db.noteWithTags
.filterColumns { it.columns - it.userId - it.deleted }
.filter { (it.userId eq userId) and (it.deleted eq false) }
.map { converter.toPersistedNote(it)!! }
override fun makePublic(userId: Int, uuid: UUID) = db.update(Notes) {
set(it.public, true)
where { Notes.userId eq userId and (Notes.uuid eq uuid) and (it.deleted eq false) }
} == 1
override fun makePrivate(userId: Int, uuid: UUID) = db.update(Notes) {
set(it.public, false)
where { Notes.userId eq userId and (Notes.uuid eq uuid) and (it.deleted eq false) }
} == 1
override fun findPublic(uuid: UUID) = db.noteWithTags
.filterColumns { it.columns - it.userId - it.deleted }
.find { (it.uuid eq uuid) and (it.public eq true) and (it.deleted eq false) }
.let { converter.toPersistedNote(it) }
}
private inline fun <T> T.runIf(condition: Boolean, block: T.() -> T) = run { if (condition) block(this) else this }

View File

@ -1,40 +0,0 @@
package be.simplenotes.persistence.repositories
import be.simplenotes.persistence.Users
import be.simplenotes.persistence.converters.UserConverter
import be.simplenotes.persistence.users
import be.simplenotes.types.PersistedUser
import be.simplenotes.types.User
import org.ktorm.database.Database
import org.ktorm.dsl.*
import org.ktorm.entity.any
import org.ktorm.entity.find
import java.sql.SQLIntegrityConstraintViolationException
import javax.inject.Singleton
@Singleton
internal class UserRepositoryImpl(
private val db: Database,
private val converter: UserConverter,
) : UserRepository {
override fun create(user: User) = try {
val id = db.insertAndGenerateKey(Users) {
set(it.username, user.username)
set(it.password, user.password)
} as Int
PersistedUser(user.username, user.password, id)
} catch (e: SQLIntegrityConstraintViolationException) {
null
}
override fun find(username: String) = db.users.find { it.username eq username }
.let { converter.toPersistedUser(it) }
override fun find(id: Int) = db.users.find { it.id eq id }
.let { converter.toPersistedUser(it) }
override fun exists(username: String) = db.users.any { it.username eq username }
override fun exists(id: Int) = db.users.any { it.id eq id }
override fun delete(id: Int) = db.delete(Users) { it.id eq id } == 1
override fun findAll() = db.from(Users).select(Users.id).map { it.getInt(1) }
}

View File

@ -0,0 +1,9 @@
package be.simplenotes.persistence.transactions
import me.liuwj.ktorm.database.Database
import javax.inject.Singleton
@Singleton
internal class KtormTransactionService(private val database: Database) : TransactionService {
override fun <T> use(block: () -> T) = database.useTransaction { block() }
}

View File

@ -0,0 +1,5 @@
package be.simplenotes.persistence.transactions
interface TransactionService {
fun <T> use(block: () -> T): T
}

View File

@ -0,0 +1,42 @@
package be.simplenotes.persistence.users
import be.simplenotes.persistence.converters.UserConverter
import be.simplenotes.persistence.repositories.UserRepository
import be.simplenotes.types.PersistedUser
import be.simplenotes.types.User
import me.liuwj.ktorm.database.Database
import me.liuwj.ktorm.dsl.*
import me.liuwj.ktorm.entity.any
import me.liuwj.ktorm.entity.find
import java.sql.SQLIntegrityConstraintViolationException
import javax.inject.Singleton
@Singleton
internal class UserRepositoryImpl(
private val db: Database,
private val converter: UserConverter,
) : UserRepository {
override fun create(user: User): PersistedUser? {
return try {
val id = db.insertAndGenerateKey(Users) {
it.username to user.username
it.password to user.password
} as Int
PersistedUser(user.username, user.password, id)
} catch (e: SQLIntegrityConstraintViolationException) {
null
}
}
override fun find(username: String) = db.users.find { it.username eq username }
?.let { converter.toPersistedUser(it) }
override fun find(id: Int) = db.users.find { it.id eq id }?.let {
converter.toPersistedUser(it)
}
override fun exists(username: String) = db.users.any { it.username eq username }
override fun exists(id: Int) = db.users.any { it.id eq id }
override fun delete(id: Int) = db.delete(Users) { it.id eq id } == 1
override fun findAll() = db.from(Users).select(Users.id).map { it[Users.id]!! }
}

View File

@ -0,0 +1,28 @@
package be.simplenotes.persistence.users
import me.liuwj.ktorm.database.Database
import me.liuwj.ktorm.entity.Entity
import me.liuwj.ktorm.entity.sequenceOf
import me.liuwj.ktorm.schema.Table
import me.liuwj.ktorm.schema.int
import me.liuwj.ktorm.schema.varchar
internal open class Users(alias: String?) : Table<UserEntity>("Users", alias) {
companion object : Users(null)
override fun aliased(alias: String) = Users(alias)
val id = int("id").primaryKey().bindTo { it.id }
val username = varchar("username").bindTo { it.username }
val password = varchar("password").bindTo { it.password }
}
internal interface UserEntity : Entity<UserEntity> {
companion object : Entity.Factory<UserEntity>()
var id: Int
var username: String
var password: String
}
internal val Database.users get() = this.sequenceOf(Users, withReferences = false)

View File

@ -0,0 +1,8 @@
package be.simplenotes.persistence.utils
import be.simplenotes.config.DataSourceConfig
enum class DbType { H2, MariaDb }
fun DataSourceConfig.type(): DbType = if (jdbcUrl.contains("mariadb")) DbType.MariaDb
else DbType.H2

View File

@ -0,0 +1,22 @@
package be.simplenotes.persistence
import be.simplenotes.config.DataSourceConfig
import org.testcontainers.containers.MariaDBContainer
class KMariadbContainer : MariaDBContainer<KMariadbContainer>("mariadb:10.5.5")
fun h2dataSourceConfig() = DataSourceConfig(
jdbcUrl = "jdbc:h2:mem:regular;DB_CLOSE_DELAY=-1;",
username = "h2",
password = "",
maximumPoolSize = 2,
connectionTimeout = 3000
)
fun mariadbDataSourceConfig(jdbcUrl: String) = DataSourceConfig(
jdbcUrl = jdbcUrl,
username = "test",
password = "test",
maximumPoolSize = 2,
connectionTimeout = 3000
)

View File

@ -0,0 +1,37 @@
package be.simplenotes.persistence
import be.simplenotes.config.DataSourceConfig
import org.assertj.core.api.Assertions.assertThat
import org.junit.jupiter.api.Tag
import org.junit.jupiter.api.Test
import org.junit.jupiter.api.parallel.ResourceLock
@ResourceLock("h2")
class H2DbHealthCheckImplTest : DbTest() {
override fun dataSourceConfig() = h2dataSourceConfig()
@Test
fun healthCheck() {
assertThat(beanContext.getBean<DbHealthCheck>().isOk()).isTrue
}
}
@Tag("slow")
@ResourceLock("mariadb")
class MariaDbHealthCheckImplTest : DbTest() {
lateinit var mariaDB: KMariadbContainer
override fun dataSourceConfig(): DataSourceConfig {
mariaDB = KMariadbContainer()
mariaDB.start()
return mariadbDataSourceConfig(mariaDB.jdbcUrl)
}
@Test
fun healthCheck() {
val healthCheck = beanContext.getBean<DbHealthCheck>()
assertThat(healthCheck.isOk()).isTrue
mariaDB.stop()
assertThat(healthCheck.isOk()).isFalse
}
}

View File

@ -1,20 +1,29 @@
package be.simplenotes.persistence package be.simplenotes.persistence
import io.micronaut.context.ApplicationContext import be.simplenotes.config.DataSourceConfig
import io.micronaut.context.BeanContext import io.micronaut.context.BeanContext
import org.flywaydb.core.Flyway import org.flywaydb.core.Flyway
import org.junit.jupiter.api.AfterAll import org.junit.jupiter.api.AfterAll
import org.junit.jupiter.api.BeforeAll
import org.junit.jupiter.api.BeforeEach import org.junit.jupiter.api.BeforeEach
import org.junit.jupiter.api.parallel.ResourceLock
import javax.sql.DataSource import javax.sql.DataSource
@ResourceLock("h2")
abstract class DbTest { abstract class DbTest {
val beanContext = ApplicationContext.build().deduceEnvironment(false).environments("test").start() abstract fun dataSourceConfig(): DataSourceConfig
val beanContext = BeanContext
.build()
inline fun <reified T> BeanContext.getBean(): T = getBean(T::class.java) inline fun <reified T> BeanContext.getBean(): T = getBean(T::class.java)
@BeforeAll
fun setComponent() {
beanContext
.registerSingleton(dataSourceConfig())
.start()
}
@BeforeEach @BeforeEach
fun beforeEach() { fun beforeEach() {
val migration = beanContext.getBean<DbMigrations>() val migration = beanContext.getBean<DbMigrations>()

View File

@ -0,0 +1,164 @@
package be.simplenotes.persistence.converters
import be.simplenotes.persistence.notes.NoteEntity
import be.simplenotes.types.*
import io.micronaut.context.BeanContext
import org.assertj.core.api.Assertions.assertThat
import org.junit.jupiter.api.DisplayName
import org.junit.jupiter.api.Nested
import org.junit.jupiter.api.Test
import java.time.LocalDateTime
import java.util.*
internal class NoteConverterTest {
private val ctx = BeanContext.run()
val converter = ctx.getBean(NoteConverter::class.java)
@Nested
@DisplayName("Entity -> Models")
inner class EntityToModels {
@Test
fun `convert NoteEntity to Note`() {
val entity = NoteEntity {
title = "title"
markdown = "md"
html = "html"
}
val tags = listOf("a", "b")
val note = converter.toNote(entity, tags)
val expectedNote = Note(
NoteMetadata(
title = "title",
tags = tags,
),
markdown = "md",
html = "html"
)
assertThat(note).isEqualTo(expectedNote)
}
@Test
fun `convert NoteEntity to ExportedNote`() {
val entity = NoteEntity {
title = "title"
markdown = "md"
html = "html"
updatedAt = LocalDateTime.MIN
deleted = true
}
val tags = listOf("a", "b")
val note = converter.toExportedNote(entity, tags)
val expectedNote = ExportedNote(
title = "title",
tags = tags,
markdown = "md",
html = "html",
updatedAt = LocalDateTime.MIN,
trash = true
)
assertThat(note).isEqualTo(expectedNote)
}
@Test
fun `convert NoteEntity to PersistedNoteMetadata`() {
val entity = NoteEntity {
uuid = UUID.randomUUID()
title = "title"
markdown = "md"
html = "html"
updatedAt = LocalDateTime.MIN
deleted = true
}
val tags = listOf("a", "b")
val note = converter.toPersistedNoteMetadata(entity, tags)
val expectedNote = PersistedNoteMetadata(
title = "title",
tags = tags,
updatedAt = LocalDateTime.MIN,
uuid = entity.uuid
)
assertThat(note).isEqualTo(expectedNote)
}
}
@Nested
@DisplayName("Models -> Entity")
inner class ModelsToEntity {
@Test
fun `convert Note to NoteEntity`() {
val note = Note(NoteMetadata("title", emptyList()), "md", "html")
val entity = converter.toEntity(note, UUID.randomUUID(), 2, LocalDateTime.MIN)
assertThat(entity)
.hasFieldOrPropertyWithValue("markdown", "md")
.hasFieldOrPropertyWithValue("html", "html")
.hasFieldOrPropertyWithValue("title", "title")
.hasFieldOrPropertyWithValue("uuid", entity.uuid)
.hasFieldOrPropertyWithValue("updatedAt", LocalDateTime.MIN)
}
@Test
fun `convert PersistedNoteMetadata to NoteEntity`() {
val persistedNoteMetadata =
PersistedNoteMetadata("title", emptyList(), LocalDateTime.MIN, UUID.randomUUID())
val entity = converter.toEntity(persistedNoteMetadata)
assertThat(entity)
.hasFieldOrPropertyWithValue("uuid", persistedNoteMetadata.uuid)
.hasFieldOrPropertyWithValue("updatedAt", persistedNoteMetadata.updatedAt)
.hasFieldOrPropertyWithValue("title", "title")
}
@Test
fun `convert NoteMetadata to NoteEntity`() {
val noteMetadata = NoteMetadata("title", emptyList())
val entity = converter.toEntity(noteMetadata)
assertThat(entity)
.hasFieldOrPropertyWithValue("title", "title")
}
@Test
fun `convert PersistedNote to NoteEntity`() {
val persistedNote = PersistedNote(
NoteMetadata("title", emptyList()),
markdown = "md",
html = "html",
updatedAt = LocalDateTime.MIN,
uuid = UUID.randomUUID(),
public = true
)
val entity = converter.toEntity(persistedNote)
assertThat(entity)
.hasFieldOrPropertyWithValue("title", "title")
.hasFieldOrPropertyWithValue("markdown", "md")
.hasFieldOrPropertyWithValue("uuid", persistedNote.uuid)
.hasFieldOrPropertyWithValue("updatedAt", persistedNote.updatedAt)
.hasFieldOrPropertyWithValue("deleted", false)
.hasFieldOrPropertyWithValue("public", true)
}
@Test
fun `convert ExportedNote to NoteEntity`() {
val exportedNote = ExportedNote(
"title",
emptyList(),
markdown = "md",
html = "html",
updatedAt = LocalDateTime.MIN,
trash = true
)
val entity = converter.toEntity(exportedNote)
assertThat(entity)
.hasFieldOrPropertyWithValue("title", "title")
.hasFieldOrPropertyWithValue("markdown", "md")
.hasFieldOrPropertyWithValue("updatedAt", exportedNote.updatedAt)
.hasFieldOrPropertyWithValue("deleted", true)
}
}
}

View File

@ -0,0 +1,48 @@
package be.simplenotes.persistence.converters
import be.simplenotes.persistence.users.UserEntity
import be.simplenotes.types.PersistedUser
import be.simplenotes.types.User
import io.micronaut.context.BeanContext
import org.assertj.core.api.Assertions.assertThat
import org.junit.jupiter.api.Test
internal class UserConverterTest {
private val ctx = BeanContext.run()
private val converter = ctx.getBean(UserConverter::class.java)
@Test
fun `convert UserEntity to User`() {
val entity = UserEntity {
username = "test"
password = "test2"
}.apply {
this["id"] = 2
}
val user = converter.toUser(entity)
assertThat(user).isEqualTo(User("test", "test2"))
}
@Test
fun `convert UserEntity to PersistedUser`() {
val entity = UserEntity {
username = "test"
password = "test2"
}.apply {
this["id"] = 2
}
val user = converter.toPersistedUser(entity)
assertThat(user).isEqualTo(PersistedUser("test", "test2", 2))
}
@Test
fun `convert User to UserEntity`() {
val user = User("test", "test2")
val entity = converter.toEntity(user)
assertThat(entity)
.hasFieldOrPropertyWithValue("username", "test")
.hasFieldOrPropertyWithValue("password", "test2")
}
}

View File

@ -1,29 +1,24 @@
package be.simplenotes.persistence package be.simplenotes.persistence.notes
import be.simplenotes.persistence.notes.* import be.simplenotes.persistence.DbTest
import be.simplenotes.persistence.converters.NoteConverter
import be.simplenotes.persistence.repositories.NoteRepository import be.simplenotes.persistence.repositories.NoteRepository
import be.simplenotes.persistence.repositories.UserRepository import be.simplenotes.persistence.repositories.UserRepository
import be.simplenotes.persistence.users.createFakeUser import be.simplenotes.persistence.users.createFakeUser
import be.simplenotes.types.ExportedNote import be.simplenotes.types.ExportedNote
import be.simplenotes.types.PersistedNote
import be.simplenotes.types.PersistedUser import be.simplenotes.types.PersistedUser
import me.liuwj.ktorm.database.Database
import me.liuwj.ktorm.dsl.eq
import me.liuwj.ktorm.entity.filter
import me.liuwj.ktorm.entity.find
import me.liuwj.ktorm.entity.mapColumns
import me.liuwj.ktorm.entity.toList
import org.assertj.core.api.Assertions.assertThat import org.assertj.core.api.Assertions.assertThat
import org.assertj.core.api.Assertions.assertThatThrownBy import org.assertj.core.api.Assertions.assertThatThrownBy
import org.junit.jupiter.api.BeforeEach import org.junit.jupiter.api.*
import org.junit.jupiter.api.DisplayName
import org.junit.jupiter.api.Nested
import org.junit.jupiter.api.Test
import org.junit.jupiter.api.parallel.ResourceLock
import org.ktorm.database.Database
import org.ktorm.dsl.eq
import org.ktorm.entity.filter
import org.ktorm.entity.find
import org.ktorm.entity.mapColumns
import org.ktorm.entity.toList
import java.sql.SQLIntegrityConstraintViolationException import java.sql.SQLIntegrityConstraintViolationException
internal abstract class BaseNoteRepositoryImplTest : DbTest() {
internal class NoteRepositoryImplTest : DbTest() {
private lateinit var noteRepo: NoteRepository private lateinit var noteRepo: NoteRepository
private lateinit var userRepo: UserRepository private lateinit var userRepo: UserRepository
@ -99,6 +94,27 @@ internal class NoteRepositoryImplTest : DbTest() {
assertThat(noteRepo.findAll(1000)).isEmpty() assertThat(noteRepo.findAll(1000)).isEmpty()
} }
// TODO: datetime -> timestamp migration
@Disabled("Not working with mariadb, inserts are too fast so the updated_at is the same")
@Test
fun pagination() {
(50 downTo 1).forEach { i ->
noteRepo.insertFakeNote(user1, "$i")
}
assertThat(noteRepo.findAll(user1.id, limit = 20, offset = 0).onEach { println(it) })
.hasSize(20)
.allMatch { it.title.toInt() in 1..20 }
assertThat(noteRepo.findAll(user1.id, limit = 20, offset = 20))
.hasSize(20)
.allMatch { it.title.toInt() in 21..40 }
assertThat(noteRepo.findAll(user1.id, limit = 20, offset = 40))
.hasSize(10)
.allMatch { it.title.toInt() in 41..50 }
}
@Test @Test
fun `find all notes with tag`() { fun `find all notes with tag`() {
with(noteRepo) { with(noteRepo) {
@ -129,18 +145,12 @@ internal class NoteRepositoryImplTest : DbTest() {
fun `find an existing note`() { fun `find an existing note`() {
val fakeNote = noteRepo.insertFakeNote(user1) val fakeNote = noteRepo.insertFakeNote(user1)
val note = db.notes.find { Notes.title eq fakeNote.title }!! val converter = beanContext.getBean(NoteConverter::class.java)
val note = db.notes.find { it.title eq fakeNote.meta.title }!!
.let { entity -> .let { entity ->
val tags = db.tags.filter { be.simplenotes.persistence.Tags.noteUuid eq entity.uuid }.mapColumns { be.simplenotes.persistence.Tags.name } as List<String> val tags = db.tags.filter { it.noteUuid eq entity.uuid }.mapColumns { it.name } as List<String>
PersistedNote( converter.toPersistedNote(entity, tags)
uuid = entity.uuid,
title = entity.title,
tags = tags,
markdown = entity.markdown,
html = entity.html,
updatedAt = entity.updatedAt,
public = entity.public,
)
} }
assertThat(noteRepo.find(user1.id, note.uuid)).isEqualTo(note) assertThat(noteRepo.find(user1.id, note.uuid)).isEqualTo(note)
@ -189,11 +199,11 @@ internal class NoteRepositoryImplTest : DbTest() {
val notes1 = noteRepo.insertFakeNotes(user1, count = 3) val notes1 = noteRepo.insertFakeNotes(user1, count = 3)
val notes2 = noteRepo.insertFakeNotes(user2, count = 1) val notes2 = noteRepo.insertFakeNotes(user2, count = 1)
val user1Tags = notes1.flatMap { it.tags }.toSet() val user1Tags = notes1.flatMap { it.meta.tags }.toSet()
assertThat(noteRepo.getTags(user1.id)) assertThat(noteRepo.getTags(user1.id))
.containsExactlyInAnyOrderElementsOf(user1Tags) .containsExactlyInAnyOrderElementsOf(user1Tags)
val user2Tags = notes2.flatMap { it.tags }.toSet() val user2Tags = notes2.flatMap { it.meta.tags }.toSet()
assertThat(noteRepo.getTags(user2.id)) assertThat(noteRepo.getTags(user2.id))
.containsExactlyInAnyOrderElementsOf(user2Tags) .containsExactlyInAnyOrderElementsOf(user2Tags)
@ -219,7 +229,9 @@ internal class NoteRepositoryImplTest : DbTest() {
.isEqualTo(newNote1) .isEqualTo(newNote1)
val note2 = noteRepo.insertFakeNote(user1) val note2 = noteRepo.insertFakeNote(user1)
val newNote2 = fakeNote().copy(tags = tagGenerator().take(3).toList()) val newNote2 = fakeNote().let {
it.copy(meta = it.meta.copy(tags = tagGenerator().take(3).toList()))
}
assertThat(noteRepo.update(user1.id, note2.uuid, newNote2)) assertThat(noteRepo.update(user1.id, note2.uuid, newNote2))
.isNotNull .isNotNull
@ -241,7 +253,7 @@ internal class NoteRepositoryImplTest : DbTest() {
.isTrue .isTrue
val isDeleted = db.notes val isDeleted = db.notes
.find { Notes.uuid eq note1.uuid } .find { it.uuid eq note1.uuid }
?.deleted ?.deleted
assertThat(isDeleted).`as`("Check that Notes.deleted is true").isTrue assertThat(isDeleted).`as`("Check that Notes.deleted is true").isTrue
@ -249,7 +261,7 @@ internal class NoteRepositoryImplTest : DbTest() {
assertThat(noteRepo.restore(user1.id, note1.uuid)).isTrue assertThat(noteRepo.restore(user1.id, note1.uuid)).isTrue
val isDeleted2 = db.notes val isDeleted2 = db.notes
.find { Notes.uuid eq note1.uuid } .find { it.uuid eq note1.uuid }
?.deleted ?.deleted
assertThat(isDeleted2).`as`("Check that Notes.deleted is false after restore()").isFalse assertThat(isDeleted2).`as`("Check that Notes.deleted is false after restore()").isFalse
@ -290,8 +302,8 @@ internal class NoteRepositoryImplTest : DbTest() {
val expected = notes.mapIndexed { i, n -> val expected = notes.mapIndexed { i, n ->
ExportedNote( ExportedNote(
title = n.title, title = n.meta.title,
tags = n.tags, tags = n.meta.tags,
markdown = n.markdown, markdown = n.markdown,
html = n.html, html = n.html,
updatedAt = n.updatedAt, updatedAt = n.updatedAt,

View File

@ -0,0 +1,31 @@
package be.simplenotes.persistence.notes
import be.simplenotes.config.DataSourceConfig
import be.simplenotes.persistence.KMariadbContainer
import be.simplenotes.persistence.h2dataSourceConfig
import be.simplenotes.persistence.mariadbDataSourceConfig
import org.junit.jupiter.api.AfterAll
import org.junit.jupiter.api.Tag
import org.junit.jupiter.api.parallel.ResourceLock
@ResourceLock("h2")
internal class H2NoteRepositoryImplTests : BaseNoteRepositoryImplTest() {
override fun dataSourceConfig() = h2dataSourceConfig()
}
@Tag("slow")
@ResourceLock("mariadb")
internal class MariaDbNoteRepositoryImplTests : BaseNoteRepositoryImplTest() {
lateinit var mariaDB: KMariadbContainer
@AfterAll
fun stopMariaDB() {
mariaDB.stop()
}
override fun dataSourceConfig(): DataSourceConfig {
mariaDB = KMariadbContainer()
mariaDB.start()
return mariadbDataSourceConfig(mariaDB.jdbcUrl)
}
}

View File

@ -1,17 +1,17 @@
package be.simplenotes.persistence package be.simplenotes.persistence.users
import be.simplenotes.persistence.DbTest
import be.simplenotes.persistence.repositories.UserRepository import be.simplenotes.persistence.repositories.UserRepository
import be.simplenotes.persistence.users.fakeUser import me.liuwj.ktorm.database.Database
import me.liuwj.ktorm.dsl.eq
import me.liuwj.ktorm.entity.find
import me.liuwj.ktorm.entity.toList
import org.assertj.core.api.Assertions.assertThat import org.assertj.core.api.Assertions.assertThat
import org.junit.jupiter.api.BeforeEach import org.junit.jupiter.api.BeforeEach
import org.junit.jupiter.api.Nested import org.junit.jupiter.api.Nested
import org.junit.jupiter.api.Test import org.junit.jupiter.api.Test
import org.ktorm.database.Database
import org.ktorm.dsl.eq
import org.ktorm.entity.find
import org.ktorm.entity.toList
internal class UserRepositoryImplTest : DbTest() { internal abstract class BaseUserRepositoryImplTest : DbTest() {
private lateinit var userRepo: UserRepository private lateinit var userRepo: UserRepository
private lateinit var db: Database private lateinit var db: Database
@ -30,7 +30,7 @@ internal class UserRepositoryImplTest : DbTest() {
.extracting("username", "password") .extracting("username", "password")
.contains(user.username, user.password) .contains(user.username, user.password)
assertThat(db.users.find { Users.username eq user.username }).isNotNull assertThat(db.users.find { it.username eq user.username }).isNotNull
assertThat(db.users.toList()).hasSize(1) assertThat(db.users.toList()).hasSize(1)
assertThat(userRepo.create(user)).isNull() assertThat(userRepo.create(user)).isNull()
} }

View File

@ -0,0 +1,31 @@
package be.simplenotes.persistence.users
import be.simplenotes.config.DataSourceConfig
import be.simplenotes.persistence.KMariadbContainer
import be.simplenotes.persistence.h2dataSourceConfig
import be.simplenotes.persistence.mariadbDataSourceConfig
import org.junit.jupiter.api.AfterAll
import org.junit.jupiter.api.Tag
import org.junit.jupiter.api.parallel.ResourceLock
@ResourceLock("h2")
internal class UserRepositoryImplTest : BaseUserRepositoryImplTest() {
override fun dataSourceConfig() = h2dataSourceConfig()
}
@Tag("slow")
@ResourceLock("mariadb")
internal class MariaDbUserRepositoryImplTest : BaseUserRepositoryImplTest() {
lateinit var mariaDB: KMariadbContainer
@AfterAll
fun stopMariaDB() {
mariaDB.stop()
}
override fun dataSourceConfig(): DataSourceConfig {
mariaDB = KMariadbContainer()
mariaDB.start()
return mariadbDataSourceConfig(mariaDB.jdbcUrl)
}
}

View File

@ -12,10 +12,10 @@ fun fakeTitle() = faker.lorem().characters(3, 50)!!
fun tagGenerator() = generateSequence { faker.lorem().word() } fun tagGenerator() = generateSequence { faker.lorem().word() }
fun fakeTags() = tagGenerator().take(faker.random().nextInt(0, 3)).toList() fun fakeTags() = tagGenerator().take(faker.random().nextInt(0, 3)).toList()
fun fakeContent() = faker.lorem().paragraph(faker.random().nextInt(0, 3))!! fun fakeContent() = faker.lorem().paragraph(faker.random().nextInt(0, 3))!!
fun fakeNote() = Note(fakeTitle(), fakeTags(), fakeContent(), fakeContent()) fun fakeNote() = Note(NoteMetadata(fakeTitle(), fakeTags()), fakeContent(), fakeContent())
fun PersistedNote.toPersistedMeta() = fun PersistedNote.toPersistedMeta() =
PersistedNoteMetadata(title, tags, updatedAt, uuid) PersistedNoteMetadata(meta.title, meta.tags, updatedAt, uuid)
fun NoteRepository.insertFakeNote( fun NoteRepository.insertFakeNote(
userId: Int, userId: Int,
@ -23,7 +23,7 @@ fun NoteRepository.insertFakeNote(
tags: List<String> = emptyList(), tags: List<String> = emptyList(),
md: String = "md", md: String = "md",
html: String = "html", html: String = "html",
): PersistedNote = create(userId, Note(title, tags, md, html)) ): PersistedNote = create(userId, Note(NoteMetadata(title, tags), md, html))
fun NoteRepository.insertFakeNote( fun NoteRepository.insertFakeNote(
user: PersistedUser, user: PersistedUser,

View File

@ -5,10 +5,10 @@
</pattern> </pattern>
</encoder> </encoder>
</appender> </appender>
<root level="INFO"> <root level="DEBUG">
<appender-ref ref="STDOUT"/> <appender-ref ref="STDOUT"/>
</root> </root>
<logger name="org.ktorm.database" level="DEBUG"/> <logger name="me.liuwj.ktorm.database" level="DEBUG"/>
<logger name="com.zaxxer.hikari" level="ERROR"/> <logger name="com.zaxxer.hikari" level="INFO"/>
<logger name="org.flywaydb.core" level="ERROR"/> <logger name="org.flywaydb.core" level="DEBUG"/>
</configuration> </configuration>

View File

@ -8,7 +8,6 @@ plugins {
dependencies { dependencies {
implementation(project(":types")) implementation(project(":types"))
implementation(project(":config"))
implementation(Libs.Lucene.core) implementation(Libs.Lucene.core)
implementation(Libs.Lucene.queryParser) implementation(Libs.Lucene.queryParser)

View File

@ -7,15 +7,18 @@ import org.apache.lucene.document.Field
import org.apache.lucene.document.StringField import org.apache.lucene.document.StringField
import org.apache.lucene.document.TextField import org.apache.lucene.document.TextField
internal fun PersistedNote.toDocument() = Document().apply { internal fun PersistedNote.toDocument(): Document {
// non searchable fields val note = this
add(StringField(uuidField, UuidFieldConverter.toDoc(uuid), Field.Store.YES)) return Document().apply {
add(StringField(updatedAtField, LocalDateTimeFieldConverter.toDoc(updatedAt), Field.Store.YES)) // non searchable fields
add(StringField(uuidField, UuidFieldConverter.toDoc(note.uuid), Field.Store.YES))
add(StringField(updatedAtField, LocalDateTimeFieldConverter.toDoc(note.updatedAt), Field.Store.YES))
// searchable fields // searchable fields
add(TextField(titleField, title, Field.Store.YES)) add(TextField(titleField, note.meta.title, Field.Store.YES))
add(TextField(tagsField, TagsFieldConverter.toDoc(tags), Field.Store.YES)) add(TextField(tagsField, TagsFieldConverter.toDoc(note.meta.tags), Field.Store.YES))
add(TextField(contentField, markdown, Field.Store.YES)) add(TextField(contentField, note.markdown, Field.Store.YES))
}
} }
internal fun Document.toNoteMeta() = PersistedNoteMetadata( internal fun Document.toNoteMeta() = PersistedNoteMetadata(

View File

@ -16,7 +16,12 @@ import javax.inject.Named
import javax.inject.Singleton import javax.inject.Singleton
@Singleton @Singleton
internal class NoteSearcherImpl(@Named("search-index") basePath: Path) : NoteSearcher { internal class NoteSearcherImpl(
@Named("search-index")
basePath: Path,
) : NoteSearcher {
constructor() : this(Path.of("/tmp", "lucene"))
private val baseFile = basePath.toFile() private val baseFile = basePath.toFile()

View File

@ -1,6 +1,5 @@
package be.simplenotes.search package be.simplenotes.search
import be.simplenotes.config.DataConfig
import io.micronaut.context.annotation.Factory import io.micronaut.context.annotation.Factory
import io.micronaut.context.annotation.Prototype import io.micronaut.context.annotation.Prototype
import java.nio.file.Path import java.nio.file.Path
@ -11,5 +10,5 @@ class SearchModule {
@Named("search-index") @Named("search-index")
@Prototype @Prototype
internal fun luceneIndex(dataConfig: DataConfig) = Path.of(dataConfig.dataDir).resolve("index") internal fun luceneIndex() = Path.of(".lucene")
} }

View File

@ -1,5 +1,6 @@
package be.simplenotes.search package be.simplenotes.search
import be.simplenotes.types.NoteMetadata
import be.simplenotes.types.PersistedNote import be.simplenotes.types.PersistedNote
import be.simplenotes.types.PersistedNoteMetadata import be.simplenotes.types.PersistedNoteMetadata
import org.assertj.core.api.Assertions.assertThat import org.assertj.core.api.Assertions.assertThat
@ -8,7 +9,6 @@ import org.junit.jupiter.api.AfterAll
import org.junit.jupiter.api.BeforeEach import org.junit.jupiter.api.BeforeEach
import org.junit.jupiter.api.Test import org.junit.jupiter.api.Test
import org.junit.jupiter.api.parallel.ResourceLock import org.junit.jupiter.api.parallel.ResourceLock
import java.nio.file.Path
import java.time.LocalDateTime import java.time.LocalDateTime
import java.util.* import java.util.*
@ -16,7 +16,7 @@ import java.util.*
internal class NoteSearcherImplTest { internal class NoteSearcherImplTest {
// region setup // region setup
private val searcher = NoteSearcherImpl(Path.of("/tmp", "lucene")) private val searcher = NoteSearcherImpl()
private fun index( private fun index(
title: String, title: String,
@ -25,12 +25,11 @@ internal class NoteSearcherImplTest {
uuid: UUID = UUID.randomUUID(), uuid: UUID = UUID.randomUUID(),
): PersistedNote { ): PersistedNote {
val note = PersistedNote( val note = PersistedNote(
title = title, NoteMetadata(title, tags),
tags = tags,
markdown = content, markdown = content,
html = "", html = "",
updatedAt = LocalDateTime.MIN, LocalDateTime.MIN,
uuid = uuid, uuid,
public = false public = false
) )
searcher.indexNote(1, note) searcher.indexNote(1, note)
@ -151,7 +150,7 @@ internal class NoteSearcherImplTest {
@Test @Test
fun `update index`() { fun `update index`() {
val note = index("first") val note = index("first")
searcher.updateIndex(1, note.copy(title = "new")) searcher.updateIndex(1, note.copy(meta = note.meta.copy(title = "new")))
assertThat(search("first")).isEmpty() assertThat(search("first")).isEmpty()
assertThat(search("new")).hasSize(1) assertThat(search("new")).hasSize(1)
} }

View File

@ -1,9 +1,7 @@
@file:UseContextualSerialization(LocalDateTime::class, UUID::class)
package be.simplenotes.types package be.simplenotes.types
import kotlinx.serialization.Contextual
import kotlinx.serialization.Serializable import kotlinx.serialization.Serializable
import kotlinx.serialization.UseContextualSerialization
import java.time.LocalDateTime import java.time.LocalDateTime
import java.util.* import java.util.*
@ -17,26 +15,24 @@ data class NoteMetadata(
data class PersistedNoteMetadata( data class PersistedNoteMetadata(
val title: String, val title: String,
val tags: List<String>, val tags: List<String>,
val updatedAt: LocalDateTime, @Contextual val updatedAt: LocalDateTime,
val uuid: UUID, @Contextual val uuid: UUID,
) )
@Serializable @Serializable
data class Note( data class Note(
val title: String, val meta: NoteMetadata,
val tags: List<String>,
val markdown: String, val markdown: String,
val html: String, val html: String,
) )
@Serializable @Serializable
data class PersistedNote( data class PersistedNote(
val uuid: UUID, val meta: NoteMetadata,
val title: String,
val tags: List<String>,
val markdown: String, val markdown: String,
val html: String, val html: String,
val updatedAt: LocalDateTime, @Contextual val updatedAt: LocalDateTime,
@Contextual val uuid: UUID,
val public: Boolean, val public: Boolean,
) )
@ -46,6 +42,6 @@ data class ExportedNote(
val tags: List<String>, val tags: List<String>,
val markdown: String, val markdown: String,
val html: String, val html: String,
val updatedAt: LocalDateTime, @Contextual val updatedAt: LocalDateTime,
val trash: Boolean, val trash: Boolean,
) )

View File

@ -121,7 +121,7 @@ class NoteView(@Named("styles") styles: String) : View(styles) {
} }
fun renderedNote(loggedInUser: LoggedInUser?, note: PersistedNote, shared: Boolean) = renderPage( fun renderedNote(loggedInUser: LoggedInUser?, note: PersistedNote, shared: Boolean) = renderPage(
note.title, note.meta.title,
loggedInUser = loggedInUser, loggedInUser = loggedInUser,
scripts = listOf("/highlight.10.1.2.js", "/init-highlight.0.0.1.js") scripts = listOf("/highlight.10.1.2.js", "/init-highlight.0.0.1.js")
) { ) {
@ -136,9 +136,9 @@ class NoteView(@Named("styles") styles: String) : View(styles) {
} }
div("flex items-center justify-between mb-4") { div("flex items-center justify-between mb-4") {
h1("text-3xl fond-bold underline") { +note.title } h1("text-3xl fond-bold underline") { +note.meta.title }
span("space-x-2") { span("space-x-2") {
note.tags.forEach { note.meta.tags.forEach {
a(href = "/notes?tag=$it", classes = "tag") { a(href = "/notes?tag=$it", classes = "tag") {
+"#$it" +"#$it"
} }