diff --git a/db/query/geometries.sql b/db/query/geometries.sql index 04c4a4c..b3b0144 100644 --- a/db/query/geometries.sql +++ b/db/query/geometries.sql @@ -161,3 +161,23 @@ WHERE geometry_id = $1; -- name: DeleteEntityGeometry :exec DELETE FROM entity_geometries WHERE entity_id = $1 AND geometry_id = $2; + +-- name: GetEntityGeometriesByPairs :many +SELECT + e.id AS entity_id, + e.name AS entity_name, + e.description AS entity_description, + g.id AS geometry_id, + g.geo_type, + g.draw_geometry, + g.binding, + g.time_start, + g.time_end +FROM ( + SELECT unnest(@entity_ids::uuid[]) as eid, unnest(@geometry_ids::uuid[]) as gid +) as pairs +JOIN entities e ON e.id = pairs.eid +JOIN entity_geometries eg ON eg.entity_id = e.id AND eg.geometry_id = pairs.gid +JOIN geometries g ON g.id = pairs.gid +WHERE e.is_deleted = false +AND g.is_deleted = false; diff --git a/internal/dtos/request/geometry.go b/internal/dtos/request/geometry.go index c96dee6..ecb69a7 100644 --- a/internal/dtos/request/geometry.go +++ b/internal/dtos/request/geometry.go @@ -11,9 +11,7 @@ type SearchGeometryDto struct { } type SearchGeometriesByEntityNameDto struct { - // Entity name keyword for searching. FE will render the result list by entity name. Name string `json:"name" query:"name" validate:"required,max=255"` - // Cursor is entity UUID (id < cursor). Cursor string `json:"cursor" query:"cursor" validate:"omitempty,uuid"` Limit int `json:"limit" query:"limit" validate:"omitempty,min=1,max=100"` } diff --git a/internal/dtos/response/geometryEntitySearch.go b/internal/dtos/response/geometryEntitySearch.go index 7763482..8d538f8 100644 --- a/internal/dtos/response/geometryEntitySearch.go +++ b/internal/dtos/response/geometryEntitySearch.go @@ -2,8 +2,7 @@ package response import "encoding/json" -// SearchGeometriesByEntityNameResponse groups geometries by matched entities. -// Cursor pagination is based on entity_id (descending). + type SearchGeometriesByEntityNameResponse struct { Items []*EntityGeometriesSearchItem `json:"items"` NextCursor string `json:"next_cursor,omitempty"` diff --git a/internal/gen/sqlc/geometries.sql.go b/internal/gen/sqlc/geometries.sql.go index 0fd9f72..d114d35 100644 --- a/internal/gen/sqlc/geometries.sql.go +++ b/internal/gen/sqlc/geometries.sql.go @@ -191,6 +191,74 @@ func (q *Queries) DeleteGeometry(ctx context.Context, id pgtype.UUID) error { return err } +const getEntityGeometriesByPairs = `-- name: GetEntityGeometriesByPairs :many +SELECT + e.id AS entity_id, + e.name AS entity_name, + e.description AS entity_description, + g.id AS geometry_id, + g.geo_type, + g.draw_geometry, + g.binding, + g.time_start, + g.time_end +FROM ( + SELECT unnest($1::uuid[]) as eid, unnest($2::uuid[]) as gid +) as pairs +JOIN entities e ON e.id = pairs.eid +JOIN entity_geometries eg ON eg.entity_id = e.id AND eg.geometry_id = pairs.gid +JOIN geometries g ON g.id = pairs.gid +WHERE e.is_deleted = false +AND g.is_deleted = false +` + +type GetEntityGeometriesByPairsParams struct { + EntityIds []pgtype.UUID `json:"entity_ids"` + GeometryIds []pgtype.UUID `json:"geometry_ids"` +} + +type GetEntityGeometriesByPairsRow struct { + EntityID pgtype.UUID `json:"entity_id"` + EntityName string `json:"entity_name"` + EntityDescription pgtype.Text `json:"entity_description"` + GeometryID pgtype.UUID `json:"geometry_id"` + GeoType int16 `json:"geo_type"` + DrawGeometry json.RawMessage `json:"draw_geometry"` + Binding []byte `json:"binding"` + TimeStart pgtype.Int4 `json:"time_start"` + TimeEnd pgtype.Int4 `json:"time_end"` +} + +func (q *Queries) GetEntityGeometriesByPairs(ctx context.Context, arg GetEntityGeometriesByPairsParams) ([]GetEntityGeometriesByPairsRow, error) { + rows, err := q.db.Query(ctx, getEntityGeometriesByPairs, arg.EntityIds, arg.GeometryIds) + if err != nil { + return nil, err + } + defer rows.Close() + items := []GetEntityGeometriesByPairsRow{} + for rows.Next() { + var i GetEntityGeometriesByPairsRow + if err := rows.Scan( + &i.EntityID, + &i.EntityName, + &i.EntityDescription, + &i.GeometryID, + &i.GeoType, + &i.DrawGeometry, + &i.Binding, + &i.TimeStart, + &i.TimeEnd, + ); err != nil { + return nil, err + } + items = append(items, i) + } + if err := rows.Err(); err != nil { + return nil, err + } + return items, nil +} + const getGeometriesByIDs = `-- name: GetGeometriesByIDs :many SELECT id, geo_type, draw_geometry, binding, time_start, time_end, project_id, @@ -476,6 +544,86 @@ func (q *Queries) SearchGeometries(ctx context.Context, arg SearchGeometriesPara return items, nil } +const searchGeometriesByEntityName = `-- name: SearchGeometriesByEntityName :many +WITH matched_entities AS ( + SELECT + e.id, + e.name, + e.description + FROM entities e + WHERE e.is_deleted = false + AND ($1::text IS NULL OR e.name ILIKE '%' || $1::text || '%') + AND ($2::uuid IS NULL OR e.id < $2::uuid) + ORDER BY e.id DESC + LIMIT $3 +) +SELECT + me.id AS entity_id, + me.name AS entity_name, + me.description AS entity_description, + g.id AS geometry_id, + g.geo_type, + g.draw_geometry, + g.binding, + g.time_start, + g.time_end +FROM matched_entities me +LEFT JOIN entity_geometries eg + ON eg.entity_id = me.id +LEFT JOIN geometries g + ON g.id = eg.geometry_id + AND g.is_deleted = false +ORDER BY me.id DESC, g.id DESC +` + +type SearchGeometriesByEntityNameParams struct { + Name pgtype.Text `json:"name"` + CursorID pgtype.UUID `json:"cursor_id"` + LimitCount int32 `json:"limit_count"` +} + +type SearchGeometriesByEntityNameRow struct { + EntityID pgtype.UUID `json:"entity_id"` + EntityName string `json:"entity_name"` + EntityDescription pgtype.Text `json:"entity_description"` + GeometryID pgtype.UUID `json:"geometry_id"` + GeoType pgtype.Int2 `json:"geo_type"` + DrawGeometry []byte `json:"draw_geometry"` + Binding []byte `json:"binding"` + TimeStart pgtype.Int4 `json:"time_start"` + TimeEnd pgtype.Int4 `json:"time_end"` +} + +func (q *Queries) SearchGeometriesByEntityName(ctx context.Context, arg SearchGeometriesByEntityNameParams) ([]SearchGeometriesByEntityNameRow, error) { + rows, err := q.db.Query(ctx, searchGeometriesByEntityName, arg.Name, arg.CursorID, arg.LimitCount) + if err != nil { + return nil, err + } + defer rows.Close() + items := []SearchGeometriesByEntityNameRow{} + for rows.Next() { + var i SearchGeometriesByEntityNameRow + if err := rows.Scan( + &i.EntityID, + &i.EntityName, + &i.EntityDescription, + &i.GeometryID, + &i.GeoType, + &i.DrawGeometry, + &i.Binding, + &i.TimeStart, + &i.TimeEnd, + ); err != nil { + return nil, err + } + items = append(items, i) + } + if err := rows.Err(); err != nil { + return nil, err + } + return items, nil +} + const updateGeometry = `-- name: UpdateGeometry :one UPDATE geometries SET diff --git a/internal/gen/sqlc/project.sql.go b/internal/gen/sqlc/project.sql.go index 3d8988b..434fee7 100644 --- a/internal/gen/sqlc/project.sql.go +++ b/internal/gen/sqlc/project.sql.go @@ -213,7 +213,7 @@ SELECT (SELECT json_agg(json_build_object('id', c.id, 'edit_summary', c.edit_summary) ORDER BY c.created_at DESC) FROM commits c WHERE c.project_id = p.id AND c.is_deleted = false), '[]' - )::json AS commits, + )::json AS commits, COALESCE( (SELECT json_agg(json_build_object('id', s.id, 'status', s.status)) FROM submissions s WHERE s.project_id = p.id AND s.is_deleted = false), '[]' @@ -289,10 +289,10 @@ SELECT FROM commits c WHERE c.project_id = p.id AND c.is_deleted = false), '[]' )::json AS commits, - COALESCE( - (SELECT json_agg(json_build_object('id', s.id, 'status', s.status)) FROM submissions s WHERE s.project_id = p.id AND s.is_deleted = false), - '[]' - )::json AS submissions, + COALESCE( + (SELECT json_agg(json_build_object('id', s.id, 'status', s.status)) FROM submissions s WHERE s.project_id = p.id AND s.is_deleted = false), + '[]' + )::json AS submissions, json_build_object( 'id', u.id, 'email', u.email, @@ -376,11 +376,11 @@ SELECT (SELECT json_agg(json_build_object('id', c.id, 'edit_summary', c.edit_summary) ORDER BY c.created_at DESC) FROM commits c WHERE c.project_id = p.id AND c.is_deleted = false), '[]' - )::json AS commits, - COALESCE( - (SELECT json_agg(json_build_object('id', s.id, 'status', s.status)) FROM submissions s WHERE s.project_id = p.id AND s.is_deleted = false), - '[]' - )::json AS submissions, + )::json AS commits, + COALESCE( + (SELECT json_agg(json_build_object('id', s.id, 'status', s.status)) FROM submissions s WHERE s.project_id = p.id AND s.is_deleted = false), + '[]' + )::json AS submissions, json_build_object( 'id', u.id, 'email', u.email, @@ -656,7 +656,7 @@ RETURNING (SELECT json_agg(json_build_object('id', c.id, 'edit_summary', c.edit_summary) ORDER BY c.created_at DESC) FROM commits c WHERE c.project_id = projects.id AND c.is_deleted = false), '[]' - )::json AS commits, + )::json AS commits, COALESCE( (SELECT json_agg(json_build_object('id', s.id, 'status', s.status)) FROM submissions s WHERE s.project_id = projects.id AND s.is_deleted = false), '[]' diff --git a/internal/models/geometry.go b/internal/models/geometry.go index 7587dc8..1301131 100644 --- a/internal/models/geometry.go +++ b/internal/models/geometry.go @@ -7,17 +7,29 @@ import ( ) type GeometryEntity struct { - ID string `json:"id"` - GeoType int16 `json:"geo_type"` - DrawGeometry json.RawMessage `json:"draw_geometry"` - Binding json.RawMessage `json:"binding"` - TimeStart int32 `json:"time_start"` - TimeEnd int32 `json:"time_end"` - Bbox *response.Bbox `json:"bbox"` - ProjectID string `json:"project_id"` - IsDeleted bool `json:"is_deleted"` - CreatedAt *time.Time `json:"created_at"` - UpdatedAt *time.Time `json:"updated_at"` + ID string `json:"id"` + GeoType int16 `json:"geo_type"` + DrawGeometry json.RawMessage `json:"draw_geometry"` + Binding json.RawMessage `json:"binding"` + TimeStart int32 `json:"time_start"` + TimeEnd int32 `json:"time_end"` + Bbox *response.Bbox `json:"bbox"` + ProjectID string `json:"project_id"` + IsDeleted bool `json:"is_deleted"` + CreatedAt *time.Time `json:"created_at"` + UpdatedAt *time.Time `json:"updated_at"` +} + +type EntityGeometriesSearchEntity struct { + EntityID string `json:"entity_id"` + EntityName string `json:"name"` + EntityDescription string `json:"description"` + GeometryID string `json:"id"` + GeoType int16 `json:"geo_type"` + DrawGeometry json.RawMessage `json:"draw_geometry"` + Binding json.RawMessage `json:"binding,omitempty"` + TimeStart *int32 `json:"time_start,omitempty"` + TimeEnd *int32 `json:"time_end,omitempty"` } func (g *GeometryEntity) ToResponse() *response.GeometryResponse { diff --git a/internal/repositories/geometryRepository.go b/internal/repositories/geometryRepository.go index 08d0ff5..bb786ee 100644 --- a/internal/repositories/geometryRepository.go +++ b/internal/repositories/geometryRepository.go @@ -5,6 +5,7 @@ import ( "crypto/md5" "encoding/json" "fmt" + "strings" "github.com/jackc/pgx/v5" "github.com/jackc/pgx/v5/pgtype" @@ -21,7 +22,7 @@ type GeometryRepository interface { GetByID(ctx context.Context, id pgtype.UUID) (*models.GeometryEntity, error) GetByIDs(ctx context.Context, ids []string) ([]*models.GeometryEntity, error) Search(ctx context.Context, params sqlc.SearchGeometriesParams) ([]*models.GeometryEntity, error) - SearchByEntityName(ctx context.Context, name string, cursorID *pgtype.UUID, limit int32) ([]EntityGeometriesSearchRow, error) + SearchByEntityName(ctx context.Context, params sqlc.SearchGeometriesByEntityNameParams) ([]*models.EntityGeometriesSearchEntity, error) Create(ctx context.Context, params sqlc.CreateGeometryParams) (*models.GeometryEntity, error) Update(ctx context.Context, params sqlc.UpdateGeometryParams) (*models.GeometryEntity, error) Delete(ctx context.Context, id pgtype.UUID) error @@ -36,41 +37,27 @@ type GeometryRepository interface { } type geometryRepository struct { - q *sqlc.Queries - c cache.Cache + q *sqlc.Queries + c cache.Cache db sqlc.DBTX } func NewGeometryRepository(db sqlc.DBTX, c cache.Cache) GeometryRepository { return &geometryRepository{ - q: sqlc.New(db), - c: c, + q: sqlc.New(db), + c: c, db: db, } } func (r *geometryRepository) WithTx(tx pgx.Tx) GeometryRepository { return &geometryRepository{ - q: r.q.WithTx(tx), - c: r.c, + q: r.q.WithTx(tx), + c: r.c, db: tx, } } -// EntityGeometriesSearchRow is a "flattened" row shape for searching geometries by entity name. -// FE will display entity list by entity_name, while still having geometries in the same response. -type EntityGeometriesSearchRow struct { - EntityID string `json:"entity_id"` - EntityName string `json:"name"` - EntityDescription string `json:"description"` - GeometryID string `json:"id"` - GeoType int16 `json:"geo_type"` - DrawGeometry json.RawMessage `json:"draw_geometry"` - Binding json.RawMessage `json:"binding,omitempty"` - TimeStart *int32 `json:"time_start,omitempty"` - TimeEnd *int32 `json:"time_end,omitempty"` -} - func (r *geometryRepository) generateQueryKey(prefix string, params any) string { b, _ := json.Marshal(params) hash := fmt.Sprintf("%x", md5.Sum(b)) @@ -396,121 +383,124 @@ func (r *geometryRepository) DeleteEntityGeometry(ctx context.Context, entityID }) } -func (r *geometryRepository) SearchByEntityName( - ctx context.Context, - name string, - cursorID *pgtype.UUID, - limit int32, -) ([]EntityGeometriesSearchRow, error) { - // Cursor is entity_id (not geometry_id) so FE can render entities as the primary list. - const q = ` -WITH matched_entities AS ( - SELECT - e.id, - e.name, - e.description - FROM entities e - WHERE e.is_deleted = false - AND ($1::text = '' OR e.name ILIKE '%' || $1::text || '%') - AND ($2::uuid IS NULL OR e.id < $2::uuid) - ORDER BY e.id DESC - LIMIT $3 -) -SELECT - me.id AS entity_id, - me.name AS entity_name, - COALESCE(me.description, '') AS entity_description, - g.id AS geometry_id, - g.geo_type, - g.draw_geometry, - g.binding, - g.time_start, - g.time_end -FROM matched_entities me -LEFT JOIN entity_geometries eg - ON eg.entity_id = me.id -LEFT JOIN geometries g - ON g.id = eg.geometry_id - AND g.is_deleted = false -ORDER BY me.id DESC, g.id DESC -` +func (r *geometryRepository) getSearchByIDsWithFallback(ctx context.Context, pairs []string) ([]*models.EntityGeometriesSearchEntity, error) { + if len(pairs) == 0 { + return []*models.EntityGeometriesSearchEntity{}, nil + } + keys := make([]string, len(pairs)) + for i, pair := range pairs { + keys[i] = fmt.Sprintf("geometry:search:item:%s", pair) + } + raws := r.c.MGet(ctx, keys...) - var cursor pgtype.UUID - if cursorID != nil { - cursor = *cursorID - } else { - cursor = pgtype.UUID{Valid: false} + var results []*models.EntityGeometriesSearchEntity + missingToCache := make(map[string]any) + + var missingEntityIds []pgtype.UUID + var missingGeometryIds []pgtype.UUID + + for i, b := range raws { + if len(b) == 0 { + parts := strings.Split(pairs[i], ":") + if len(parts) == 2 { + eID := pgtype.UUID{} + gID := pgtype.UUID{} + if err := eID.Scan(parts[0]); err == nil { + if err := gID.Scan(parts[1]); err == nil { + missingEntityIds = append(missingEntityIds, eID) + missingGeometryIds = append(missingGeometryIds, gID) + } + } + } + } } - rows, err := r.db.Query(ctx, q, name, cursor, limit) + dbMap := make(map[string]*models.EntityGeometriesSearchEntity) + if len(missingEntityIds) > 0 { + dbRows, err := r.q.GetEntityGeometriesByPairs(ctx, sqlc.GetEntityGeometriesByPairsParams{ + EntityIds: missingEntityIds, + GeometryIds: missingGeometryIds, + }) + if err == nil { + for _, row := range dbRows { + item := &models.EntityGeometriesSearchEntity{ + EntityID: convert.UUIDToString(row.EntityID), + EntityName: row.EntityName, + EntityDescription: convert.TextToString(row.EntityDescription), + GeometryID: convert.UUIDToString(row.GeometryID), + GeoType: row.GeoType, + DrawGeometry: row.DrawGeometry, + Binding: row.Binding, + TimeStart: convert.Int4ToPtr(row.TimeStart), + TimeEnd: convert.Int4ToPtr(row.TimeEnd), + } + key := fmt.Sprintf("%s:%s", item.EntityID, item.GeometryID) + dbMap[key] = item + } + } + } + + for i, b := range raws { + if len(b) > 0 { + var u models.EntityGeometriesSearchEntity + if err := json.Unmarshal(b, &u); err == nil { + results = append(results, &u) + } + } else { + if item, ok := dbMap[pairs[i]]; ok { + results = append(results, item) + missingToCache[keys[i]] = item + } + } + } + + if len(missingToCache) > 0 { + _ = r.c.MSet(ctx, missingToCache, constants.NormalCacheDuration) + } + + return results, nil +} + +func (r *geometryRepository) SearchByEntityName(ctx context.Context, params sqlc.SearchGeometriesByEntityNameParams) ([]*models.EntityGeometriesSearchEntity, error) { + + queryKey := r.generateQueryKey("geometry:search:entity", params) + var cachedPairs []string + if err := r.c.Get(ctx, queryKey, &cachedPairs); err == nil && len(cachedPairs) > 0 { + return r.getSearchByIDsWithFallback(ctx, cachedPairs) + } + + rows, err := r.q.SearchGeometriesByEntityName(ctx, params) if err != nil { return nil, err } - defer rows.Close() + var geometries []*models.EntityGeometriesSearchEntity + var pairs []string + geometryToCache := make(map[string]any) - out := make([]EntityGeometriesSearchRow, 0) - for rows.Next() { - var entityID pgtype.UUID - var entityName pgtype.Text - var entityDesc pgtype.Text - var geoID pgtype.UUID - var geoType pgtype.Int2 - var draw json.RawMessage - var binding json.RawMessage - var timeStart pgtype.Int4 - var timeEnd pgtype.Int4 - - if err := rows.Scan( - &entityID, - &entityName, - &entityDesc, - &geoID, - &geoType, - &draw, - &binding, - &timeStart, - &timeEnd, - ); err != nil { - return nil, err + for _, row := range rows { + item := &models.EntityGeometriesSearchEntity{ + EntityID: convert.UUIDToString(row.EntityID), + EntityName: row.EntityName, + EntityDescription: convert.TextToString(row.EntityDescription), + GeometryID: convert.UUIDToString(row.GeometryID), + GeoType: convert.Int2ToInt16(row.GeoType), + DrawGeometry: row.DrawGeometry, + Binding: row.Binding, + TimeStart: convert.Int4ToPtr(row.TimeStart), + TimeEnd: convert.Int4ToPtr(row.TimeEnd), } - - var ts *int32 - if timeStart.Valid { - v := timeStart.Int32 - ts = &v - } - var te *int32 - if timeEnd.Valid { - v := timeEnd.Int32 - te = &v - } - - row := EntityGeometriesSearchRow{ - EntityID: convert.UUIDToString(entityID), - EntityName: entityName.String, - EntityDescription: entityDesc.String, - GeometryID: convert.UUIDToString(geoID), - GeoType: geoType.Int16, - DrawGeometry: draw, - Binding: binding, - TimeStart: ts, - TimeEnd: te, - } - - // Entity with no geometry will have NULL geometry_id. - if !geoID.Valid { - row.GeometryID = "" - row.GeoType = 0 - row.DrawGeometry = nil - row.Binding = nil - row.TimeStart = nil - row.TimeEnd = nil - } - - out = append(out, row) + pair := fmt.Sprintf("%s:%s", item.EntityID, item.GeometryID) + geometries = append(geometries, item) + pairs = append(pairs, pair) + geometryToCache[fmt.Sprintf("geometry:search:item:%s", pair)] = item } - if err := rows.Err(); err != nil { - return nil, err + + if len(geometryToCache) > 0 { + _ = r.c.MSet(ctx, geometryToCache, constants.NormalCacheDuration) } - return out, nil + if len(pairs) > 0 { + _ = r.c.Set(ctx, queryKey, pairs, constants.ListCacheDuration) + } + + return geometries, nil } diff --git a/internal/services/geometryService.go b/internal/services/geometryService.go index ed8ca34..7e8629b 100644 --- a/internal/services/geometryService.go +++ b/internal/services/geometryService.go @@ -86,16 +86,20 @@ func (s *geometryService) SearchGeometriesByEntityName( limit = 20 } - var cursorID *pgtype.UUID + var cursorID pgtype.UUID if req.Cursor != "" { id, err := convert.StringToUUID(req.Cursor) if err != nil { return nil, fiber.NewError(fiber.StatusBadRequest, "Invalid cursor format") } - cursorID = &id + cursorID = id } - rows, err := s.geometryRepo.SearchByEntityName(ctx, req.Name, cursorID, limit) + rows, err := s.geometryRepo.SearchByEntityName(ctx, sqlc.SearchGeometriesByEntityNameParams{ + Name: convert.StringToText(req.Name), + CursorID: cursorID, + LimitCount: limit, + }) if err != nil { return nil, fiber.NewError(fiber.StatusInternalServerError, "Failed to search geometries by entity name") } @@ -116,7 +120,6 @@ func (s *geometryService) SearchGeometriesByEntityName( order = append(order, row.EntityID) } - // LEFT JOIN: entity might have no geometry. if row.GeometryID == "" || len(row.DrawGeometry) == 0 { continue } @@ -138,7 +141,6 @@ func (s *geometryService) SearchGeometriesByEntityName( nextCursor := "" if len(order) > 0 { - // Use last entity id in the current page as the cursor for the next page (id < cursor). nextCursor = order[len(order)-1] } diff --git a/pkg/convert/convert.go b/pkg/convert/convert.go index c6f3374..bb94a31 100644 --- a/pkg/convert/convert.go +++ b/pkg/convert/convert.go @@ -143,6 +143,13 @@ func Int2ToInt16Ptr(v pgtype.Int2) *int16 { return &int16Val } +func Int2ToInt16(v pgtype.Int2) int16 { + if v.Valid { + return v.Int16 + } + return 0 +} + func PtrFloat64ToInt4(v *float64) pgtype.Int4 { if v == nil { return pgtype.Int4{Valid: false}