Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
17 changes: 15 additions & 2 deletions build.sbt
Original file line number Diff line number Diff line change
Expand Up @@ -47,8 +47,8 @@ lazy val commonSettings = Seq(

lazy val scalawiki = (project in file("."))
.settings(commonSettings)
.dependsOn(core, bots, dumps, wlx, `http-extensions`)
.aggregate(core, bots, dumps, wlx, `http-extensions`)
.dependsOn(core, bots, dumps, wlx, duckdb, `http-extensions`)
.aggregate(core, bots, dumps, wlx, duckdb, `http-extensions`)

lazy val core = Project("scalawiki-core", file("scalawiki-core"))
.settings(commonSettings: _*)
Expand Down Expand Up @@ -131,3 +131,16 @@ lazy val `http-extensions` = (project in file("http-extensions"))
"org.scalacheck" %% "scalacheck" % ScalaCheckV % Test
)
)

lazy val duckdb = Project("scalawiki-duckdb", file("scalawiki-duckdb"))
.dependsOn(core % "compile->compile;test->test")
.settings(commonSettings: _*)
.settings(
libraryDependencies ++= Seq(
Library.Quill.quillJdbc,
Library.DuckDb.jdbc,
Library.Specs2.core % Test,
Library.Specs2.matcherExtra % Test,
Library.Specs2.mock % Test
)
)
10 changes: 10 additions & 0 deletions project/Dependencies.scala
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ object Dependencies {
val BlameApiV = "15.10.15"
val ChronicleMapV = "3.27ea1"
val ChronoScalaV = "1.0.0"
val DuckDbV = "1.1.3"
val FicusV = "1.5.2"
val GuavaV = "33.4.0-jre"
val H2V = "1.4.200"
Expand All @@ -15,6 +16,7 @@ object Dependencies {
val JSoupV = "1.18.3"
val LogbackClassicV = "1.5.15"
val MockServerV = "5.15.0"
val QuillV = "4.8.6"
val ReactiveStreamsV = "1.0.4"
val RetryV = "0.3.6"
val Scala213V = "2.13.15"
Expand Down Expand Up @@ -135,6 +137,14 @@ object Dependencies {
val mock = "org.specs2" %% "specs2-mock" % SpecsV
}

object Quill {
val quillJdbc = "io.getquill" %% "quill-jdbc" % QuillV
}

object DuckDb {
val jdbc = "org.duckdb" % "duckdb_jdbc" % DuckDbV
}

}

}
3 changes: 3 additions & 0 deletions scalawiki-duckdb/.gitignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
*.db
*.db.wal
target/
92 changes: 92 additions & 0 deletions scalawiki-duckdb/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,92 @@
# ScalaWiki DuckDB Module

This module provides DuckDB database integration for ScalaWiki using Quill for type-safe, compile-time checked database queries.

## Features

- **ImageRepository**: Repository for reading and writing `org.scalawiki.dto.Image` instances
- **Quill Integration**: Uses Quill for type-safe database queries with compile-time verification
- **DuckDB Support**: Leverages DuckDB's embedded database capabilities for fast, serverless analytics
- **SQL Injection Protection**: All queries use parameterized statements via Quill's lift mechanism

## Usage

### Creating a Repository

```scala
import org.scalawiki.duckdb.ImageRepository

// Create an in-memory repository
val repository = ImageRepository.inMemory()

// Or create a file-based repository
val fileRepository = ImageRepository("jdbc:duckdb:/path/to/database.db")

// Initialize the database table
repository.createTable()
```

### CRUD Operations

```scala
import org.scalawiki.dto.Image

// Insert an image
val image = Image(
title = "Example.jpg",
url = Some("http://example.com/image.jpg"),
author = Some("John Doe"),
monumentIds = Seq("MON123")
)
repository.insert(image)

// Find by title
val found = repository.findByTitle("Example.jpg")

// Find by author
val byAuthor = repository.findByAuthor("John Doe")

// Find by monument ID
val byMonument = repository.findByMonumentId("MON123")

// Update
val updated = image.copy(author = Some("Jane Doe"))
repository.update(updated)

// Delete
repository.delete("Example.jpg")

// Get all images
val all = repository.findAll()

// Count images
val count = repository.count()
```

### Batch Operations

```scala
// Insert multiple images
val images = Seq(
Image(title = "Image1.jpg"),
Image(title = "Image2.jpg"),
Image(title = "Image3.jpg")
)
repository.insertBatch(images)
```

## Dependencies

This module depends on:
- `io.getquill:quill-jdbc:4.8.6` - Quill JDBC support
- `org.duckdb:duckdb_jdbc:1.1.3` - DuckDB JDBC driver
- `scalawiki-core` - Core ScalaWiki DTOs

## Testing

Run tests with:
```
sbt "project scalawiki-duckdb" test
```

All tests use in-memory DuckDB databases for fast, isolated test execution.
Original file line number Diff line number Diff line change
@@ -0,0 +1,224 @@
package org.scalawiki.duckdb

import io.getquill._
import org.scalawiki.dto.Image
import java.time.ZonedDateTime
import javax.sql.DataSource

/**
* Repository for reading and writing Image instances using Quill to DuckDB database
*/
class ImageRepository(dataSource: DataSource) {

// DuckDB is compatible with PostgreSQL dialect for most operations
private val ctx = new PostgresJdbcContext(NamingStrategy(SnakeCase), dataSource)
import ctx._

/**
* Database schema for Image
*/
case class ImageRow(
title: String,
url: Option[String] = None,
pageUrl: Option[String] = None,
size: Option[Long] = None,
width: Option[Int] = None,
height: Option[Int] = None,
author: Option[String] = None,
uploaderLogin: Option[String] = None,
year: Option[String] = None,
date: Option[ZonedDateTime] = None,
monumentIds: String = "",
pageId: Option[Long] = None,
categories: String = "",
specialNominations: String = "",
mime: Option[String] = None
)

/**
* Convert Image DTO to database row
*/
private def toImageRow(image: Image): ImageRow = {
ImageRow(
title = image.title,
url = image.url,
pageUrl = image.pageUrl,
size = image.size,
width = image.width,
height = image.height,
author = image.author,
uploaderLogin = image.uploader.flatMap(_.login),
year = image.year,
date = image.date,
monumentIds = image.monumentIds.mkString(","),
pageId = image.pageId,
categories = image.categories.mkString(","),
specialNominations = image.specialNominations.mkString(","),
mime = image.mime
)
}

/**
* Convert database row to Image DTO
*/
private def fromImageRow(row: ImageRow): Image = {
Image(
title = row.title,
url = row.url,
pageUrl = row.pageUrl,
size = row.size,
width = row.width,
height = row.height,
author = row.author,
uploader = row.uploaderLogin.map(login => org.scalawiki.dto.User(None, Some(login))),
year = row.year,
date = row.date,
monumentIds = if (row.monumentIds.isEmpty) Seq.empty else row.monumentIds.split(",").toSeq,
pageId = row.pageId,
metadata = None,
categories = if (row.categories.isEmpty) Set.empty else row.categories.split(",").toSet,
specialNominations = if (row.specialNominations.isEmpty) Set.empty else row.specialNominations.split(",").toSet,
mime = row.mime
)
}

/**
* Insert an Image into the database
*/
def insert(image: Image): Long = {
val row = toImageRow(image)
ctx.run(query[ImageRow].insertValue(lift(row)))
}

/**
* Insert multiple Images into the database
*/
def insertBatch(images: Seq[Image]): List[Long] = {
val rows = images.map(toImageRow)
ctx.run(liftQuery(rows).foreach(row => query[ImageRow].insertValue(row)))
}

/**
* Find an Image by title
*/
def findByTitle(title: String): Option[Image] = {
ctx.run(query[ImageRow].filter(_.title == lift(title))).headOption.map(fromImageRow)
}

/**
* Find all Images
*/
def findAll(): Seq[Image] = {
ctx.run(query[ImageRow]).map(fromImageRow)
}

/**
* Find Images by monument ID
*/
def findByMonumentId(monumentId: String): Seq[Image] = {
val pattern = s"%$monumentId%"
ctx.run(query[ImageRow].filter(row =>
sql"${row.monumentIds} LIKE ${lift(pattern)}".asCondition
)).map(fromImageRow)
}

/**
* Find Images by author
*/
def findByAuthor(author: String): Seq[Image] = {
ctx.run(query[ImageRow].filter(_.author.exists(_ == lift(author)))).map(fromImageRow)
}

/**
* Update an Image
*/
def update(image: Image): Long = {
val row = toImageRow(image)
ctx.run(query[ImageRow].filter(_.title == lift(row.title)).updateValue(lift(row)))
}

/**
* Delete an Image by title
*/
def delete(title: String): Long = {
ctx.run(query[ImageRow].filter(_.title == lift(title)).delete)
}

/**
* Count all Images
*/
def count(): Long = {
ctx.run(query[ImageRow].size)
}

/**
* Create the images table if it doesn't exist
*/
def createTable(): Unit = {
val createTableSql = """
CREATE TABLE IF NOT EXISTS image_row (
title VARCHAR PRIMARY KEY,
url VARCHAR,
page_url VARCHAR,
size BIGINT,
width INTEGER,
height INTEGER,
author VARCHAR,
uploader_login VARCHAR,
year VARCHAR,
date TIMESTAMP WITH TIME ZONE,
monument_ids VARCHAR,
page_id BIGINT,
categories VARCHAR,
special_nominations VARCHAR,
mime VARCHAR
)
"""
val connection = dataSource.getConnection()
try {
val statement = connection.createStatement()
try {
statement.execute(createTableSql)
} finally {
statement.close()
}
} finally {
connection.close()
}
}

/**
* Drop the images table
*/
def dropTable(): Unit = {
val connection = dataSource.getConnection()
try {
val statement = connection.createStatement()
try {
statement.execute("DROP TABLE IF EXISTS image_row")
} finally {
statement.close()
}
} finally {
connection.close()
}
}
}

object ImageRepository {
/**
* Create a new ImageRepository with a DuckDB connection
*/
def apply(jdbcUrl: String): ImageRepository = {
val ds = new org.duckdb.DuckDBDataSource()
ds.setUrl(jdbcUrl)
new ImageRepository(ds)
}

/**
* Create a new ImageRepository with an in-memory DuckDB database
*/
def inMemory(): ImageRepository = {
apply("jdbc:duckdb:")
}
}
Loading
Loading