refactor (v2): better arch

Co-authored-by: Copilot <copilot@github.com>
This commit is contained in:
2026-06-07 21:19:54 +02:00
parent 0753f3d256
commit 839308ec19
166 changed files with 8553 additions and 884 deletions

View File

@@ -0,0 +1,15 @@
[package]
name = "sqlite"
version = "0.1.0"
edition = "2024"
[dependencies]
domain = { workspace = true }
async-trait = { workspace = true }
chrono = { workspace = true }
uuid = { workspace = true }
sqlx = { version = "0.8", features = ["runtime-tokio-rustls", "sqlite", "chrono", "uuid", "migrate", "macros"] }
serde_json = { workspace = true }
[dev-dependencies]
tokio = { workspace = true }

View File

@@ -0,0 +1,71 @@
-- Initial schema for K-Notes
-- SQLite with FTS5 for full-text search
-- Users table (OIDC-ready)
CREATE TABLE IF NOT EXISTS users (
id TEXT PRIMARY KEY NOT NULL,
subject TEXT UNIQUE NOT NULL, -- OIDC subject identifier
email TEXT NOT NULL,
created_at TEXT NOT NULL DEFAULT (datetime('now'))
);
CREATE INDEX idx_users_subject ON users(subject);
CREATE INDEX idx_users_email ON users(email);
-- Notes table
CREATE TABLE IF NOT EXISTS notes (
id TEXT PRIMARY KEY NOT NULL,
user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
title TEXT NOT NULL,
content TEXT NOT NULL DEFAULT '',
is_pinned INTEGER NOT NULL DEFAULT 0,
is_archived INTEGER NOT NULL DEFAULT 0,
created_at TEXT NOT NULL DEFAULT (datetime('now')),
updated_at TEXT NOT NULL DEFAULT (datetime('now'))
);
CREATE INDEX idx_notes_user_id ON notes(user_id);
CREATE INDEX idx_notes_is_pinned ON notes(is_pinned);
CREATE INDEX idx_notes_is_archived ON notes(is_archived);
CREATE INDEX idx_notes_updated_at ON notes(updated_at);
-- Tags table (user-scoped)
CREATE TABLE IF NOT EXISTS tags (
id TEXT PRIMARY KEY NOT NULL,
name TEXT NOT NULL,
user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
UNIQUE(name, user_id)
);
CREATE INDEX idx_tags_user_id ON tags(user_id);
-- Junction table for note-tag relationship
CREATE TABLE IF NOT EXISTS note_tags (
note_id TEXT NOT NULL REFERENCES notes(id) ON DELETE CASCADE,
tag_id TEXT NOT NULL REFERENCES tags(id) ON DELETE CASCADE,
PRIMARY KEY (note_id, tag_id)
);
CREATE INDEX idx_note_tags_tag_id ON note_tags(tag_id);
-- Full-text search virtual table
CREATE VIRTUAL TABLE IF NOT EXISTS notes_fts USING fts5(
title,
content,
content='notes',
content_rowid='rowid'
);
-- Triggers to keep FTS index in sync
CREATE TRIGGER notes_ai AFTER INSERT ON notes BEGIN
INSERT INTO notes_fts(rowid, title, content) VALUES (NEW.rowid, NEW.title, NEW.content);
END;
CREATE TRIGGER notes_ad AFTER DELETE ON notes BEGIN
INSERT INTO notes_fts(notes_fts, rowid, title, content) VALUES('delete', OLD.rowid, OLD.title, OLD.content);
END;
CREATE TRIGGER notes_au AFTER UPDATE ON notes BEGIN
INSERT INTO notes_fts(notes_fts, rowid, title, content) VALUES('delete', OLD.rowid, OLD.title, OLD.content);
INSERT INTO notes_fts(rowid, title, content) VALUES (NEW.rowid, NEW.title, NEW.content);
END;

View File

@@ -0,0 +1,2 @@
-- Add password_hash column to users table
ALTER TABLE users ADD COLUMN password_hash TEXT;

View File

@@ -0,0 +1 @@
ALTER TABLE notes ADD COLUMN color TEXT NOT NULL DEFAULT 'DEFAULT';

View File

@@ -0,0 +1,11 @@
-- Add note_versions table
CREATE TABLE note_versions (
id TEXT PRIMARY KEY,
note_id TEXT NOT NULL,
title TEXT NOT NULL,
content TEXT NOT NULL,
created_at TEXT NOT NULL,
FOREIGN KEY(note_id) REFERENCES notes(id) ON DELETE CASCADE
);
CREATE INDEX idx_note_versions_note_id ON note_versions(note_id);

View File

@@ -0,0 +1,12 @@
CREATE TABLE IF NOT EXISTS note_links (
source_note_id TEXT NOT NULL,
target_note_id TEXT NOT NULL,
score REAL NOT NULL,
created_at DATETIME NOT NULL,
PRIMARY KEY (source_note_id, target_note_id),
FOREIGN KEY (source_note_id) REFERENCES notes(id) ON DELETE CASCADE,
FOREIGN KEY (target_note_id) REFERENCES notes(id) ON DELETE CASCADE
);
CREATE INDEX idx_note_links_source ON note_links(source_note_id);
CREATE INDEX idx_note_links_target ON note_links(target_note_id);

View File

@@ -0,0 +1,45 @@
-- Allow NULL titles in notes table
-- SQLite doesn't support ALTER COLUMN, so we need to recreate the table
-- Step 1: Create new table with nullable title
CREATE TABLE notes_new (
id TEXT PRIMARY KEY NOT NULL,
user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
title TEXT, -- Now nullable
content TEXT NOT NULL DEFAULT '',
color TEXT NOT NULL DEFAULT 'DEFAULT',
is_pinned INTEGER NOT NULL DEFAULT 0,
is_archived INTEGER NOT NULL DEFAULT 0,
created_at TEXT NOT NULL DEFAULT (datetime('now')),
updated_at TEXT NOT NULL DEFAULT (datetime('now'))
);
-- Step 2: Copy data from old table
INSERT INTO notes_new (id, user_id, title, content, color, is_pinned, is_archived, created_at, updated_at)
SELECT id, user_id, title, content, color, is_pinned, is_archived, created_at, updated_at FROM notes;
-- Step 3: Drop old table
DROP TABLE notes;
-- Step 4: Rename new table
ALTER TABLE notes_new RENAME TO notes;
-- Step 5: Recreate indexes
CREATE INDEX idx_notes_user_id ON notes(user_id);
CREATE INDEX idx_notes_is_pinned ON notes(is_pinned);
CREATE INDEX idx_notes_is_archived ON notes(is_archived);
CREATE INDEX idx_notes_updated_at ON notes(updated_at);
-- Step 6: Recreate FTS triggers
CREATE TRIGGER notes_ai AFTER INSERT ON notes BEGIN
INSERT INTO notes_fts(rowid, title, content) VALUES (NEW.rowid, COALESCE(NEW.title, ''), NEW.content);
END;
CREATE TRIGGER notes_ad AFTER DELETE ON notes BEGIN
INSERT INTO notes_fts(notes_fts, rowid, title, content) VALUES('delete', OLD.rowid, COALESCE(OLD.title, ''), OLD.content);
END;
CREATE TRIGGER notes_au AFTER UPDATE ON notes BEGIN
INSERT INTO notes_fts(notes_fts, rowid, title, content) VALUES('delete', OLD.rowid, COALESCE(OLD.title, ''), OLD.content);
INSERT INTO notes_fts(rowid, title, content) VALUES (NEW.rowid, COALESCE(NEW.title, ''), NEW.content);
END;

View File

@@ -0,0 +1,16 @@
-- note_versions.title should be nullable to match notes where title is optional
CREATE TABLE note_versions_new (
id TEXT PRIMARY KEY,
note_id TEXT NOT NULL,
title TEXT,
content TEXT NOT NULL,
created_at TEXT NOT NULL,
FOREIGN KEY(note_id) REFERENCES notes(id) ON DELETE CASCADE
);
INSERT INTO note_versions_new SELECT id, note_id, NULLIF(title, ''), content, created_at FROM note_versions;
DROP TABLE note_versions;
ALTER TABLE note_versions_new RENAME TO note_versions;
CREATE INDEX idx_note_versions_note_id ON note_versions(note_id);

View File

@@ -0,0 +1,43 @@
use chrono::{DateTime, Utc};
pub use sqlx::SqlitePool;
use sqlx::sqlite::{SqliteConnectOptions, SqliteJournalMode, SqlitePoolOptions};
use std::str::FromStr;
use domain::errors::DomainError;
pub async fn connect(database_url: &str) -> Result<SqlitePool, sqlx::Error> {
let options = SqliteConnectOptions::from_str(database_url)?
.create_if_missing(true)
.journal_mode(SqliteJournalMode::Wal)
.foreign_keys(true);
SqlitePoolOptions::new()
.max_connections(5)
.connect_with(options)
.await
}
pub async fn run_migrations(pool: &SqlitePool) -> Result<(), sqlx::migrate::MigrateError> {
sqlx::migrate!("./migrations").run(pool).await
}
/// Parse a datetime string from SQLite (RFC3339 or naive format).
pub(crate) fn parse_dt(s: &str) -> Result<DateTime<Utc>, DomainError> {
DateTime::parse_from_rfc3339(s)
.map(|dt| dt.with_timezone(&Utc))
.or_else(|_| {
chrono::NaiveDateTime::parse_from_str(s, "%Y-%m-%d %H:%M:%S").map(|dt| dt.and_utc())
})
.map_err(|e| DomainError::Repository(format!("invalid datetime '{s}': {e}")))
}
/// Map a sqlx error to DomainError::Repository.
pub(crate) trait RepoExt<T> {
fn repo(self) -> Result<T, DomainError>;
}
impl<T> RepoExt<T> for Result<T, sqlx::Error> {
fn repo(self) -> Result<T, DomainError> {
self.map_err(|e| DomainError::Repository(e.to_string()))
}
}

View File

@@ -0,0 +1,5 @@
pub mod db;
pub mod link;
pub mod note;
pub mod tag;
pub mod user;

View File

@@ -0,0 +1,103 @@
use async_trait::async_trait;
use sqlx::{FromRow, SqlitePool};
use domain::{
errors::{DomainError, DomainResult},
note::{
entity::{NoteId, NoteLink},
ports::LinkRepository,
},
};
use crate::db::RepoExt;
pub struct SqliteLinkRepository {
pool: SqlitePool,
}
impl SqliteLinkRepository {
pub fn new(pool: SqlitePool) -> Self {
Self { pool }
}
}
#[derive(FromRow)]
struct LinkRow {
source_note_id: String,
target_note_id: String,
score: f32,
created_at: String,
}
impl TryFrom<LinkRow> for NoteLink {
type Error = DomainError;
fn try_from(row: LinkRow) -> Result<Self, Self::Error> {
let source_id = NoteId::from_uuid(
uuid::Uuid::parse_str(&row.source_note_id)
.map_err(|e| DomainError::Repository(format!("invalid source uuid: {e}")))?,
);
let target_id = NoteId::from_uuid(
uuid::Uuid::parse_str(&row.target_note_id)
.map_err(|e| DomainError::Repository(format!("invalid target uuid: {e}")))?,
);
let created_at = crate::db::parse_dt(&row.created_at)?;
Ok(NoteLink {
source_id,
target_id,
score: row.score,
created_at,
})
}
}
#[async_trait]
impl LinkRepository for SqliteLinkRepository {
async fn save_links(&self, links: &[NoteLink]) -> DomainResult<()> {
let mut tx = self.pool.begin().await.repo()?;
for link in links {
sqlx::query(
r#"
INSERT INTO note_links (source_note_id, target_note_id, score, created_at)
VALUES (?, ?, ?, ?)
ON CONFLICT(source_note_id, target_note_id) DO UPDATE SET
score = excluded.score,
created_at = excluded.created_at
"#,
)
.bind(link.source_id.as_uuid().to_string())
.bind(link.target_id.as_uuid().to_string())
.bind(link.score)
.bind(link.created_at.to_rfc3339())
.execute(&mut *tx)
.await
.repo()?;
}
tx.commit().await.repo()
}
async fn delete_for_source(&self, source_id: &NoteId) -> DomainResult<()> {
sqlx::query("DELETE FROM note_links WHERE source_note_id = ?")
.bind(source_id.as_uuid().to_string())
.execute(&self.pool)
.await
.repo()
.map(|_| ())
}
async fn find_for_note(&self, note_id: &NoteId) -> DomainResult<Vec<NoteLink>> {
sqlx::query_as::<_, LinkRow>(
"SELECT source_note_id, target_note_id, score, created_at \
FROM note_links WHERE source_note_id = ? ORDER BY score DESC",
)
.bind(note_id.as_uuid().to_string())
.fetch_all(&self.pool)
.await
.repo()?
.into_iter()
.map(NoteLink::try_from)
.collect()
}
}

View File

@@ -0,0 +1,281 @@
use async_trait::async_trait;
use sqlx::{FromRow, QueryBuilder, Sqlite, SqlitePool};
use domain::{
errors::{DomainError, DomainResult},
note::{
entity::{Note, NoteFilter, NoteId, NoteVersion},
ports::NoteRepository,
value_objects::{NoteColor, NoteTitle},
},
tag::entity::{Tag, TagId},
user::entity::UserId,
};
use crate::db::{RepoExt, parse_dt};
pub struct SqliteNoteRepository {
pool: SqlitePool,
}
impl SqliteNoteRepository {
pub fn new(pool: SqlitePool) -> Self {
Self { pool }
}
}
// ── Row types ────────────────────────────────────────────────────────────────
#[derive(FromRow)]
struct NoteRow {
id: String,
user_id: String,
title: Option<String>,
content: String,
color: String,
is_pinned: i32,
is_archived: i32,
created_at: String,
updated_at: String,
tags_json: String,
}
impl TryFrom<NoteRow> for Note {
type Error = DomainError;
fn try_from(row: NoteRow) -> Result<Self, Self::Error> {
let id = NoteId::from_uuid(
uuid::Uuid::parse_str(&row.id)
.map_err(|e| DomainError::Repository(format!("invalid note uuid: {e}")))?,
);
let user_id = UserId::from_uuid(
uuid::Uuid::parse_str(&row.user_id)
.map_err(|e| DomainError::Repository(format!("invalid user uuid: {e}")))?,
);
let title = NoteTitle::from_optional(row.title)?;
let tags = parse_tags_json(&row.tags_json)?;
Ok(Note {
id,
user_id,
title,
content: row.content,
color: NoteColor::new(row.color),
is_pinned: row.is_pinned != 0,
is_archived: row.is_archived != 0,
created_at: parse_dt(&row.created_at)?,
updated_at: parse_dt(&row.updated_at)?,
tags,
})
}
}
fn parse_tags_json(json: &str) -> Result<Vec<Tag>, DomainError> {
let values: Vec<serde_json::Value> = serde_json::from_str(json)
.map_err(|e| DomainError::Repository(format!("invalid tags json: {e}")))?;
values
.into_iter()
.filter(|v| !v.is_null())
.map(|v| {
let parse_str = |key: &str| {
v[key]
.as_str()
.ok_or_else(|| DomainError::Repository(format!("missing tag field '{key}'")))
};
let id = TagId::from_uuid(
uuid::Uuid::parse_str(parse_str("id")?)
.map_err(|e| DomainError::Repository(format!("invalid tag uuid: {e}")))?,
);
let user_id = UserId::from_uuid(
uuid::Uuid::parse_str(parse_str("user_id")?)
.map_err(|e| DomainError::Repository(format!("invalid tag user_id: {e}")))?,
);
let name = domain::tag::value_objects::TagName::new(parse_str("name")?)?;
Ok(Tag::from_row(id, name, user_id))
})
.collect()
}
#[derive(FromRow)]
struct VersionRow {
id: String,
note_id: String,
title: Option<String>,
content: String,
created_at: String,
}
impl TryFrom<VersionRow> for NoteVersion {
type Error = DomainError;
fn try_from(row: VersionRow) -> Result<Self, Self::Error> {
Ok(NoteVersion {
id: uuid::Uuid::parse_str(&row.id)
.map_err(|e| DomainError::Repository(format!("invalid version uuid: {e}")))?,
note_id: NoteId::from_uuid(
uuid::Uuid::parse_str(&row.note_id)
.map_err(|e| DomainError::Repository(format!("invalid note uuid: {e}")))?,
),
title: row.title,
content: row.content,
created_at: parse_dt(&row.created_at)?,
})
}
}
// ── Shared SELECT fragment ────────────────────────────────────────────────────
const NOTE_SELECT: &str = r#"
SELECT n.id, n.user_id, n.title, n.content, n.color, n.is_pinned, n.is_archived,
n.created_at, n.updated_at,
json_group_array(
CASE WHEN t.id IS NOT NULL
THEN json_object('id', t.id, 'name', t.name, 'user_id', t.user_id)
ELSE NULL END
) AS tags_json
FROM notes n
LEFT JOIN note_tags nt ON n.id = nt.note_id
LEFT JOIN tags t ON nt.tag_id = t.id
"#;
// ── NoteRepository ───────────────────────────────────────────────────────────
#[async_trait]
impl NoteRepository for SqliteNoteRepository {
async fn find_by_id(&self, id: &NoteId) -> DomainResult<Option<Note>> {
let sql = format!("{NOTE_SELECT} WHERE n.id = ? GROUP BY n.id");
sqlx::query_as::<_, NoteRow>(&sql)
.bind(id.as_uuid().to_string())
.fetch_optional(&self.pool)
.await
.repo()?
.map(Note::try_from)
.transpose()
}
async fn find_by_user(&self, user_id: &UserId, filter: NoteFilter) -> DomainResult<Vec<Note>> {
let base = format!("{NOTE_SELECT} WHERE n.user_id = ");
let mut qb: QueryBuilder<Sqlite> = QueryBuilder::new(base);
qb.push_bind(user_id.as_uuid().to_string());
if let Some(pinned) = filter.is_pinned {
qb.push(" AND n.is_pinned = ").push_bind(pinned as i32);
}
if let Some(archived) = filter.is_archived {
qb.push(" AND n.is_archived = ").push_bind(archived as i32);
}
if let Some(tag_id) = filter.tag_id {
qb.push(" AND n.id IN (SELECT note_id FROM note_tags WHERE tag_id = ")
.push_bind(tag_id.as_uuid().to_string())
.push(")");
}
qb.push(" GROUP BY n.id ORDER BY n.is_pinned DESC, n.updated_at DESC");
qb.build_query_as::<NoteRow>()
.fetch_all(&self.pool)
.await
.repo()?
.into_iter()
.map(Note::try_from)
.collect()
}
async fn search(&self, user_id: &UserId, query: &str) -> DomainResult<Vec<Note>> {
let sql = format!(
r#"{NOTE_SELECT}
WHERE n.user_id = ?
AND (
n.rowid IN (SELECT rowid FROM notes_fts WHERE notes_fts MATCH ?)
OR EXISTS (
SELECT 1 FROM note_tags nt2
JOIN tags t2 ON nt2.tag_id = t2.id
WHERE nt2.note_id = n.id AND t2.name LIKE ?
)
)
GROUP BY n.id ORDER BY n.updated_at DESC"#
);
sqlx::query_as::<_, NoteRow>(&sql)
.bind(user_id.as_uuid().to_string())
.bind(query)
.bind(format!("%{query}%"))
.fetch_all(&self.pool)
.await
.repo()?
.into_iter()
.map(Note::try_from)
.collect()
}
async fn save(&self, note: &Note) -> DomainResult<()> {
sqlx::query(
r#"
INSERT INTO notes (id, user_id, title, content, color, is_pinned, is_archived, created_at, updated_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
ON CONFLICT(id) DO UPDATE SET
title = excluded.title,
content = excluded.content,
color = excluded.color,
is_pinned = excluded.is_pinned,
is_archived = excluded.is_archived,
updated_at = excluded.updated_at
"#,
)
.bind(note.id.as_uuid().to_string())
.bind(note.user_id.as_uuid().to_string())
.bind(note.title.as_ref().map(|t| t.as_ref()))
.bind(&note.content)
.bind(note.color.as_str())
.bind(note.is_pinned as i32)
.bind(note.is_archived as i32)
.bind(note.created_at.to_rfc3339())
.bind(note.updated_at.to_rfc3339())
.execute(&self.pool)
.await
.repo()
.map(|_| ())
}
async fn delete(&self, id: &NoteId) -> DomainResult<()> {
sqlx::query("DELETE FROM notes WHERE id = ?")
.bind(id.as_uuid().to_string())
.execute(&self.pool)
.await
.repo()
.map(|_| ())
}
async fn save_version(&self, version: &NoteVersion) -> DomainResult<()> {
sqlx::query(
"INSERT INTO note_versions (id, note_id, title, content, created_at) VALUES (?, ?, ?, ?, ?)",
)
.bind(version.id.to_string())
.bind(version.note_id.as_uuid().to_string())
.bind(version.title.as_deref())
.bind(&version.content)
.bind(version.created_at.to_rfc3339())
.execute(&self.pool)
.await
.repo()
.map(|_| ())
}
async fn find_versions(&self, note_id: &NoteId) -> DomainResult<Vec<NoteVersion>> {
sqlx::query_as::<_, VersionRow>(
"SELECT id, note_id, title, content, created_at FROM note_versions WHERE note_id = ? ORDER BY created_at DESC",
)
.bind(note_id.as_uuid().to_string())
.fetch_all(&self.pool)
.await
.repo()?
.into_iter()
.map(NoteVersion::try_from)
.collect()
}
}
#[cfg(test)]
#[path = "tests/note.rs"]
mod tests;

View File

@@ -0,0 +1,157 @@
use async_trait::async_trait;
use sqlx::{FromRow, SqlitePool};
use domain::{
errors::{DomainError, DomainResult},
note::entity::NoteId,
tag::{
entity::{Tag, TagId},
ports::TagRepository,
value_objects::TagName,
},
user::entity::UserId,
};
use crate::db::RepoExt;
pub struct SqliteTagRepository {
pool: SqlitePool,
}
impl SqliteTagRepository {
pub fn new(pool: SqlitePool) -> Self {
Self { pool }
}
}
#[derive(FromRow)]
struct TagRow {
id: String,
name: String,
user_id: String,
}
impl TryFrom<TagRow> for Tag {
type Error = DomainError;
fn try_from(row: TagRow) -> Result<Self, Self::Error> {
let id = TagId::from_uuid(
uuid::Uuid::parse_str(&row.id)
.map_err(|e| DomainError::Repository(format!("invalid tag uuid: {e}")))?,
);
let user_id = UserId::from_uuid(
uuid::Uuid::parse_str(&row.user_id)
.map_err(|e| DomainError::Repository(format!("invalid user uuid: {e}")))?,
);
let name = TagName::new(row.name)?;
Ok(Tag::from_row(id, name, user_id))
}
}
#[async_trait]
impl TagRepository for SqliteTagRepository {
async fn find_by_id(&self, id: &TagId) -> DomainResult<Option<Tag>> {
sqlx::query_as::<_, TagRow>("SELECT id, name, user_id FROM tags WHERE id = ?")
.bind(id.as_uuid().to_string())
.fetch_optional(&self.pool)
.await
.repo()?
.map(Tag::try_from)
.transpose()
}
async fn find_by_user(&self, user_id: &UserId) -> DomainResult<Vec<Tag>> {
sqlx::query_as::<_, TagRow>(
"SELECT id, name, user_id FROM tags WHERE user_id = ? ORDER BY name",
)
.bind(user_id.as_uuid().to_string())
.fetch_all(&self.pool)
.await
.repo()?
.into_iter()
.map(Tag::try_from)
.collect()
}
async fn find_by_name(&self, user_id: &UserId, name: &TagName) -> DomainResult<Option<Tag>> {
sqlx::query_as::<_, TagRow>(
"SELECT id, name, user_id FROM tags WHERE user_id = ? AND name = ?",
)
.bind(user_id.as_uuid().to_string())
.bind(name.as_ref())
.fetch_optional(&self.pool)
.await
.repo()?
.map(Tag::try_from)
.transpose()
}
async fn find_by_note(&self, note_id: &NoteId) -> DomainResult<Vec<Tag>> {
sqlx::query_as::<_, TagRow>(
r#"
SELECT t.id, t.name, t.user_id
FROM tags t
INNER JOIN note_tags nt ON t.id = nt.tag_id
WHERE nt.note_id = ?
ORDER BY t.name
"#,
)
.bind(note_id.as_uuid().to_string())
.fetch_all(&self.pool)
.await
.repo()?
.into_iter()
.map(Tag::try_from)
.collect()
}
async fn save(&self, tag: &Tag) -> DomainResult<()> {
sqlx::query(
r#"
INSERT INTO tags (id, name, user_id)
VALUES (?, ?, ?)
ON CONFLICT(id) DO UPDATE SET name = excluded.name
"#,
)
.bind(tag.id.as_uuid().to_string())
.bind(tag.name.as_ref())
.bind(tag.user_id.as_uuid().to_string())
.execute(&self.pool)
.await
.repo()
.map(|_| ())
}
async fn delete(&self, id: &TagId) -> DomainResult<()> {
sqlx::query("DELETE FROM tags WHERE id = ?")
.bind(id.as_uuid().to_string())
.execute(&self.pool)
.await
.repo()
.map(|_| ())
}
async fn add_to_note(&self, tag_id: &TagId, note_id: &NoteId) -> DomainResult<()> {
sqlx::query("INSERT OR IGNORE INTO note_tags (note_id, tag_id) VALUES (?, ?)")
.bind(note_id.as_uuid().to_string())
.bind(tag_id.as_uuid().to_string())
.execute(&self.pool)
.await
.repo()
.map(|_| ())
}
async fn remove_from_note(&self, tag_id: &TagId, note_id: &NoteId) -> DomainResult<()> {
sqlx::query("DELETE FROM note_tags WHERE note_id = ? AND tag_id = ?")
.bind(note_id.as_uuid().to_string())
.bind(tag_id.as_uuid().to_string())
.execute(&self.pool)
.await
.repo()
.map(|_| ())
}
}
#[cfg(test)]
#[path = "tests/tag.rs"]
mod tests;

View File

@@ -0,0 +1,119 @@
use sqlx::SqlitePool;
use domain::{
note::{
entity::{Note, NoteFilter},
ports::NoteRepository,
value_objects::NoteTitle,
},
user::{entity::User, ports::UserRepository, value_objects::Email},
};
use crate::{db::run_migrations, note::SqliteNoteRepository, user::SqliteUserRepository};
async fn pool() -> SqlitePool {
let p = SqlitePool::connect("sqlite::memory:").await.unwrap();
run_migrations(&p).await.unwrap();
p
}
async fn seed_user(pool: &SqlitePool) -> User {
let repo = SqliteUserRepository::new(pool.clone());
let user = User::new_oidc("sub", Email::new("u@example.com").unwrap());
repo.save(&user).await.unwrap();
user
}
#[tokio::test]
async fn save_and_find_by_id() {
let p = pool().await;
let user = seed_user(&p).await;
let repo = SqliteNoteRepository::new(p);
let note = Note::new(user.id, NoteTitle::new("Hello").ok(), "world".to_string());
repo.save(&note).await.unwrap();
let found = repo.find_by_id(&note.id).await.unwrap().unwrap();
assert_eq!(found.content, "world");
assert_eq!(found.title.as_ref().unwrap().as_ref(), "Hello");
}
#[tokio::test]
async fn save_note_without_title() {
let p = pool().await;
let user = seed_user(&p).await;
let repo = SqliteNoteRepository::new(p);
let note = Note::new(user.id, None, "no title".to_string());
repo.save(&note).await.unwrap();
let found = repo.find_by_id(&note.id).await.unwrap().unwrap();
assert!(found.title.is_none());
}
#[tokio::test]
async fn find_by_user_with_pinned_filter() {
let p = pool().await;
let user = seed_user(&p).await;
let repo = SqliteNoteRepository::new(p);
let mut pinned = Note::new(user.id, None, "pinned".to_string());
pinned.set_pinned(true);
repo.save(&pinned).await.unwrap();
repo.save(&Note::new(user.id, None, "normal".to_string()))
.await
.unwrap();
let results = repo
.find_by_user(&user.id, NoteFilter::default().pinned())
.await
.unwrap();
assert_eq!(results.len(), 1);
assert_eq!(results[0].content, "pinned");
}
#[tokio::test]
async fn delete_removes_note() {
let p = pool().await;
let user = seed_user(&p).await;
let repo = SqliteNoteRepository::new(p);
let note = Note::new(user.id, None, "bye".to_string());
repo.save(&note).await.unwrap();
repo.delete(&note.id).await.unwrap();
assert!(repo.find_by_id(&note.id).await.unwrap().is_none());
}
#[tokio::test]
async fn save_and_find_versions() {
let p = pool().await;
let user = seed_user(&p).await;
let repo = SqliteNoteRepository::new(p);
let note = Note::new(user.id, None, "v1".to_string());
repo.save(&note).await.unwrap();
let version = domain::note::entity::NoteVersion::snapshot(&note);
repo.save_version(&version).await.unwrap();
let versions = repo.find_versions(&note.id).await.unwrap();
assert_eq!(versions.len(), 1);
assert_eq!(versions[0].content, "v1");
}
#[tokio::test]
async fn upsert_updates_note() {
let p = pool().await;
let user = seed_user(&p).await;
let repo = SqliteNoteRepository::new(p);
let mut note = Note::new(user.id, None, "original".to_string());
repo.save(&note).await.unwrap();
note.set_content("updated");
repo.save(&note).await.unwrap();
let found = repo.find_by_id(&note.id).await.unwrap().unwrap();
assert_eq!(found.content, "updated");
}

View File

@@ -0,0 +1,82 @@
use sqlx::SqlitePool;
use domain::{
tag::{entity::Tag, ports::TagRepository, value_objects::TagName},
user::entity::{User, UserId},
};
use crate::{db::run_migrations, tag::SqliteTagRepository, user::SqliteUserRepository};
use domain::user::{ports::UserRepository, value_objects::Email};
async fn pool() -> SqlitePool {
let p = SqlitePool::connect("sqlite::memory:").await.unwrap();
run_migrations(&p).await.unwrap();
p
}
async fn seed_user(pool: &SqlitePool) -> User {
let repo = SqliteUserRepository::new(pool.clone());
let user = User::new_oidc("sub", Email::new("u@example.com").unwrap());
repo.save(&user).await.unwrap();
user
}
#[tokio::test]
async fn save_and_find_by_id() {
let p = pool().await;
let user = seed_user(&p).await;
let repo = SqliteTagRepository::new(p);
let tag = Tag::new(TagName::new("work").unwrap(), user.id);
repo.save(&tag).await.unwrap();
let found = repo.find_by_id(&tag.id).await.unwrap().unwrap();
assert_eq!(found.name.as_ref(), "work");
}
#[tokio::test]
async fn find_by_name() {
let p = pool().await;
let user = seed_user(&p).await;
let repo = SqliteTagRepository::new(p);
let tag = Tag::new(TagName::new("rust").unwrap(), user.id);
repo.save(&tag).await.unwrap();
let found = repo
.find_by_name(&user.id, &TagName::new("rust").unwrap())
.await
.unwrap();
assert_eq!(found.unwrap().id, tag.id);
}
#[tokio::test]
async fn find_by_user_returns_sorted() {
let p = pool().await;
let user = seed_user(&p).await;
let repo = SqliteTagRepository::new(p);
repo.save(&Tag::new(TagName::new("zebra").unwrap(), user.id))
.await
.unwrap();
repo.save(&Tag::new(TagName::new("alpha").unwrap(), user.id))
.await
.unwrap();
let tags = repo.find_by_user(&user.id).await.unwrap();
assert_eq!(tags[0].name.as_ref(), "alpha");
assert_eq!(tags[1].name.as_ref(), "zebra");
}
#[tokio::test]
async fn delete_removes_tag() {
let p = pool().await;
let user = seed_user(&p).await;
let repo = SqliteTagRepository::new(p);
let tag = Tag::new(TagName::new("gone").unwrap(), user.id);
repo.save(&tag).await.unwrap();
repo.delete(&tag.id).await.unwrap();
assert!(repo.find_by_id(&tag.id).await.unwrap().is_none());
}

View File

@@ -0,0 +1,84 @@
use sqlx::SqlitePool;
use domain::user::{
entity::{User, UserId},
ports::UserRepository,
value_objects::{Email, PasswordHash},
};
use crate::{db::run_migrations, user::SqliteUserRepository};
async fn pool() -> SqlitePool {
let p = SqlitePool::connect("sqlite::memory:").await.unwrap();
run_migrations(&p).await.unwrap();
p
}
#[tokio::test]
async fn save_and_find_by_id() {
let repo = SqliteUserRepository::new(pool().await);
let user = User::new_oidc("oidc|123", Email::new("a@example.com").unwrap());
repo.save(&user).await.unwrap();
let found = repo.find_by_id(&user.id).await.unwrap().unwrap();
assert_eq!(found.subject, "oidc|123");
assert_eq!(found.email.as_ref(), "a@example.com");
assert!(found.password_hash.is_none());
}
#[tokio::test]
async fn save_local_user_with_password_hash() {
let repo = SqliteUserRepository::new(pool().await);
let user = User::new_local(
Email::new("local@example.com").unwrap(),
PasswordHash::new("argon2hash"),
);
repo.save(&user).await.unwrap();
let found = repo.find_by_id(&user.id).await.unwrap().unwrap();
assert_eq!(found.password_hash.unwrap().as_str(), "argon2hash");
}
#[tokio::test]
async fn find_by_subject() {
let repo = SqliteUserRepository::new(pool().await);
let user = User::new_oidc("google|456", Email::new("g@example.com").unwrap());
repo.save(&user).await.unwrap();
let found = repo.find_by_subject("google|456").await.unwrap().unwrap();
assert_eq!(found.id, user.id);
}
#[tokio::test]
async fn find_by_email() {
let repo = SqliteUserRepository::new(pool().await);
let email = Email::new("find@example.com").unwrap();
let user = User::new_oidc("sub", email.clone());
repo.save(&user).await.unwrap();
let found = repo.find_by_email(&email).await.unwrap().unwrap();
assert_eq!(found.id, user.id);
}
#[tokio::test]
async fn delete_removes_user() {
let repo = SqliteUserRepository::new(pool().await);
let user = User::new_oidc("del|1", Email::new("del@example.com").unwrap());
repo.save(&user).await.unwrap();
repo.delete(&user.id).await.unwrap();
assert!(repo.find_by_id(&user.id).await.unwrap().is_none());
}
#[tokio::test]
async fn upsert_updates_existing_user() {
let repo = SqliteUserRepository::new(pool().await);
let mut user = User::new_oidc("sub", Email::new("u@example.com").unwrap());
repo.save(&user).await.unwrap();
user.subject = "sub-updated".into();
repo.save(&user).await.unwrap();
let found = repo.find_by_id(&user.id).await.unwrap().unwrap();
assert_eq!(found.subject, "sub-updated");
}

View File

@@ -0,0 +1,129 @@
use async_trait::async_trait;
use sqlx::{FromRow, SqlitePool};
use domain::{
errors::DomainResult,
user::{
entity::{User, UserId},
ports::UserRepository,
value_objects::{Email, PasswordHash},
},
};
use crate::db::{RepoExt, parse_dt};
pub struct SqliteUserRepository {
pool: SqlitePool,
}
impl SqliteUserRepository {
pub fn new(pool: SqlitePool) -> Self {
Self { pool }
}
}
#[derive(FromRow)]
struct UserRow {
id: String,
subject: String,
email: String,
password_hash: Option<String>,
created_at: String,
}
impl TryFrom<UserRow> for User {
type Error = domain::errors::DomainError;
fn try_from(row: UserRow) -> Result<Self, Self::Error> {
use domain::errors::DomainError;
let id = UserId::from_uuid(
uuid::Uuid::parse_str(&row.id)
.map_err(|e| DomainError::Repository(format!("invalid user uuid: {e}")))?,
);
let email = Email::new(&row.email)?;
let password_hash = row.password_hash.map(PasswordHash::new);
let created_at = parse_dt(&row.created_at)?;
Ok(User::from_row(
id,
row.subject,
email,
password_hash,
created_at,
))
}
}
#[async_trait]
impl UserRepository for SqliteUserRepository {
async fn find_by_id(&self, id: &UserId) -> DomainResult<Option<User>> {
let id_str = id.as_uuid().to_string();
sqlx::query_as::<_, UserRow>(
"SELECT id, subject, email, password_hash, created_at FROM users WHERE id = ?",
)
.bind(&id_str)
.fetch_optional(&self.pool)
.await
.repo()?
.map(User::try_from)
.transpose()
}
async fn find_by_subject(&self, subject: &str) -> DomainResult<Option<User>> {
sqlx::query_as::<_, UserRow>(
"SELECT id, subject, email, password_hash, created_at FROM users WHERE subject = ?",
)
.bind(subject)
.fetch_optional(&self.pool)
.await
.repo()?
.map(User::try_from)
.transpose()
}
async fn find_by_email(&self, email: &Email) -> DomainResult<Option<User>> {
sqlx::query_as::<_, UserRow>(
"SELECT id, subject, email, password_hash, created_at FROM users WHERE email = ?",
)
.bind(email.as_ref())
.fetch_optional(&self.pool)
.await
.repo()?
.map(User::try_from)
.transpose()
}
async fn save(&self, user: &User) -> DomainResult<()> {
sqlx::query(
r#"
INSERT INTO users (id, subject, email, password_hash, created_at)
VALUES (?, ?, ?, ?, ?)
ON CONFLICT(id) DO UPDATE SET
subject = excluded.subject,
email = excluded.email,
password_hash = excluded.password_hash
"#,
)
.bind(user.id.as_uuid().to_string())
.bind(&user.subject)
.bind(user.email.as_ref())
.bind(user.password_hash.as_ref().map(PasswordHash::as_str))
.bind(user.created_at.to_rfc3339())
.execute(&self.pool)
.await
.repo()
.map(|_| ())
}
async fn delete(&self, id: &UserId) -> DomainResult<()> {
sqlx::query("DELETE FROM users WHERE id = ?")
.bind(id.as_uuid().to_string())
.execute(&self.pool)
.await
.repo()
.map(|_| ())
}
}
#[cfg(test)]
#[path = "tests/user.rs"]
mod tests;