Rails+React博客Post与Tag多对多更新时重复创建同名标签问题
我正在使用Ruby on Rails和React搭建个人博客网站,在更新文章并添加新标签时遇到了问题。我的操作界面如下:
当我在更新表单中新增名为"test"的标签时,预期最终标签列表为"work"和"test"两个标签,但实际运行结果如下:
系统没有仅追加"test"标签,反而重复创建了已有的"work"和新的"test"标签,导致标签列表出现重复数据。后端数据库中的数据情况如下:
数据库中存在两条ID不同、名称均为"test"的独立标签记录。我的需求是创建标签时不生成同名重复标签,以实现按标签筛选文章的功能,当前因标签重复问题无法实现该需求。以下是我编写的相关代码:
前端代码(React)
Profile.jsx
作用:通过.map方法渲染多个PostContainer组件,承载文章列表
import { useEffect, useState } from 'react' import PostContainer from './PostContainers' export default function Profile({currentUser}){ const [posts, setPosts] = useState([]) function updatePost(postObj){ const newPostArray = posts.map((post) => { if(post.id === postObj.id){ return postObj } return post }) setPosts(newPostArray) } function deletePost(postId){ const newArray = posts.filter(post => post.id !== postId) setPosts(newArray) } const getPosts = () => { fetch(`/post/${currentUser.id}`) .then((r) =>{ if(r.ok){ r.json().then((posts)=>setPosts(posts)) } }) } useEffect(() => { getPosts() }, []) return( <div> <h1>Hello {currentUser.username}</h1> <h3>What's on your mind today?</h3> <div className='postWrapper'> <h2>Your Posts</h2> <div className='postLists'> {posts && posts?.map((post)=> <PostContainer key={post.id} post={post} updatePost={updatePost} deletePost={deletePost} /> )} </div> </div> </div> ) }
postContainer.jsx
作用:对应界面中独立的文章卡片组件,包含更新、删除文章功能
import { useState } from 'react'; export default function PostContainer({ post, updatePost, deletePost }){ const [isShown, setIsShown] = useState(false); const [title, setNewTitle] = useState(""); const [content, setNewContent] = useState(""); const [tags, setNewTags] = useState(''); const userId = post.user_id; function handleDelete(e){ e.preventDefault(); console.log(post.id) fetch(`/post/${post.id}`,{ method: "DELETE", headers: { 'Content-Type':'application/json' }, }) .then((r) => r.json()) .then(deletePost(post.id)) } // 发送PATCH更新请求 function handleSubmit(e){ e.preventDefault(); const patchedPost = { title, content, userId, tags_attributes: tags.split(',').map((el) => { return {name: el} }) } fetch(`/post/${post.id}`, { method: "PATCH", headers: { "Content-Type": "application/json" }, body: JSON.stringify(patchedPost) }) .then(r => r.json()) .then(x => updatePost(x)) .catch(err => alert(err.message)) setIsShown(false); } // 展示更新表单 const handleUpdate = (e) =>{ e.preventDefault(); setIsShown(current => !current) // 预填充原有文章数据 setNewTitle(post.title); setNewContent(post.content); setNewTags(post.tags.map((x)=>x.name)); } return( <div> <div> {isShown && ( <div> <form className='update-form' onSubmit={handleSubmit}> <label>Title</label><br/> <textarea type='text' value={title} onChange={(e)=>setNewTitle(e.target.value)}></textarea><br/> <label>Content</label><br/> <textarea type='text' value={content} onChange={(e)=>setNewContent(e.target.value)}></textarea><br/> <label>Tags</label><br/> <textarea type='text' value={tags} onChange={(e)=>setNewTags(e.target.value)}></textarea><br/> <button type='submit'>Update</button> <button onClick={(e)=>{e.preventDefault(); setIsShown(false)}}>Cancel</button> </form> </div> )} </div><br/> <div className='postContainer'> <h1>{post.title}</h1> <p className="users-posts-content">{post.content}</p> <p>Tags: {post.tags.map((x)=> x.name).join(',')}</p> <button onClick={(e)=>handleUpdate(e)}>Update</button> <button onClick={(e)=>handleDelete(e)}>Delete Post</button> </div> </div> ) }
Post.jsx
作用:包含新建文章表单,未在问题截图中展示
import { useState } from "react"; import {useNavigate} from 'react-router-dom'; export default function Post({currentUser}){ const[title, setTitle] = useState(""); const[content, setContent] = useState(""); const[tags, setTags] = useState(""); const user_id = currentUser.id const navigate = useNavigate(); function handleSubmit(e){ e.preventDefault(); const newPost = { title, content, user_id, tags_attributes: tags.split(',').map((el) => { return {name: el} }) } // 提交前校验所有字段已填写 if(title === "" || content === "" || user_id === "" || tags === ""){ alert("Please enter all the inputs!") }else( fetch(`/post`, { method: 'POST', headers: {"Content-Type": 'application/json'}, body: JSON.stringify(newPost), }).then((r) =>{ if (r.ok){ r.json().then(navigate('/profile')) alert("New post created!") }else{ alert("New post creation failed") } }) ) } return( <div className="post-form-container"> <form className="post-form" onSubmit={handleSubmit}> <label>Title</label><br/> <input className='title-input' type='text' onChange={(e) => setTitle(e.target.value)} value={title}> </input><br/> <label>Content</label><br/> <textarea className="content-input" type='text' onChange={(e) => setContent(e.target.value)} value={content} placeholder="Start typing~"> </textarea><br/> <label>Tags: seperated by commas</label><br/> <input className="tags-input" type='text' onChange={(e) => setTags(e.target.value)} value={tags}> </input><br/> <button className="post-btn" type="submit">Submit</button> </form> </div> ) }
后端代码(Ruby on Rails)
posts_controller.rb
作用:处理文章的新增、更新、删除请求
class PostsController < ApplicationController before_action :authorized skip_before_action :authorized, only: [:index, :show, :create] def show posts = User.find(params[:id]).posts render json: posts, include: :User end def index posts = Post.all render json: posts, include: :User end def create user = User.find_by(id: session[:user_id]) post = user.posts.create(post_params) if post.valid? render json: post, status: :created else render json: {error: post.errors.full_messages }, status: :unprocessable_entity end end def update post = Post.find_by(id: params[:id]) if post # 待实现:更新前对比标签数组,移除文章不存在的旧标签关联 post.update(post_params) render json: post, status: :accepted else render json: {error: "Post not found"}, status: :not_found end end def destroy post = Post.find_by(id: params[:id]) if post post.destroy render json: {} else render json: {error: "post not found"}, status: :not_found end end private def authorize return render json: { error: "Not authorized" }, status: :unauthorized unless session.include? :user_id end def post_params # params.require(:post).permit(:title, :content, tags_attributes: [:name]) params.permit(:title, :content, tags_attributes: [:name]) end end
Post.rb 模型
class Post < ApplicationRecord belongs_to :user has_many :post_tags, dependent: :destroy has_many :tags, through: :post_tags # 允许通过父级模型保存关联记录属性 accepts_nested_attributes_for :tags end
Tag.rb 模型
class Tag < ApplicationRecord has_many :post_tags has_many :posts, through: :post_tags end
post_tags.rb 中间表模型
class PostTag < ApplicationRecord belongs_to :post belongs_to :tag end
问题原因&修复方案
核心问题
- 标签表
Tag没有对name字段加唯一约束,允许创建同名标签 - 直接用
accepts_nested_attributes_for :tags传标签名时,Rails默认会新建标签记录,不会自动查找已存在的同名标签关联 - 更新文章时没有处理旧标签关联的逻辑,会把传入的所有标签都当成新记录创建
修复步骤
给标签表加唯一索引
执行命令生成迁移文件:rails generate migration AddUniqueIndexToTagsName编辑生成的迁移文件:
class AddUniqueIndexToTagsName < ActiveRecord::Migration[7.0] def change add_index :tags, :name, unique: true end end执行迁移:
rails db:migrate给Tag模型加名称唯一性校验,加上查找或创建同名标签的逻辑
修改tag.rb:class Tag < ApplicationRecord has_many :post_tags has_many :posts, through: :post_tags validates :name, presence: true, uniqueness: true end修改
Post.rb模型,重写标签属性赋值方法,自动复用已存在的标签:class Post < ApplicationRecord belongs_to :user has_many :post_tags, dependent: :destroy has_many :tags, through: :post_tags # 移除原来的accepts_nested_attributes_for :tags,自定义标签赋值逻辑 def tags_attributes=(tag_attributes) # 清空当前文章的旧标签关联,支持删除不需要的标签 self.post_tags.clear tag_attributes.each do |attr| # 去除标签名前后空格,过滤空值 tag_name = attr[:name].strip next if tag_name.blank? # 查找已存在的同名标签,不存在则新建 tag = Tag.find_or_create_by(name: tag_name) # 关联标签到当前文章,避免重复关联 self.tags << tag unless self.tags.include?(tag) end end end修正前端标签预填充的类型问题
postContainer.jsx里预填充标签时,setNewTags接收的是数组,但textarea绑定的value是字符串,最好预填充时就转成逗号分隔字符串,避免异常:// 原代码 // setNewTags(post.tags.map((x)=>x.name)); // 修改为 setNewTags(post.tags.map((x)=>x.name).join(','));
修改完成后重启Rails服务,再测试更新文章添加标签,就不会重复创建同名标签了。
内容的提问来源于stack exchange,提问作者Jeffrey

