Refactor (#4)

Reviewed-on: #4
This commit is contained in:
2025-12-26 00:32:20 +00:00
parent 58de25e5bc
commit 1bacb454fa
11 changed files with 238 additions and 115 deletions

View File

@@ -2,30 +2,25 @@
use async_trait::async_trait;
use chrono::{DateTime, Utc};
use sqlx::{FromRow, SqlitePool};
use sqlx::{FromRow, QueryBuilder, Sqlite, SqlitePool};
use uuid::Uuid;
use notes_domain::{
DomainError, DomainResult, Note, NoteFilter, NoteRepository, NoteVersion, Tag, TagRepository,
};
use crate::tag_repository::SqliteTagRepository;
use notes_domain::{DomainError, DomainResult, Note, NoteFilter, NoteRepository, NoteVersion, Tag};
/// SQLite adapter for NoteRepository
pub struct SqliteNoteRepository {
pool: SqlitePool,
tag_repo: SqliteTagRepository,
}
impl SqliteNoteRepository {
pub fn new(pool: SqlitePool) -> Self {
let tag_repo = SqliteTagRepository::new(pool.clone());
Self { pool, tag_repo }
Self { pool }
}
}
/// Row with JSON-aggregated tags for single-query fetching
#[derive(Debug, FromRow)]
struct NoteRow {
struct NoteRowWithTags {
id: String,
user_id: String,
title: String,
@@ -35,27 +30,59 @@ struct NoteRow {
is_archived: i32,
created_at: String,
updated_at: String,
tags_json: String,
}
impl NoteRow {
fn try_into_note(self, tags: Vec<Tag>) -> Result<Note, DomainError> {
/// Helper to parse datetime strings
fn parse_datetime(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::RepositoryError(format!("Invalid datetime: {}", e)))
}
/// Helper to parse tags from JSON array
fn parse_tags_json(tags_json: &str) -> Result<Vec<Tag>, DomainError> {
// SQLite returns [null] for LEFT JOIN with no matches
let parsed: Vec<serde_json::Value> = serde_json::from_str(tags_json)
.map_err(|e| DomainError::RepositoryError(format!("Failed to parse tags JSON: {}", e)))?;
parsed
.into_iter()
.filter(|v| !v.is_null())
.map(|v| {
let id_str = v["id"]
.as_str()
.ok_or_else(|| DomainError::RepositoryError("Missing tag id".to_string()))?;
let name = v["name"]
.as_str()
.ok_or_else(|| DomainError::RepositoryError("Missing tag name".to_string()))?;
let user_id_str = v["user_id"]
.as_str()
.ok_or_else(|| DomainError::RepositoryError("Missing tag user_id".to_string()))?;
let id = Uuid::parse_str(id_str)
.map_err(|e| DomainError::RepositoryError(format!("Invalid tag UUID: {}", e)))?;
let user_id = Uuid::parse_str(user_id_str)
.map_err(|e| DomainError::RepositoryError(format!("Invalid tag user_id: {}", e)))?;
Ok(Tag::with_id(id, name.to_string(), user_id))
})
.collect()
}
impl NoteRowWithTags {
fn try_into_note(self) -> Result<Note, DomainError> {
let id = Uuid::parse_str(&self.id)
.map_err(|e| DomainError::RepositoryError(format!("Invalid UUID: {}", e)))?;
let user_id = Uuid::parse_str(&self.user_id)
.map_err(|e| DomainError::RepositoryError(format!("Invalid UUID: {}", e)))?;
let parse_datetime = |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::RepositoryError(format!("Invalid datetime: {}", e)))
};
let created_at = parse_datetime(&self.created_at)?;
let updated_at = parse_datetime(&self.updated_at)?;
let tags = parse_tags_json(&self.tags_json)?;
Ok(Note {
id,
@@ -110,10 +137,20 @@ impl NoteVersionRow {
impl NoteRepository for SqliteNoteRepository {
async fn find_by_id(&self, id: Uuid) -> DomainResult<Option<Note>> {
let id_str = id.to_string();
let row: Option<NoteRow> = sqlx::query_as(
let row: Option<NoteRowWithTags> = sqlx::query_as(
r#"
SELECT id, user_id, title, content, color, is_pinned, is_archived, created_at, updated_at
FROM notes WHERE id = ?
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
WHERE n.id = ?
GROUP BY n.id
"#,
)
.bind(&id_str)
@@ -122,10 +159,7 @@ impl NoteRepository for SqliteNoteRepository {
.map_err(|e| DomainError::RepositoryError(e.to_string()))?;
match row {
Some(row) => {
let tags = self.tag_repo.find_by_note(id).await?;
Ok(Some(row.try_into_note(tags)?))
}
Some(row) => Ok(Some(row.try_into_note()?)),
None => Ok(None),
}
}
@@ -133,50 +167,52 @@ impl NoteRepository for SqliteNoteRepository {
async fn find_by_user(&self, user_id: Uuid, filter: NoteFilter) -> DomainResult<Vec<Note>> {
let user_id_str = user_id.to_string();
// Build dynamic query based on filter
let mut query = String::from(
// Build dynamic query using QueryBuilder for safety
let mut query_builder: QueryBuilder<Sqlite> = QueryBuilder::new(
r#"
SELECT id, user_id, title, content, color, is_pinned, is_archived, created_at, updated_at
FROM notes
WHERE user_id = ?
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
WHERE n.user_id =
"#,
);
query_builder.push_bind(user_id_str);
if let Some(pinned) = filter.is_pinned {
query.push_str(&format!(" AND is_pinned = {}", if pinned { 1 } else { 0 }));
query_builder
.push(" AND n.is_pinned = ")
.push_bind(if pinned { 1i32 } else { 0i32 });
}
if let Some(archived) = filter.is_archived {
query.push_str(&format!(
" AND is_archived = {}",
if archived { 1 } else { 0 }
));
query_builder
.push(" AND n.is_archived = ")
.push_bind(if archived { 1i32 } else { 0i32 });
}
if let Some(tag_id) = filter.tag_id {
query.push_str(&format!(
" AND id IN (SELECT note_id FROM note_tags WHERE tag_id = '{}')",
tag_id
));
query_builder
.push(" AND n.id IN (SELECT note_id FROM note_tags WHERE tag_id = ")
.push_bind(tag_id.to_string())
.push(")");
}
query.push_str(" ORDER BY is_pinned DESC, updated_at DESC");
query_builder.push(" GROUP BY n.id ORDER BY n.is_pinned DESC, n.updated_at DESC");
let rows: Vec<NoteRow> = sqlx::query_as(&query)
.bind(&user_id_str)
let rows: Vec<NoteRowWithTags> = query_builder
.build_query_as()
.fetch_all(&self.pool)
.await
.map_err(|e| DomainError::RepositoryError(e.to_string()))?;
let mut notes = Vec::with_capacity(rows.len());
for row in rows {
let note_id = Uuid::parse_str(&row.id)
.map_err(|e| DomainError::RepositoryError(format!("Invalid UUID: {}", e)))?;
let tags = self.tag_repo.find_by_note(note_id).await?;
notes.push(row.try_into_note(tags)?);
}
Ok(notes)
rows.into_iter().map(|row| row.try_into_note()).collect()
}
async fn save(&self, note: &Note) -> DomainResult<()> {
@@ -229,26 +265,34 @@ impl NoteRepository for SqliteNoteRepository {
async fn search(&self, user_id: Uuid, query: &str) -> DomainResult<Vec<Note>> {
let user_id_str = user_id.to_string();
let like_query = format!("%{}%", query);
// Use FTS5 for full-text search OR tag name match
let rows: Vec<NoteRow> = sqlx::query_as(
// Use FTS5 for full-text search OR tag name match, with JSON-aggregated tags
let rows: Vec<NoteRowWithTags> = sqlx::query_as(
r#"
SELECT DISTINCT n.id, n.user_id, n.title, n.content, n.color, n.is_pinned, n.is_archived, n.created_at, n.updated_at
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
WHERE n.user_id = ?
AND (
n.rowid IN (SELECT rowid FROM notes_fts WHERE notes_fts MATCH ?)
OR
EXISTS (
SELECT 1 FROM note_tags nt
JOIN tags t ON nt.tag_id = t.id
WHERE nt.note_id = n.id AND t.name LIKE ?
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
"#
"#,
)
.bind(&user_id_str)
.bind(query)
@@ -257,15 +301,7 @@ impl NoteRepository for SqliteNoteRepository {
.await
.map_err(|e| DomainError::RepositoryError(e.to_string()))?;
let mut notes = Vec::with_capacity(rows.len());
for row in rows {
let note_id = Uuid::parse_str(&row.id)
.map_err(|e| DomainError::RepositoryError(format!("Invalid UUID: {}", e)))?;
let tags = self.tag_repo.find_by_note(note_id).await?;
notes.push(row.try_into_note(tags)?);
}
Ok(notes)
rows.into_iter().map(|row| row.try_into_note()).collect()
}
async fn save_version(&self, version: &NoteVersion) -> DomainResult<()> {