Task 8: Implement Posts CRUD
Step 1: Create Post entity
Before we dive into code, it's essential to understand the concept of relational databases and the importance of relationships between different entities.
Relational Databases and Relations
Relational databases store data in tables, which can be thought of as analogous to classes in object-oriented programming. Each table represents a type of entity (e.g., users, posts, comments), and each row in a table represents an instance of that entity.
Relationships between entities are fundamental in relational databases. They define how one entity relates to another and allow us to perform complex queries across multiple tables. The most common types of relationships are:
- One-to-One: This type of relationship occurs when a row in one table is linked to one, and only one, row in another table. For example, in our database, we can introduce a profile table where each user will have a profile. This represents a one-to-one relationship.
- One-to-Many: Where a single row in one table can be linked to many rows in another table. This is the typical relationship between users and posts, where one user can have many posts.
- Many-to-One: The inverse of one-to-many, where many rows in one table are linked to a single row in another table.
- Many-to-Many: This refers to a relationship where rows in one table can be linked to multiple rows in another table, and vice versa. For example, if we were to introduce a "repost" feature (similar to retweeting), then a post could be reposted by many users, and a user can repost many posts. This captures a many-to-many relationship between users and (re)posts.
Currently, we're interested in the one-to-many relationship between users and posts, as one user can create many posts but each post is associated with one user.
By defining these relationships in our entities, we can leverage TypeORM's ability to automatically join tables and manage related data efficiently.
With this foundational knowledge, we can proceed to define the Post
entity in our application.
Create Post Entity and Set Up Relationship with User
Recall that entities in TypeORM are classes that map to database tables. Each entity corresponds to a table in the database, and instances of entities correspond to rows within that table.
To define the Post
entity and its relationship with the User
entity, follow these steps:
Create a new file named post.entity.ts
within a posts
directory in the src
folder. This file will define the Post
entity and its properties, which will correspond to the columns in the database table.
import { User } from 'src/user/user.entity';
import {
Column,
CreateDateColumn,
Entity,
JoinColumn,
ManyToOne,
PrimaryGeneratedColumn,
} from 'typeorm';
@Entity()
export class Post {
@PrimaryGeneratedColumn('uuid')
id: string;
@Column()
content: string;
@CreateDateColumn({ type: 'timestamptz', default: () => 'CURRENT_TIMESTAMP' })
timestamp: Date;
@Column({ nullable: true })
image: string;
@Column({ default: 0 })
likeCount: number;
@Column({ default: 0 })
commentCount: number;
@ManyToOne(() => User, (user) => user.posts)
@JoinColumn({ name: 'userId' })
user: User;
@Column()
userId: number;
}
In the Post
entity, we define a many-to-one relationship with the User
entity. This means that each post can be associated with one user, but each user can have many posts.
@ManyToOne(() => User, (user) => user.posts)
@JoinColumn({ name: 'userId' })
user: User;
@Column()
userId: number;
The @ManyToOne
decorator is used to define this relationship. The @JoinColumn
decorator specifies the name of the column that will hold the foreign key in the posts
table to the users
table.
💡 A foreign key is a column (or a set of columns) in a table that refers to the primary key of another table. It establishes a relationship between two tables by linking the data in one table to the data in another table.
The @JoinColumn({ name: 'userId' })
decorator in the Post
entity specifies the column name in the table that will be used to establish the foreign key relationship with the User
table.
In the context of the Post
entity, the userId
column is the foreign key that references the id
column of the User
entity. This allows us to associate each post with the corresponding user in the database. By doing so, we've also enabled referential integrity, ensuring that every post is associated with a valid user. TypeORM will automatically create the necessary foreign key constraint in the database and handle the retrieval of associated entities when querying entities.
To complete the relationship, update the User
entity to include a collection of Post
entities. This will allow us to access a user's posts directly from the User
entity.
// Add this import at the top of the file
import { Post } from 'src/posts/post.entity';
// Put this inside the User entity class
@OneToMany(() => Post, (post) => post.user)
posts: Post[];
The @OneToMany
decorator is used to establish the inverse side of the relationship, where a user can have multiple posts.
The () => Post, post => post.user
inside the @OneToMany
decorator defines the target entity and the inverse side of the relationship.
() => Post
: Specifies the target entity, which is thePost
entity in this case.post => post.user
: Defines the property on thePost
entity that references theUser
entity. In this case, theuser
property on thePost
entity represents the user who created the post.
Similarly, the () => User, user => user.posts
inside the @ManyToOne
decorator in the Post
entity specifies the target entity and the inverse side of the relationship.
() => User
indicates that the target entity is theUser
entity.user => user.posts
establishes the inverse side of the relationship, indicating the property on theUser
entity that references thePost
entity. In this case, theposts
property on theUser
entity represents the posts created by the user.
By specifying these configurations, TypeORM knows how to establish the relationship between the User
entity and the Post
entity. It allows us to access a user's posts through the posts
property on the User
entity, just as we would be able to access a user who created the post through the Post
entity.
Aside: The @CreateDateColumn({ type: 'timestamptz', default: () => 'CURRENT_TIMESTAMP' })
decorator is applied to the timestamp
property of the Post
entity. It specifies that the column should have the data type timestamptz
, which represents a timestamp with time zone.
The default: () => 'CURRENT_TIMESTAMP'
option sets the default value of the column to the current timestamp. This means that if no value is provided for the timestamp
property when creating a new Post
entity, it will automatically be populated with the current timestamp.
Using the @CreateDateColumn
decorator simplifies tracking the creation time of a post without requiring manual timestamp value assignment in the code.
Aside: The @PrimaryGeneratedColumn('uuid')
specifies that the id
column in the Post
entity will be a primary key column with a UUID (Universally Unique Identifier) data type. The uuid
data type is commonly used for primary keys because it generates unique identifiers that are highly unlikely to collide with each other.
In contrast, the User
entity uses @PrimaryGeneratedColumn()
without any arguments, which means that the primary key values for users will be auto-incrementing integers. This is a common choice for primary keys in many scenarios.
The decision to use auto-incrementing integers or UUIDs for primary keys depends on the specific requirements of the application. Auto-incrementing integers are generally more compact and easier to read, while UUIDs provide better uniqueness guarantees across distributed systems.
Please stage and commit the changes:
git add .
git commit -m "Create Post entity"
Step 2: Set up Posts module, service, and controller
Let's proceed with setting up the Posts module, service, and controller. We will now use the Nest CLI to streamline this process. The CLI will help us quickly generate the necessary files and boilerplate code.
Generate Posts Module
First, we will generate the Posts module. This module will act as a dedicated space for all the posts-related functionalities. Use the Nest CLI to generate the module:
nest generate module posts
This command will create a posts.module.ts
file inside a posts
directory. It also updates the app.module.ts
to include the PostsModule
.
Generate Posts Service
Next, we'll generate the Posts service. The service will contain the business logic for handling post operations such as creation, retrieval, updating, and deletion.
nest generate service posts
The CLI will create posts.service.ts
and posts.service.spec.ts
files in the posts
directory. The posts.service.spec.ts
file is for testing the service operations, while the posts.service.ts
file is where you will implement the logic to interact with the database.
Generate Posts Controller
Lastly, we will generate the Posts controller. The controller will handle incoming HTTP requests and delegate to the Posts service for processing.
nest generate controller posts
This command will create posts.controller.ts
and posts.controller.spec.ts
files in the posts
directory.
After running these commands, the posts
folder should have the following files:
src
└── posts
├── post.entity.ts
├── posts.controller.spec.ts
├── posts.controller.ts
├── posts.module.ts
├── posts.service.spec.ts
└── posts.service.ts
Now, let's begin preparing these files before we dive into implementing each one.
Posts Module
In posts.module.ts
, import the TypeOrmModule
and the Post
entity to register it:
import { Module } from '@nestjs/common';
import { PostsService } from './posts.service';
import { PostsController } from './posts.controller';
import { TypeOrmModule } from '@nestjs/typeorm'; // <-- add this line
import { Post } from './post.entity'; // <-- add this line
@Module({
imports: [TypeOrmModule.forFeature([Post])], // <-- add this line
providers: [PostsService],
controllers: [PostsController],
})
export class PostsModule {}
Posts Service
In posts.service.ts
, inject the repository for the Post
entity:
import { Injectable } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { Repository } from 'typeorm';
import { Post } from './post.entity';
@Injectable()
export class PostsService {
constructor(
@InjectRepository(Post)
private postRepository: Repository<Post>,
) {}
// We'll add methods for handling CRUD operations here
}
Posts Controller
In posts.controller.ts
, inject the service for the Post
entity:
import { Controller } from '@nestjs/common';
import { PostsService } from './posts.service';
@Controller('posts')
export class PostsController {
constructor(private readonly postsService: PostsService) {}
// We will add handlers for CRUD endpoints here
}
These are the basic setup steps for the Posts module, service, and controller. We will fill in the implementations for each CRUD operation in the upcoming steps.
Please stage and commit these changes:
git add .
git commit -m "Set up Posts module, service, and controller"
Step 3: Implement POST endpoint
This step involves creating DTOs, service method, and controller action to handle the creation of a new post.
1. Create Post DTO
First, we'll create a DTO to define the structure of the data for creating a new post. This will be used to capture the incoming request data.
Create a file named create-post.dto.ts
within the posts
directory and define the DTO:
import { IsNotEmpty, IsOptional, IsString } from 'class-validator';
export class CreatePostDto {
@IsString()
@IsNotEmpty({ message: 'Content cannot be empty' })
content: string;
@IsOptional()
@IsString()
image?: string;
}
We're using class validators to ensure that the content
field is a non-empty string, and the image
field is optional.
2. Post Response DTO
Create a file named post-response.dto.ts
within the posts
directory to define the structure of the data when a post is returned in a response.
export class PostResponseDto {
id: string;
content: string;
timestamp: Date;
image?: string;
likeCount: number;
commentCount: number;
}
This DTO will be used when we want to send back post data in the response.
Notice that this response does not include information about the user who created the post. It might be beneficial to include the user information in the PostResponseDto
. If you recall, on the frontend with our mock data, we had a mock API function called fetchPosts
that would return an array of posts with user data (with the type Promise<PostWithUserData[]>
). We can do something similar when implementing the GET endpoint, so that user data is included in each post. However, for now, I have decided to exclude that data.
2. Update Posts Service
Now, update the PostsService
to include a method to create a new post. Add the following create
operation to the PostsService
class (and update the import statements as needed):
async create(createPostDto: CreatePostDto, userId: number): Promise<Post> {
const post = await this.postRepository.create({
...createPostDto,
userId,
});
return this.postRepository.save(post);
}
Here, we're creating a new post and saving it using the repository. The user ID is assumed to be passed along with the request, which we will handle in the controller.
3. Update Posts Controller
Next, let's create the controller action to handle the POST request.
Add the following create
operation to PostsController
class (and update the import statements as needed):
@Post()
async create(@Body() createPostDto: CreatePostDto): Promise<PostResponseDto> {
const userId = 1; // TODO: get userId from JWT token
const post = await this.postsService.create(createPostDto, userId);
delete post.userId;
return post;
}
In the final implementation, the userId
should come from the authenticated user context, but for now, we can hardcode it for testing purposes.
4. Testing with Postman
With the POST endpoint implemented, you can now test it using Postman.
-
Set up a new POST request to your server's
/posts
endpoint. -
Set the body type to
raw
and format toJSON
. -
Include the content (and optionally an image URL) in the JSON body of the request. For example, something like this:
{ "content": "This is a new post", }
-
Send the request and ensure that you receive the expected response, which should be the new post object with an ID, content, etc.
Once you have tested the endpoint to ensure that it's functioning correctly, commit your changes:
git add .
git commit -m "Implement POST endpoint for creating posts"
This completes the implementation of the POST endpoint for creating new posts.
Step 4: Implement GET (one) endpoint
We'll now create the functionality to retrieve a single post by its ID. This involves adding a new method to the service and a new route to the controller.
1. Update Posts Service
First, we need to update the PostsService
to include a method that retrieves a post by its ID. Here’s how you might add this functionality:
In posts.service.ts
, add the following method (and update the import statements as needed):
async findOne(id: string): Promise<Post | null> {
return this.postRepository.findOneBy({ id });
}
This method will try to find a single post by its ID. If it doesn't find a post with the given ID, it returns null
.
2. Update Posts Controller
Next, we'll update the PostsController
to handle requests to get a single post by ID.
In posts.controller.ts
, add the following operation (and update the import statements as needed):
@Get(':id')
async findOne(@Param('id') id: string): Promise<PostResponseDto> {
const post = await this.postsService.findOne(id);
if (!post) {
throw new NotFoundException(`Post with ID ${id} not found`);
}
delete post.userId;
return post;
}
Here, we are adding a new route that will capture the id
parameter from the URL. The controller calls the service's findOne
method. If the service is unable to find a post with the given ID, it throws a NotFoundException
. If the target post is found, we exclude the userId
before returning it, as our PostResponseDto
does not include the user ID.
The @Param('id')
decorator is used in the controller method to retrieve the value of the id
parameter from the URL. It captures the id
parameter from the route /posts/:id
, where :id
represents the dynamic value that will be passed in the URL. This allows you to access the dynamic parts of the URL and use them in your controller logic.
For example, if the route is /posts/123
, the @Param('id')
decorator will retrieve the value 123
and assign it to the id
variable in the controller method.
3. Testing with Postman
Test this new functionality using Postman by setting up a new GET request:
- Set up a new GET request to your server's
/posts/{id}
endpoint, replacing{id}
with the actual ID of the post you want to retrieve. - Send the request and ensure that you receive the expected response, which should be the post object corresponding to the ID you've provided.
If the post is found, it should return the post data; if not, it should return a 404 Not Found status.
After confirming the route works as expected, make sure to commit your changes:
git add .
git commit -m "Implement GET endpoint for retrieving a single post"
This completes the implementation of the GET endpoint for retrieving a specific post by its ID.
Step 5: Implement PATCH endpoint
In many social platforms, updates to posts may either be disallowed after posting or permitted within a certain timeframe. For the purposes of our Posts application, we will demonstrate the implementation of an update operation without such restrictions.
1. Update Post DTO
First, let's define a DTO for updating a post. This DTO will allow partial updates.
Create a file named update-post.dto.ts
within the posts
directory and define the DTO:
import { IsOptional, IsString } from 'class-validator';
export class UpdatePostDto {
@IsOptional()
@IsString()
content?: string;
@IsOptional()
@IsString()
image?: string;
}
This DTO uses the IsOptional
decorator, allowing us to send only the fields we want to update without requiring all the properties that a CreatePostDto
might require.
2. Update Posts Service
Next, update the PostsService
to include a method for updating an existing post:
In posts.service.ts
, add the update
method (and update the import statements as needed):
async update(id: string, updatePostDto: UpdatePostDto): Promise<Post | null> {
const post = await this.postRepository.preload({ id, ...updatePostDto });
if (!post) {
return null;
}
return this.postRepository.save(post);
}
The preload
method creates a new entity based on the object passed, where the id
is required for TypeORM to determine which entity to load from the database. It then merges the provided updatePostDto
with the found entity. If the entity does not exist in the database, preload
returns undefined
, and we return null
.
The save
operation will update the entity if it already exists in the database (otherwise it will add the entity to the database).
3. Update Posts Controller
Now, let's update the PostsController
to handle the PATCH request:
In posts.controller.ts
, add the update
method (and update the import statements as needed):
@Patch(':id')
async update(
@Param('id') id: string,
@Body() updatePostDto: UpdatePostDto,
): Promise<PostResponseDto> {
const post = await this.postsService.update(id, updatePostDto);
if (!post) {
throw new NotFoundException(`Post with ID ${id} not found`);
}
delete post.userId;
return post;
}
Here, we invoke the update
method on the PostsService
class to update the post. If the post does not exist in the database, the service will return null
, and we throw a NotFoundException
.
If the post exists, it will be updated. We remove the userId
from the updated post to exclude it from the response, ensuring consistency with the previous endpoints.
Aside: We could have used PUT
for update instead of PATCH
. The main difference between the PUT
and PATCH
HTTP operations lies in their intended use cases for updating resources.
PUT
is used to completely replace an existing resource with a new representation. When making a PUT
request, the entire resource is sent in the request payload, including any fields that are not being updated. The server then replaces the existing resource with the new representation provided in the request.
On the other hand, PATCH
is used to make partial updates to an existing resource. When making a PATCH
request, only the specific fields that need to be updated are sent in the request payload. The server then applies these updates to the existing resource, leaving the other fields unchanged.
In summary:
- Use
PUT
when you want to completely replace a resource. - Use
PATCH
when you want to make partial updates to a resource.
In our case, it is more appropriate to use a PATCH
request to update a post, as we only make partial updates to it.
4. Testing with Postman
You can test the PATCH endpoint as follows:
- Set up a PATCH request in Postman to your server's
/posts/{id}
endpoint, with{id}
being the ID of the post to update. - Set the body type to
raw
and the format toJSON
. - Include the fields you wish to update in the JSON body of the request.
- Send the request and confirm that you receive the updated post object as a response.
After verifying the endpoint's functionality, commit your changes:
git add .
git commit -m "Implement PATCH endpoint for updating posts"
This wraps up the implementation of the PATCH endpoint, allowing for updates to posts.
Step 6: Implement DELETE endpoint
As mentioned earlier, on certain social platforms, you might not be able to delete your posts after posting or you might have a specific time limit to do so. However, for our Posts application, we will show how to delete a post without any restrictions.
The DELETE endpoint allows users to remove a post from the system. Here's how you can implement this functionality in your NestJS application.
1. Update Posts Service
First, update the PostsService
to include a method for deleting a post. In posts.service.ts
, add:
async remove(id: string): Promise<Post | null> {
const post = await this.findOne(id);
if (!post) {
return null;
}
return this.postRepository.remove(post);
}
The remove
method deletes the post and returns the deleted post.
2. Update Posts Controller
Now, update the PostsController
to handle the DELETE request. In posts.controller.ts
, add:
@Delete(':id')
async remove(
@Param('id') id: string,
): Promise<{ statusCode: number; message: string }> {
const post = await this.postsService.remove(id);
if (!post) {
throw new NotFoundException(`Post with ID ${id} not found`);
}
return {
statusCode: 200,
message: 'Post deleted successfully',
};
}
This action invokes the remove
method of the PostsService
. It uses the @Param
decorator to extract the id
from the URL parameters.
The function returns NotFoundException
if the post does not exist. If the deletion is successful, a message indicating success is returned to the client.
3. Testing with Postman
You can test the DELETE endpoint using Postman:
- Set up a DELETE request to your server's
/posts/{id}
endpoint, where{id}
is the ID of the post you want to delete. - Send the request and confirm that the post is deleted. The response should be a 200 OK status with no content.
After testing the endpoint and confirming it works as expected, commit your changes:
git add .
git commit -m "Implement DELETE endpoint for posts"
Step 7: Implement GET (all) endpoint
This step involves creating an endpoint that allows the retrieval of all posts from the database. This is a common feature in social media platforms where users can scroll through a feed of posts.
1. Update Posts Service
First, update the PostsService
to include a method for retrieving all posts. In posts.service.ts
, add the following findAll
method:
async findAll(): Promise<Post[]> {
return this.postRepository.find();
}
Here, the findAll
method uses the repository's find
function to retrieve all posts.
2. Update Posts Controller
Now, update the PostsController
to handle the GET request for all posts. In posts.controller.ts
, add the following action:
@Get()
async findAll(): Promise<PostResponseDto[]> {
const posts = await this.postsService.findAll();
return posts.map((post) => {
delete post.userId;
return post;
});
}
This action retrieves all posts using the findAll
method of the PostsService
. It then maps over each post to format the response.
3. Testing with Postman
To ensure your GET endpoint is working as expected, you can test it using Postman:
- Set up a new GET request to your server's
/posts
endpoint. - Send the request and confirm that you receive an array of post objects, each including the respective author's data.
If the endpoint is functioning correctly, you'll get a list of posts (with the author’s userId
excluded).
After testing the functionality, remember to commit your changes to version control:
git add .
git commit -m "Implement GET endpoint for retrieving all posts"