diff --git a/src/api.rs b/src/api.rs index 9c1ccb0..74346b2 100644 --- a/src/api.rs +++ b/src/api.rs @@ -18,19 +18,18 @@ pub async fn read_question( State(store): State>>, Path(id): Path, ) -> Response { - match store.read().await.fetch_one_question_by_id(id).await { + //First, fetch the question + let question_result = store.read().await.fetch_one_question_by_id(id).await; + match question_result { Some(question) => { - // We could do this in one DB hit with a purpose built store function and different underlying structures, but two hits isn't that bad. - let tags = store - .read() - .await - .fetch_tags_by_property(question.id, "question_id") - .await - .unwrap_or(vec![]); - let response_dto: QuestionDTO = QuestionDTO::new(question, tags); - response_dto.into_response() + //Then fetch the tags for this question, if there are any + let tags_result = store.read().await.get_tags_for_question(question.id).await; + match tags_result { + Some(tags) => QuestionDTO::new(question, tags).into_response(), + None => QuestionDTO::new(question, vec![]).into_response(), + } } - None => (StatusCode::NOT_FOUND).into_response(), + None => (StatusCode::NOT_EXTENDED).into_response(), } } @@ -79,12 +78,6 @@ pub async fn create_question( State(store): State>>, Json(new_question): Json, ) -> Response { - //Step 1: See which of supplied tags already exists - //Step 2: Create the non-existing tags - //Step 3: Create the question - //Step 4: Create the join listing - //Step 5: Return result - //Step 6: Ponder the use of ORMs match store.write().await.add_question(new_question).await { Ok(question) => (StatusCode::CREATED, Json(&question)).into_response(), Err(e) => (StatusCode::CONFLICT, e).into_response(), @@ -102,21 +95,6 @@ pub async fn update_question( State(_store): State>>, Json(_question): Json, ) -> Response { - // Step 0: Update the question entry - // Step 1: Fetch the question_tags for the given tags - // Step 2: Fetch the tags for the given question tags - // Step 3: new_tags_labels = list of tag labels not already on question - // Step 3a: Fetch existing_new_tag as tags which already by given name exist, but do not yet have a question_tag association - // Step 3b: Create question_tag association for 3a - // Step 3c: Create new tags which do not already exist - // Step 3d: Create question_tag association for 3c - // Step 4: remove_tag_labls = list of tag labs which should no longer have a question_tag association with the question - // Step 4a: Fetch existing_old_tags as tags by given na - - // match store.write().await.update_question(question) { - // Ok(question) => question.into_response(), - // Err(e) => (StatusCode::NOT_FOUND, e).into_response(), - // } todo!() } diff --git a/src/pg_store.rs b/src/pg_store.rs index 554f79a..fe1187a 100644 --- a/src/pg_store.rs +++ b/src/pg_store.rs @@ -1,14 +1,11 @@ -use std::borrow::Borrow; - -use answer::NewAnswer; -use question::QuestionDTO; +use std::vec; /// Store is responsible for manageing the in-memory hashmap of questions by providing initialization read/write functions, /// and file I/O operations to persist these questions /// TODO - Results returning errors should use specified types, not strings use self::{ - answer::Answer, - question::{NewQuestion, Question}, + answer::{Answer, NewAnswer}, + question::{NewQuestion, Question, QuestionDTO}, question_tag::QuestionTag, tag::Tag, }; @@ -22,7 +19,7 @@ pub struct Store { impl Store { /// Helper to deal with unwrapping postgres fields as u8 which we want. Not gracefull if you pass in the wrong field name - so do not do that fn id_to_u8(pg_row: &PgRow, id_name: &str) -> u8 { - pg_row.try_get::(id_name).unwrap() as u8 + pg_row.try_get::(id_name).unwrap() as u8 } pub async fn new(db_url: &str) -> Self { @@ -40,6 +37,26 @@ impl Store { } } + //Get the question tags for a particular question + pub async fn get_tags_for_question(&self, question_id: u8) -> Option> { + let query = "SELECT label FROM tag JOIN question_tag on tag.id = question_tag.tag_id JOIN question on tag.question_id = $1"; + let result = sqlx::query(query) + .bind(question_id.to_string()) + .fetch_all(&self.connection) + .await; + match result { + Ok(pg_rows) => { + let tags: Vec = pg_rows + .iter() + .map(|pg_row| Tag::new(Store::id_to_u8(pg_row, "id"), pg_row.get("label"))) + .collect::>(); + Some(tags) + } + _ => None, + } + } + + //Remove a question/tag association pub async fn _remove_question_tag( &mut self, question_id: u8, @@ -55,6 +72,8 @@ impl Store { Err(e) => Err(e.to_string()), } } + + //Add a question/tag association pub async fn _add_question_tag( &mut self, question_id: u8, @@ -75,7 +94,8 @@ impl Store { } } - // Add new tags to tags table, only if tags with existing label do not exist + // Add new tags to tags table, only if tags with existing label do not exist. If they already exist, just ignore that + // Returns list of tags pub async fn add_tags(&mut self, tag_labels: Vec) -> Result, String> { let insert_values = tag_labels.join(","); let query = @@ -85,10 +105,13 @@ impl Store { .fetch_all(&self.connection) .await; match result { - Ok(pg_rows) => Ok(pg_rows - .iter() - .map(|pg_row| Tag::new(Store::id_to_u8(pg_row, "id"), pg_row.get("label"))) - .collect()), + Ok(pg_rows) => { + let tags: Vec = pg_rows + .iter() + .map(|pg_row| Tag::new(Store::id_to_u8(pg_row, "id"), pg_row.get("label"))) + .collect(); + Ok(tags) + } Err(e) => Err(e.to_string()), } } @@ -99,15 +122,17 @@ impl Store { pub async fn associate_tags( &mut self, question_id: u8, - tags: Vec, + tags: &[Tag], ) -> Result, String> { + //for the query binding we must convert the tag vector to a comma seperated string list of questionid/tagid tuples let question_id_tag_id_tuple_string = tags .iter() - .map(|tag| format!("({},{})", question_id.to_string(), tag.id.to_string())) + .map(|tag| format!("({},{})", question_id, tag.id)) .collect::>() .join(","); + let query = "INSERT INTO question_tag VALUES ($1) ON CONFLICT (question_id, tag_id) DO NOTHING RETURNING *"; - let result = sqlx::query(&query) + let result = sqlx::query(query) .bind(question_id_tag_id_tuple_string) .fetch_all(&self.connection) .await; @@ -131,16 +156,28 @@ impl Store { // Takes a question id and a list of tags, and remove any existing question_tag join associations // Ignores if an association does not already exist // Returns Ok(true) on success - pub async fn unassociate_tags(&mut self, question_id: u8, tags: Vec) { + pub async fn _unassociate_tags( + &mut self, + question_id: u8, + tags: Vec, + ) -> Result { let question_id_tag_id_tuple_string = tags .iter() - .map(|tag| format!("({},{})", question_id.to_string(), tag.id.to_string())) + .map(|tag| format!("({},{})", question_id, tag.id)) .collect::>() .join(","); let query = "DELETE FROM question_tag WHERE (question_id, tag_id) IN ($1)"; + match sqlx::query(query) + .bind(question_id_tag_id_tuple_string) + .execute(&self.connection) + .await + { + Ok(_) => Ok(true), + Err(e) => Err(e.to_string()), + } } - // TODO: property should be a strong type + // Fetch a list of tags by either the tag id, or by the label. Up to the caller pub async fn fetch_tags_by_property( &self, propert_id: u8, @@ -163,6 +200,7 @@ impl Store { } } + //Add a new question - but also create the tags and question/tag associations as needed pub async fn add_question(&mut self, new_question: NewQuestion) -> Result { let insert_question_result: Result = sqlx::query( "INSERT INTO questions (title, content) VALUES ($1, $2) RETURNING id, title, content", @@ -171,6 +209,7 @@ impl Store { .bind(new_question.content) .fetch_one(&self.connection) .await; + // first, insert the new question match insert_question_result { Ok(pg_row) => { let inserted_question = Question::new( @@ -178,9 +217,25 @@ impl Store { pg_row.get("title"), pg_row.get("content"), ); - let new_tags = &self.add_tags(new_question.tags).await; - match new_tags { - Ok(tags) => Ok(QuestionDTO::new(inserted_question, tags.to_vec())), + // Then create the new tags (if needed) + let new_tags_result = &self.add_tags(new_question.tags).await; + match new_tags_result { + Ok(new_tags_result) => { + let association = self + .associate_tags(inserted_question.id, new_tags_result) + .await; + // Finally, create the question/tag join + match association { + Ok(_) => { + // Now, return the QuestionDTO + Ok(QuestionDTO::new( + inserted_question, + new_tags_result.to_vec(), + )) + } + Err(e) => Err(e.to_string()), + } + } Err(e) => Err(e.to_string()), } } @@ -188,17 +243,39 @@ impl Store { } } + // Delete the question tags association for the given question, then delete the question + // Also clean up orphaned tags just in case pub async fn remove_question(&mut self, id: u8) -> Result { - let result = sqlx::query("DELETE FROM questions WHERE id = $1") + let delete_question_tag_query = "DELETE FROM question_tag WHERE question_id = $1"; + let delete_question_query = "DELETE FROM questions WHERE id = $1"; + // First, delete any possible question/tag associations + match sqlx::query(delete_question_tag_query) .bind(id.to_string()) .execute(&self.connection) - .await; - match result { - Ok(_) => Ok(true), + .await + { + Ok(_) => { + //Now, delete the question + match sqlx::query(delete_question_query) + .bind(id.to_string()) + .execute(&self.connection) + .await + { + Ok(_) => { + // Finally remove any tags that may have been orphaned + match self.remove_orphan_tags().await { + Ok(_) => Ok(true), + Err(e) => Err(e.to_string()), + } + } + Err(e) => Err(e.to_string()), + } + } Err(e) => Err(e.to_string()), } } + // Fetch one question, but do not worry about joining the tags pub async fn fetch_one_question_by_id(&self, id: u8) -> Option { let row_result = sqlx::query("SELECT * FROM questions WHERE id = $1") .bind(id.to_string()) @@ -214,6 +291,7 @@ impl Store { } } + // Fetch many questions - do not worry about joining the tags pub async fn fetch_many_questions(&self, start: usize, size: usize) -> Option> { let rows_result: Result, sqlx::Error> = sqlx::query("SELECT * FROM questions ORDER BY id LIMIT $1 OFFSET $2") @@ -238,8 +316,8 @@ impl Store { } /// Remove tags from tags table, which have no question tag association - /// Returns true if any tags were removed, false otherwise - pub async fn _remove_orphan_tags(&mut self) -> Result { + /// Returns true on success + pub async fn remove_orphan_tags(&mut self) -> Result { let result = sqlx::query( "DELETE FROM tags where id NOT IN (SELECT DISTINCT tag_id from question_tag)", ) @@ -251,10 +329,7 @@ impl Store { } } - pub fn _sync_question_tags(&mut self, _tags: Vec) { - todo!() - } - + //Update a question entity - just the question details (not tags) pub async fn _update_question(&mut self, question: Question) -> Result { let result = sqlx::query("UPDATE questions SET title = $1 AND SET content = $2 WHERE id = $3 RETURNING id, title, content") .bind(question.title).bind(question.content).bind(question.id.to_string())