Skip to content

Commit b765065

Browse files
committed
Second revision: Add PostgreSQL database integration with CRUD operations and automatic Typesense synchronization
1 parent b8e1cac commit b765065

10 files changed

Lines changed: 956 additions & 7 deletions

File tree

typesense-gin-full-text-search/.env.example

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,3 +7,10 @@ TYPESENSE_PORT=8108
77
TYPESENSE_PROTOCOL=http
88
TYPESENSE_API_KEY=xyz
99
TYPESENSE_COLLECTION=books
10+
11+
# Database Credentials
12+
DB_HOST=xxx
13+
DB_PORT=xxx
14+
DB_USER=xxx
15+
DB_PASSWORD=xxx
16+
DB_NAME=xxx

typesense-gin-full-text-search/go.mod

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,12 @@ require (
2121
github.com/goccy/go-json v0.10.5 // indirect
2222
github.com/goccy/go-yaml v1.19.2 // indirect
2323
github.com/google/uuid v1.6.0 // indirect
24+
github.com/jackc/pgpassfile v1.0.0 // indirect
25+
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect
26+
github.com/jackc/pgx/v5 v5.8.0 // indirect
27+
github.com/jackc/puddle/v2 v2.2.2 // indirect
28+
github.com/jinzhu/inflection v1.0.0 // indirect
29+
github.com/jinzhu/now v1.1.5 // indirect
2430
github.com/joho/godotenv v1.5.1 // indirect
2531
github.com/json-iterator/go v1.1.12 // indirect
2632
github.com/klauspost/cpuid/v2 v2.3.0 // indirect
@@ -48,4 +54,6 @@ require (
4854
golang.org/x/text v0.34.0 // indirect
4955
golang.org/x/tools v0.42.0 // indirect
5056
google.golang.org/protobuf v1.36.11 // indirect
57+
gorm.io/driver/postgres v1.6.0 // indirect
58+
gorm.io/gorm v1.31.1 // indirect
5159
)

typesense-gin-full-text-search/go.sum

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -39,6 +39,18 @@ github.com/goccy/go-yaml v1.19.2/go.mod h1:XBurs7gK8ATbW4ZPGKgcbrY1Br56PdM69F7Lk
3939
github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg=
4040
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
4141
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
42+
github.com/jackc/pgpassfile v1.0.0 h1:/6Hmqy13Ss2zCq62VdNG8tM1wchn8zjSGOBJ6icpsIM=
43+
github.com/jackc/pgpassfile v1.0.0/go.mod h1:CEx0iS5ambNFdcRtxPj5JhEz+xB6uRky5eyVu/W2HEg=
44+
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 h1:iCEnooe7UlwOQYpKFhBabPMi4aNAfoODPEFNiAnClxo=
45+
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761/go.mod h1:5TJZWKEWniPve33vlWYSoGYefn3gLQRzjfDlhSJ9ZKM=
46+
github.com/jackc/pgx/v5 v5.8.0 h1:TYPDoleBBme0xGSAX3/+NujXXtpZn9HBONkQC7IEZSo=
47+
github.com/jackc/pgx/v5 v5.8.0/go.mod h1:QVeDInX2m9VyzvNeiCJVjCkNFqzsNb43204HshNSZKw=
48+
github.com/jackc/puddle/v2 v2.2.2 h1:PR8nw+E/1w0GLuRFSmiioY6UooMp6KJv0/61nB7icHo=
49+
github.com/jackc/puddle/v2 v2.2.2/go.mod h1:vriiEXHvEE654aYKXXjOvZM39qJ0q+azkZFrfEOc3H4=
50+
github.com/jinzhu/inflection v1.0.0 h1:K317FqzuhWc8YvSVlFMCCUb36O/S9MCKRDI7QkRKD/E=
51+
github.com/jinzhu/inflection v1.0.0/go.mod h1:h+uFLlag+Qp1Va5pdKtLDYj+kHp5pxUVkryuEj+Srlc=
52+
github.com/jinzhu/now v1.1.5 h1:/o9tlHleP7gOFmsnYNz3RGnqzefHA47wQpKrrdTIwXQ=
53+
github.com/jinzhu/now v1.1.5/go.mod h1:d3SSVoowX0Lcu0IBviAWJpolVfI5UJVZZ7cO71lE/z8=
4254
github.com/joho/godotenv v1.5.1 h1:7eLL/+HRGLY0ldzfGMeQkb7vMd0as4CfYvUVzLqw0N0=
4355
github.com/joho/godotenv v1.5.1/go.mod h1:f4LDr5Voq0i2e/R5DDNOoa2zzDfwtkZa6DnEwAbqwq4=
4456
github.com/json-iterator/go v1.1.12 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnrnM=
@@ -81,6 +93,7 @@ github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSS
8193
github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo=
8294
github.com/stretchr/objx v0.5.2/go.mod h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/8L+MA=
8395
github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
96+
github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
8497
github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
8598
github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU=
8699
github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo=
@@ -120,3 +133,7 @@ google.golang.org/protobuf v1.36.11/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j
120133
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
121134
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
122135
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
136+
gorm.io/driver/postgres v1.6.0 h1:2dxzU8xJ+ivvqTRph34QX+WrRaJlmfyPqXmoGVjMBa4=
137+
gorm.io/driver/postgres v1.6.0/go.mod h1:vUw0mrGgrTK+uPHEhAdV4sfFELrByKVGnaVRkXDhtWo=
138+
gorm.io/gorm v1.31.1 h1:7CA8FTFz/gRfgqgpeKIBcervUn3xSyPUmr6B2WXJ7kg=
139+
gorm.io/gorm v1.31.1/go.mod h1:XyQVbO2k6YkOis7C2437jSit3SsDK72s7n7rsSHd+Gs=
Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,39 @@
1+
package models
2+
3+
import (
4+
"fmt"
5+
"time"
6+
7+
"gorm.io/gorm"
8+
)
9+
10+
type Book struct {
11+
ID uint `gorm:"primaryKey" json:"id"`
12+
Title string `json:"title"`
13+
Authors []string `gorm:"serializer:json" json:"authors"`
14+
PublicationYear int `json:"publication_year"`
15+
AverageRating float64 `json:"average_rating"`
16+
ImageUrl string `json:"image_url"`
17+
RatingsCount int `json:"ratings_count"`
18+
CreatedAt time.Time `json:"created_at"`
19+
UpdatedAt time.Time `json:"updated_at"`
20+
DeletedAt gorm.DeletedAt `gorm:"index" json:"deleted_at,omitempty"`
21+
}
22+
23+
func (b *Book) GetTypesenseID() string {
24+
return fmt.Sprintf("book_%d", b.ID)
25+
}
26+
27+
func (b *Book) BeforeCreate(tx *gorm.DB) error {
28+
return nil
29+
}
30+
31+
func (b *Book) BeforeUpdate(tx *gorm.DB) error {
32+
b.UpdatedAt = time.Now()
33+
return nil
34+
}
35+
36+
func (b *Book) BeforeDelete(tx *gorm.DB) error {
37+
b.UpdatedAt = time.Now()
38+
return nil
39+
}
Lines changed: 196 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,196 @@
1+
package routes
2+
3+
import (
4+
"context"
5+
"log"
6+
"net/http"
7+
8+
"github.com/gin-gonic/gin"
9+
"github.com/typesense/code-samples/typesense-gin-full-text-search/models"
10+
"github.com/typesense/code-samples/typesense-gin-full-text-search/utils"
11+
)
12+
13+
// SetupBookRoutes configures all book CRUD routes
14+
func SetupBookRoutes(router *gin.Engine) {
15+
books := router.Group("/books")
16+
{
17+
books.POST("", createBook)
18+
books.GET("/:id", getBook)
19+
books.GET("", getAllBooks)
20+
books.PUT("/:id", updateBook)
21+
books.DELETE("/:id", deleteBook)
22+
}
23+
}
24+
25+
// createBook creates a new book in the database and syncs to Typesense
26+
func createBook(c *gin.Context) {
27+
var book models.Book
28+
29+
if err := c.ShouldBindJSON(&book); err != nil {
30+
c.JSON(http.StatusBadRequest, gin.H{
31+
"error": "Invalid request body: " + err.Error(),
32+
})
33+
return
34+
}
35+
36+
// Save to database (source of truth)
37+
if err := utils.SaveBook(c.Request.Context(), &book); err != nil {
38+
c.JSON(http.StatusInternalServerError, gin.H{
39+
"error": "Failed to create book: " + err.Error(),
40+
})
41+
return
42+
}
43+
44+
// Sync to Typesense asynchronously (non-blocking)
45+
go func(bookCopy models.Book) {
46+
ctx := context.Background()
47+
if err := utils.SyncBookOnUpdate(ctx, &bookCopy); err != nil {
48+
log.Printf("Async Typesense sync failed for book %d: %v", bookCopy.ID, err)
49+
}
50+
}(book)
51+
52+
c.JSON(http.StatusCreated, gin.H{
53+
"message": "Book created successfully",
54+
"book": book,
55+
})
56+
}
57+
58+
// getBook retrieves a single book by ID
59+
func getBook(c *gin.Context) {
60+
var uri struct {
61+
ID uint `uri:"id" binding:"required"`
62+
}
63+
64+
if err := c.ShouldBindUri(&uri); err != nil {
65+
c.JSON(http.StatusBadRequest, gin.H{
66+
"error": "Invalid book ID",
67+
})
68+
return
69+
}
70+
71+
book, err := utils.GetBookByID(c.Request.Context(), uri.ID)
72+
if err != nil {
73+
c.JSON(http.StatusNotFound, gin.H{
74+
"error": "Book not found",
75+
})
76+
return
77+
}
78+
79+
c.JSON(http.StatusOK, gin.H{
80+
"book": book,
81+
})
82+
}
83+
84+
// getAllBooks retrieves all books from the database
85+
func getAllBooks(c *gin.Context) {
86+
books, err := utils.GetAllBooks(c.Request.Context())
87+
if err != nil {
88+
c.JSON(http.StatusInternalServerError, gin.H{
89+
"error": "Failed to fetch books: " + err.Error(),
90+
})
91+
return
92+
}
93+
94+
c.JSON(http.StatusOK, gin.H{
95+
"count": len(books),
96+
"books": books,
97+
})
98+
}
99+
100+
// updateBook updates an existing book and syncs to Typesense
101+
func updateBook(c *gin.Context) {
102+
var uri struct {
103+
ID uint `uri:"id" binding:"required"`
104+
}
105+
106+
if err := c.ShouldBindUri(&uri); err != nil {
107+
c.JSON(http.StatusBadRequest, gin.H{
108+
"error": "Invalid book ID",
109+
})
110+
return
111+
}
112+
113+
// Fetch existing book
114+
book, err := utils.GetBookByID(c.Request.Context(), uri.ID)
115+
if err != nil {
116+
c.JSON(http.StatusNotFound, gin.H{
117+
"error": "Book not found",
118+
})
119+
return
120+
}
121+
122+
// Bind updated data directly to existing book
123+
if err := c.ShouldBindJSON(&book); err != nil {
124+
c.JSON(http.StatusBadRequest, gin.H{
125+
"error": "Invalid request body: " + err.Error(),
126+
})
127+
return
128+
}
129+
130+
// Preserve the ID (in case it was in the JSON)
131+
book.ID = uri.ID
132+
133+
// Save to database
134+
if err := utils.SaveBook(c.Request.Context(), book); err != nil {
135+
c.JSON(http.StatusInternalServerError, gin.H{
136+
"error": "Failed to update book: " + err.Error(),
137+
})
138+
return
139+
}
140+
141+
// Sync to Typesense asynchronously (non-blocking)
142+
go func(bookCopy models.Book) {
143+
ctx := context.Background()
144+
if err := utils.SyncBookOnUpdate(ctx, &bookCopy); err != nil {
145+
log.Printf("Async Typesense sync failed for book %d: %v", bookCopy.ID, err)
146+
}
147+
}(*book)
148+
149+
c.JSON(http.StatusOK, gin.H{
150+
"message": "Book updated successfully",
151+
"book": book,
152+
})
153+
}
154+
155+
// deleteBook soft-deletes a book and removes it from Typesense
156+
func deleteBook(c *gin.Context) {
157+
var uri struct {
158+
ID uint `uri:"id" binding:"required"`
159+
}
160+
161+
if err := c.ShouldBindUri(&uri); err != nil {
162+
c.JSON(http.StatusBadRequest, gin.H{
163+
"error": "Invalid book ID",
164+
})
165+
return
166+
}
167+
168+
// Check if book exists
169+
_, err := utils.GetBookByID(c.Request.Context(), uri.ID)
170+
if err != nil {
171+
c.JSON(http.StatusNotFound, gin.H{
172+
"error": "Book not found",
173+
})
174+
return
175+
}
176+
177+
// Soft delete from database
178+
if err := utils.DeleteBook(c.Request.Context(), uri.ID); err != nil {
179+
c.JSON(http.StatusInternalServerError, gin.H{
180+
"error": "Failed to delete book: " + err.Error(),
181+
})
182+
return
183+
}
184+
185+
// Remove from Typesense asynchronously (non-blocking)
186+
go func(bookID uint) {
187+
ctx := context.Background()
188+
if err := utils.SyncBookDeletionOnDelete(ctx, bookID); err != nil {
189+
log.Printf("Async Typesense deletion failed for book %d: %v", bookID, err)
190+
}
191+
}(uri.ID)
192+
193+
c.JSON(http.StatusOK, gin.H{
194+
"message": "Book deleted successfully",
195+
})
196+
}

typesense-gin-full-text-search/routes/search.go

Lines changed: 68 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ package routes
22

33
import (
44
"net/http"
5+
"time"
56

67
"github.com/gin-gonic/gin"
78
"github.com/typesense/code-samples/typesense-gin-full-text-search/utils"
@@ -13,6 +14,10 @@ import (
1314
func SetupSearchRoutes(router *gin.Engine) {
1415
// Simple search endpoint
1516
router.GET("/search", searchBooks)
17+
18+
// Sync endpoints for database-to-Typesense synchronization
19+
router.POST("/sync", syncDatabaseToTypesense)
20+
router.GET("/sync/status", getSyncStatus)
1621
}
1722

1823
// searchBooks handles the search request
@@ -56,3 +61,66 @@ func searchBooks(c *gin.Context) {
5661
"facet_counts": result.FacetCounts,
5762
})
5863
}
64+
65+
// syncDatabaseToTypesense triggers an immediate sync from database to Typesense
66+
func syncDatabaseToTypesense(c *gin.Context) {
67+
ctx := c.Request.Context()
68+
69+
// Get last sync time from global state
70+
lastSyncTime := utils.GetLastSyncTime()
71+
72+
// Perform regular book sync (inserts and updates)
73+
newSyncTime, err := utils.SyncBooksToTypesense(ctx, lastSyncTime)
74+
if err != nil {
75+
c.JSON(http.StatusInternalServerError, gin.H{
76+
"error": "Sync failed",
77+
"message": err.Error(),
78+
})
79+
return
80+
}
81+
82+
// Handle soft deletes
83+
deletedBooks, err := utils.GetDeletedBooks(ctx, lastSyncTime)
84+
if err != nil {
85+
c.JSON(http.StatusInternalServerError, gin.H{
86+
"error": "Failed to fetch deleted books",
87+
"message": err.Error(),
88+
})
89+
return
90+
}
91+
92+
if len(deletedBooks) > 0 {
93+
deletedIDs := make([]uint, 0, len(deletedBooks))
94+
for _, book := range deletedBooks {
95+
deletedIDs = append(deletedIDs, book.ID)
96+
}
97+
98+
if err := utils.SyncSoftDeletesToTypesense(ctx, deletedIDs); err != nil {
99+
c.JSON(http.StatusInternalServerError, gin.H{
100+
"error": "Failed to sync deletions",
101+
"message": err.Error(),
102+
})
103+
return
104+
}
105+
}
106+
107+
// Update global sync time
108+
utils.SetLastSyncTime(newSyncTime)
109+
110+
c.JSON(http.StatusOK, gin.H{
111+
"message": "Sync completed",
112+
"newSyncTime": newSyncTime.Format(time.RFC3339),
113+
"syncedAt": time.Now().Format(time.RFC3339),
114+
"deletedBooks": len(deletedBooks),
115+
})
116+
}
117+
118+
// getSyncStatus returns the current sync status
119+
func getSyncStatus(c *gin.Context) {
120+
lastSyncTime := utils.GetLastSyncTime()
121+
122+
c.JSON(http.StatusOK, gin.H{
123+
"lastSyncTime": lastSyncTime.Format(time.RFC3339),
124+
"syncWorkerRunning": utils.IsSyncWorkerRunning(),
125+
})
126+
}

0 commit comments

Comments
 (0)