@@ -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<()> {
|
||||
|
||||
Reference in New Issue
Block a user