Compare commits

...

2 Commits

59 changed files with 601 additions and 1225 deletions

View File

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

View File

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

View File

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

View File

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

View File

@ -16,13 +16,10 @@ object Libs {
object Drivers {
const val h2 = "com.h2database:h2:1.4.200"
const val mariadb = "org.mariadb.jdbc:mariadb-java-client:2.7.2"
}
object Ktorm {
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"
const val core = "org.ktorm:ktorm-core:3.3.0"
}
}

View File

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

View File

@ -1,32 +0,0 @@
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,
)

58
config/src/DataConfig.kt Normal file
View File

@ -0,0 +1,58 @@
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,54 +1,19 @@
version: '2.2'
services:
db:
image: mariadb:10.5.5
container_name: simplenotes-mariadb
env_file:
- .env
environment:
- PUID=1000
- PGID=1000
- 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:
- PUID=1000
- PGID=1000
- TZ=Europe/Brussels
- DB_JDBC_URL=jdbc:mariadb://db:3306/simplenotes
- DB_USERNAME=simplenotes
# .env:
# - JWT_SECRET
# - DB_PASSWORD
ports:
- 127.0.0.1:8080:8080
healthcheck:
test: "curl --fail -s http://localhost:8080/health"
interval: 5s
timeout: 1s
start_period: 2s
retries: 3
depends_on:
db:
condition: service_healthy
volumes:
- ./simplenotes-data:/app/data
volumes:
notes-db-volume:

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -0,0 +1,5 @@
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

@ -1,36 +0,0 @@
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

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

View File

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

View File

@ -0,0 +1,55 @@
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

@ -1,31 +0,0 @@
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,8 +1,5 @@
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 javax.inject.Singleton
import javax.sql.DataSource
@ -12,20 +9,11 @@ interface DbMigrations {
}
@Singleton
internal class DbMigrationsImpl(
private val dataSource: DataSource,
private val dataSourceConfig: DataSourceConfig,
) : DbMigrations {
internal class DbMigrationsImpl(private val dataSource: DataSource) : DbMigrations {
override fun migrate() {
val migrationDir = when (dataSourceConfig.type()) {
DbType.H2 -> "db/migration/other"
DbType.MariaDb -> "db/migration/mariadb"
}
Flyway.configure()
.dataSource(dataSource)
.locations(migrationDir)
.locations("db/migration")
.load()
.migrate()
}

View File

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

42
persistence/src/Tables.kt Normal file
View File

@ -0,0 +1,42 @@
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

@ -0,0 +1,34 @@
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

@ -1,85 +0,0 @@
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

@ -1,24 +0,0 @@
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,26 +1,77 @@
package be.simplenotes.persistence.extensions
import me.liuwj.ktorm.schema.BaseTable
import me.liuwj.ktorm.schema.SqlType
import java.nio.ByteBuffer
import org.ktorm.database.Database
import org.ktorm.expression.*
import org.ktorm.schema.*
import java.sql.PreparedStatement
import java.sql.ResultSet
import java.sql.Types
import java.util.*
internal class UuidBinarySqlType : SqlType<UUID>(Types.BINARY, typeName = "uuidBinary") {
override fun doGetResult(rs: ResultSet, index: Int): UUID? {
val value = rs.getBytes(index) ?: return null
return ByteBuffer.wrap(value).let { b -> UUID(b.long, b.long) }
internal class VarcharArraySqlType : SqlType<List<String>>(Types.ARRAY, typeName = "varchar[]") {
override fun doGetResult(rs: ResultSet, index: Int): List<String>? {
return when (val array = rs.getObject(index)) {
null -> null
is Array<*> -> array.map { it.toString() }
else -> error("")
}
}
override fun doSetParameter(ps: PreparedStatement, index: Int, parameter: UUID) {
val bytes = ByteBuffer.allocate(16)
.putLong(parameter.mostSignificantBits)
.putLong(parameter.leastSignificantBits)
.array()
ps.setBytes(index, bytes)
override fun doSetParameter(ps: PreparedStatement, index: Int, parameter: List<String>) {
throw UnsupportedOperationException()
}
}
internal fun <E : Any> BaseTable<E>.uuidBinary(name: String) = registerColumn(name, UuidBinarySqlType())
internal fun <E : Any> BaseTable<E>.varcharArray(name: String) = registerColumn(name, VarcharArraySqlType())
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

@ -1,219 +0,0 @@
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

@ -1,45 +0,0 @@
@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

@ -1,30 +0,0 @@
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

@ -0,0 +1,144 @@
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

@ -0,0 +1,40 @@
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

@ -1,9 +0,0 @@
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

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

View File

@ -1,42 +0,0 @@
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

@ -1,28 +0,0 @@
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

@ -1,8 +0,0 @@
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

@ -1,22 +0,0 @@
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

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

View File

@ -1,24 +1,29 @@
package be.simplenotes.persistence.notes
package be.simplenotes.persistence
import be.simplenotes.persistence.DbTest
import be.simplenotes.persistence.converters.NoteConverter
import be.simplenotes.persistence.notes.*
import be.simplenotes.persistence.repositories.NoteRepository
import be.simplenotes.persistence.repositories.UserRepository
import be.simplenotes.persistence.users.createFakeUser
import be.simplenotes.types.ExportedNote
import be.simplenotes.types.PersistedNote
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.assertThatThrownBy
import org.junit.jupiter.api.*
import org.junit.jupiter.api.BeforeEach
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
internal abstract class BaseNoteRepositoryImplTest : DbTest() {
internal class NoteRepositoryImplTest : DbTest() {
private lateinit var noteRepo: NoteRepository
private lateinit var userRepo: UserRepository
@ -94,27 +99,6 @@ internal abstract class BaseNoteRepositoryImplTest : DbTest() {
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
fun `find all notes with tag`() {
with(noteRepo) {
@ -145,12 +129,18 @@ internal abstract class BaseNoteRepositoryImplTest : DbTest() {
fun `find an existing note`() {
val fakeNote = noteRepo.insertFakeNote(user1)
val converter = beanContext.getBean(NoteConverter::class.java)
val note = db.notes.find { it.title eq fakeNote.meta.title }!!
val note = db.notes.find { Notes.title eq fakeNote.title }!!
.let { entity ->
val tags = db.tags.filter { it.noteUuid eq entity.uuid }.mapColumns { it.name } as List<String>
converter.toPersistedNote(entity, tags)
val tags = db.tags.filter { be.simplenotes.persistence.Tags.noteUuid eq entity.uuid }.mapColumns { be.simplenotes.persistence.Tags.name } as List<String>
PersistedNote(
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)
@ -199,11 +189,11 @@ internal abstract class BaseNoteRepositoryImplTest : DbTest() {
val notes1 = noteRepo.insertFakeNotes(user1, count = 3)
val notes2 = noteRepo.insertFakeNotes(user2, count = 1)
val user1Tags = notes1.flatMap { it.meta.tags }.toSet()
val user1Tags = notes1.flatMap { it.tags }.toSet()
assertThat(noteRepo.getTags(user1.id))
.containsExactlyInAnyOrderElementsOf(user1Tags)
val user2Tags = notes2.flatMap { it.meta.tags }.toSet()
val user2Tags = notes2.flatMap { it.tags }.toSet()
assertThat(noteRepo.getTags(user2.id))
.containsExactlyInAnyOrderElementsOf(user2Tags)
@ -229,9 +219,7 @@ internal abstract class BaseNoteRepositoryImplTest : DbTest() {
.isEqualTo(newNote1)
val note2 = noteRepo.insertFakeNote(user1)
val newNote2 = fakeNote().let {
it.copy(meta = it.meta.copy(tags = tagGenerator().take(3).toList()))
}
val newNote2 = fakeNote().copy(tags = tagGenerator().take(3).toList())
assertThat(noteRepo.update(user1.id, note2.uuid, newNote2))
.isNotNull
@ -253,7 +241,7 @@ internal abstract class BaseNoteRepositoryImplTest : DbTest() {
.isTrue
val isDeleted = db.notes
.find { it.uuid eq note1.uuid }
.find { Notes.uuid eq note1.uuid }
?.deleted
assertThat(isDeleted).`as`("Check that Notes.deleted is true").isTrue
@ -261,7 +249,7 @@ internal abstract class BaseNoteRepositoryImplTest : DbTest() {
assertThat(noteRepo.restore(user1.id, note1.uuid)).isTrue
val isDeleted2 = db.notes
.find { it.uuid eq note1.uuid }
.find { Notes.uuid eq note1.uuid }
?.deleted
assertThat(isDeleted2).`as`("Check that Notes.deleted is false after restore()").isFalse
@ -302,8 +290,8 @@ internal abstract class BaseNoteRepositoryImplTest : DbTest() {
val expected = notes.mapIndexed { i, n ->
ExportedNote(
title = n.meta.title,
tags = n.meta.tags,
title = n.title,
tags = n.tags,
markdown = n.markdown,
html = n.html,
updatedAt = n.updatedAt,

View File

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

View File

@ -1,164 +0,0 @@
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

@ -1,48 +0,0 @@
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,31 +0,0 @@
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,31 +0,0 @@
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 fakeTags() = tagGenerator().take(faker.random().nextInt(0, 3)).toList()
fun fakeContent() = faker.lorem().paragraph(faker.random().nextInt(0, 3))!!
fun fakeNote() = Note(NoteMetadata(fakeTitle(), fakeTags()), fakeContent(), fakeContent())
fun fakeNote() = Note(fakeTitle(), fakeTags(), fakeContent(), fakeContent())
fun PersistedNote.toPersistedMeta() =
PersistedNoteMetadata(meta.title, meta.tags, updatedAt, uuid)
PersistedNoteMetadata(title, tags, updatedAt, uuid)
fun NoteRepository.insertFakeNote(
userId: Int,
@ -23,7 +23,7 @@ fun NoteRepository.insertFakeNote(
tags: List<String> = emptyList(),
md: String = "md",
html: String = "html",
): PersistedNote = create(userId, Note(NoteMetadata(title, tags), md, html))
): PersistedNote = create(userId, Note(title, tags, md, html))
fun NoteRepository.insertFakeNote(
user: PersistedUser,

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -1,7 +1,9 @@
@file:UseContextualSerialization(LocalDateTime::class, UUID::class)
package be.simplenotes.types
import kotlinx.serialization.Contextual
import kotlinx.serialization.Serializable
import kotlinx.serialization.UseContextualSerialization
import java.time.LocalDateTime
import java.util.*
@ -15,24 +17,26 @@ data class NoteMetadata(
data class PersistedNoteMetadata(
val title: String,
val tags: List<String>,
@Contextual val updatedAt: LocalDateTime,
@Contextual val uuid: UUID,
val updatedAt: LocalDateTime,
val uuid: UUID,
)
@Serializable
data class Note(
val meta: NoteMetadata,
val title: String,
val tags: List<String>,
val markdown: String,
val html: String,
)
@Serializable
data class PersistedNote(
val meta: NoteMetadata,
val uuid: UUID,
val title: String,
val tags: List<String>,
val markdown: String,
val html: String,
@Contextual val updatedAt: LocalDateTime,
@Contextual val uuid: UUID,
val updatedAt: LocalDateTime,
val public: Boolean,
)
@ -42,6 +46,6 @@ data class ExportedNote(
val tags: List<String>,
val markdown: String,
val html: String,
@Contextual val updatedAt: LocalDateTime,
val updatedAt: LocalDateTime,
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(
note.meta.title,
note.title,
loggedInUser = loggedInUser,
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") {
h1("text-3xl fond-bold underline") { +note.meta.title }
h1("text-3xl fond-bold underline") { +note.title }
span("space-x-2") {
note.meta.tags.forEach {
note.tags.forEach {
a(href = "/notes?tag=$it", classes = "tag") {
+"#$it"
}