feat: implement relation management system with controllers, services, repositories, and corresponding API documentation.
Build and Release / release (push) Successful in 1m47s

This commit is contained in:
2026-06-06 17:22:03 +07:00
parent 7ff15ae662
commit fd8d413760
15 changed files with 706 additions and 192 deletions
+4 -1
View File
@@ -162,6 +162,7 @@ func (s *FiberServer) SetupServer(
statisticService := services.NewStatisticService(statisticRepo) statisticService := services.NewStatisticService(statisticRepo)
battleReplayService := services.NewBattleReplayService(battleReplayRepo) battleReplayService := services.NewBattleReplayService(battleReplayRepo)
goongService := services.NewGoongService(redis) goongService := services.NewGoongService(redis)
relationService := services.NewRelationService(wikiRepo, entityRepo, geometryRepo)
// controller setup // controller setup
authController := controllers.NewAuthController(authService, oauth) authController := controllers.NewAuthController(authService, oauth)
@@ -181,6 +182,7 @@ func (s *FiberServer) SetupServer(
statisticController := controllers.NewStatisticController(statisticService) statisticController := controllers.NewStatisticController(statisticService)
battleReplayController := controllers.NewBattleReplayController(battleReplayService) battleReplayController := controllers.NewBattleReplayController(battleReplayService)
goongController := controllers.NewGoongController(goongService) goongController := controllers.NewGoongController(goongService)
relationController := controllers.NewRelationController(relationService)
// route setup // route setup
routes.AuthRoutes(s.App, authController, userRepo) routes.AuthRoutes(s.App, authController, userRepo)
@@ -193,11 +195,12 @@ func (s *FiberServer) SetupServer(
routes.EntityRoutes(s.App, entityController) routes.EntityRoutes(s.App, entityController)
routes.GeometryRoutes(s.App, geometryController) routes.GeometryRoutes(s.App, geometryController)
routes.WikiRoutes(s.App, wikiController) routes.WikiRoutes(s.App, wikiController)
routes.RelationRoutes(s.App, wikiController, entityController) routes.RelationRoutes(s.App, wikiController, entityController, relationController)
routes.ProjectRoutes(s.App, projectController, commitController, userRepo) routes.ProjectRoutes(s.App, projectController, commitController, userRepo)
routes.SubmissionRoutes(s.App, submissionController, userRepo) routes.SubmissionRoutes(s.App, submissionController, userRepo)
routes.ChatbotRoutes(s.App, chatbotController, userRepo) routes.ChatbotRoutes(s.App, chatbotController, userRepo)
routes.StatisticRoutes(s.App, statisticController, userRepo) routes.StatisticRoutes(s.App, statisticController, userRepo)
routes.BattleReplayRoutes(s.App, battleReplayController) routes.BattleReplayRoutes(s.App, battleReplayController)
routes.GoongRoutes(s.App, goongController) routes.GoongRoutes(s.App, goongController)
routes.NotFoundRoute(s.App) routes.NotFoundRoute(s.App)
+6
View File
@@ -248,3 +248,9 @@ SELECT
)::uuid[] AS replay_ids )::uuid[] AS replay_ids
FROM geometries FROM geometries
WHERE geometries.bound_with = $1 AND geometries.is_deleted = false; WHERE geometries.bound_with = $1 AND geometries.is_deleted = false;
-- name: GetGeometryIDsByEntityIDs :many
SELECT entity_id, geometry_id
FROM entity_geometries
WHERE entity_id = ANY($1::uuid[]);
+6
View File
@@ -132,3 +132,9 @@ ORDER BY created_at DESC;
SELECT entity_id, wiki_id SELECT entity_id, wiki_id
FROM entity_wikis FROM entity_wikis
WHERE entity_id = ANY($1::uuid[]); WHERE entity_id = ANY($1::uuid[]);
-- name: GetEntityIDsByWikiIDs :many
SELECT wiki_id, entity_id
FROM entity_wikis
WHERE wiki_id = ANY($1::uuid[]);
+69 -1
View File
@@ -895,6 +895,13 @@ const docTemplate = `{
"name": "has_bound", "name": "has_bound",
"in": "query" "in": "query"
}, },
{
"maximum": 100,
"minimum": 1,
"type": "integer",
"name": "limit",
"in": "query"
},
{ {
"maximum": 90, "maximum": 90,
"minimum": -90, "minimum": -90,
@@ -2933,6 +2940,66 @@ const docTemplate = `{
} }
} }
}, },
"/relations": {
"get": {
"description": "Get relations by type (wiki-entity, entity-wiki, geometry-entity, entity-geometry) and list of IDs",
"consumes": [
"application/json"
],
"produces": [
"application/json"
],
"tags": [
"Relations"
],
"summary": "Get generalized batch relations",
"parameters": [
{
"minItems": 1,
"type": "array",
"items": {
"type": "string"
},
"collectionFormat": "csv",
"name": "ids",
"in": "query",
"required": true
},
{
"enum": [
"wiki-entity",
"entity-wiki",
"geometry-entity",
"entity-geometry"
],
"type": "string",
"name": "type",
"in": "query",
"required": true
}
],
"responses": {
"200": {
"description": "OK",
"schema": {
"$ref": "#/definitions/history-api_internal_dtos_response.CommonResponse"
}
},
"400": {
"description": "Bad Request",
"schema": {
"$ref": "#/definitions/history-api_internal_dtos_response.CommonResponse"
}
},
"500": {
"description": "Internal Server Error",
"schema": {
"$ref": "#/definitions/history-api_internal_dtos_response.CommonResponse"
}
}
}
}
},
"/relations/entities-by-geometries": { "/relations/entities-by-geometries": {
"get": { "get": {
"description": "Get entities grouped by geometry IDs", "description": "Get entities grouped by geometry IDs",
@@ -4900,7 +4967,8 @@ const docTemplate = `{
"type": "string" "type": "string"
}, },
"question": { "question": {
"type": "string" "type": "string",
"maxLength": 500
} }
} }
}, },
+69 -1
View File
@@ -888,6 +888,13 @@
"name": "has_bound", "name": "has_bound",
"in": "query" "in": "query"
}, },
{
"maximum": 100,
"minimum": 1,
"type": "integer",
"name": "limit",
"in": "query"
},
{ {
"maximum": 90, "maximum": 90,
"minimum": -90, "minimum": -90,
@@ -2926,6 +2933,66 @@
} }
} }
}, },
"/relations": {
"get": {
"description": "Get relations by type (wiki-entity, entity-wiki, geometry-entity, entity-geometry) and list of IDs",
"consumes": [
"application/json"
],
"produces": [
"application/json"
],
"tags": [
"Relations"
],
"summary": "Get generalized batch relations",
"parameters": [
{
"minItems": 1,
"type": "array",
"items": {
"type": "string"
},
"collectionFormat": "csv",
"name": "ids",
"in": "query",
"required": true
},
{
"enum": [
"wiki-entity",
"entity-wiki",
"geometry-entity",
"entity-geometry"
],
"type": "string",
"name": "type",
"in": "query",
"required": true
}
],
"responses": {
"200": {
"description": "OK",
"schema": {
"$ref": "#/definitions/history-api_internal_dtos_response.CommonResponse"
}
},
"400": {
"description": "Bad Request",
"schema": {
"$ref": "#/definitions/history-api_internal_dtos_response.CommonResponse"
}
},
"500": {
"description": "Internal Server Error",
"schema": {
"$ref": "#/definitions/history-api_internal_dtos_response.CommonResponse"
}
}
}
}
},
"/relations/entities-by-geometries": { "/relations/entities-by-geometries": {
"get": { "get": {
"description": "Get entities grouped by geometry IDs", "description": "Get entities grouped by geometry IDs",
@@ -4893,7 +4960,8 @@
"type": "string" "type": "string"
}, },
"question": { "question": {
"type": "string" "type": "string",
"maxLength": 500
} }
} }
}, },
+48
View File
@@ -82,6 +82,7 @@ definitions:
project_id: project_id:
type: string type: string
question: question:
maxLength: 500
type: string type: string
required: required:
- question - question
@@ -1327,6 +1328,11 @@ paths:
- in: query - in: query
name: has_bound name: has_bound
type: boolean type: boolean
- in: query
maximum: 100
minimum: 1
name: limit
type: integer
- in: query - in: query
maximum: 90 maximum: 90
minimum: -90 minimum: -90
@@ -2655,6 +2661,48 @@ paths:
summary: Get raster tile metadata summary: Get raster tile metadata
tags: tags:
- Tile - Tile
/relations:
get:
consumes:
- application/json
description: Get relations by type (wiki-entity, entity-wiki, geometry-entity,
entity-geometry) and list of IDs
parameters:
- collectionFormat: csv
in: query
items:
type: string
minItems: 1
name: ids
required: true
type: array
- enum:
- wiki-entity
- entity-wiki
- geometry-entity
- entity-geometry
in: query
name: type
required: true
type: string
produces:
- application/json
responses:
"200":
description: OK
schema:
$ref: '#/definitions/history-api_internal_dtos_response.CommonResponse'
"400":
description: Bad Request
schema:
$ref: '#/definitions/history-api_internal_dtos_response.CommonResponse'
"500":
description: Internal Server Error
schema:
$ref: '#/definitions/history-api_internal_dtos_response.CommonResponse'
summary: Get generalized batch relations
tags:
- Relations
/relations/entities-by-geometries: /relations/entities-by-geometries:
get: get:
consumes: consumes:
@@ -0,0 +1,57 @@
package controllers
import (
"context"
"history-api/internal/dtos/request"
"history-api/internal/dtos/response"
"history-api/internal/services"
"history-api/pkg/validator"
"time"
"github.com/gofiber/fiber/v3"
)
type RelationController struct {
service services.RelationService
}
func NewRelationController(svc services.RelationService) *RelationController {
return &RelationController{service: svc}
}
// GetRelations handles fetching relationships dynamically.
// @Summary Get generalized batch relations
// @Description Get relations by type (wiki-entity, entity-wiki, geometry-entity, entity-geometry) and list of IDs
// @Tags Relations
// @Accept json
// @Produce json
// @Param query query request.GetRelationsDto true "Query Parameters"
// @Success 200 {object} response.CommonResponse
// @Failure 400 {object} response.CommonResponse
// @Failure 500 {object} response.CommonResponse
// @Router /relations [get]
func (h *RelationController) GetRelations(c fiber.Ctx) error {
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
dto := &request.GetRelationsDto{}
if err := validator.ValidateQueryDto(c, dto); err != nil {
return c.Status(fiber.StatusBadRequest).JSON(response.CommonResponse{
Status: false,
Errors: err,
})
}
res, err := h.service.GetRelations(ctx, dto)
if err != nil {
return c.Status(err.Code).JSON(response.CommonResponse{
Status: false,
Message: err.Message,
})
}
return c.Status(fiber.StatusOK).JSON(response.CommonResponse{
Status: true,
Data: res,
})
}
+15
View File
@@ -11,3 +11,18 @@ type GetEntitiesByGeometryIDsDto struct {
type GetWikiContentsPreviewDto struct { type GetWikiContentsPreviewDto struct {
IDs []string `json:"ids" query:"ids" validate:"required,min=1,dive,uuid"` IDs []string `json:"ids" query:"ids" validate:"required,min=1,dive,uuid"`
} }
type GetEntitiesByWikiIDsDto struct {
WikiIDs []string `json:"wiki_ids" query:"wiki_ids" validate:"required,min=1,dive,uuid"`
}
type GetGeometriesByEntityIDsDto struct {
EntityIDs []string `json:"entity_ids" query:"entity_ids" validate:"required,min=1,dive,uuid"`
}
type GetRelationsDto struct {
Type string `json:"type" query:"type" validate:"required,oneof=wiki-entity entity-wiki geometry-entity entity-geometry"`
IDs []string `json:"ids" query:"ids" validate:"required,min=1,dive,uuid"`
}
+31
View File
@@ -538,6 +538,37 @@ func (q *Queries) GetGeometryById(ctx context.Context, id pgtype.UUID) (GetGeome
return i, err return i, err
} }
const getGeometryIDsByEntityIDs = `-- name: GetGeometryIDsByEntityIDs :many
SELECT entity_id, geometry_id
FROM entity_geometries
WHERE entity_id = ANY($1::uuid[])
`
type GetGeometryIDsByEntityIDsRow struct {
EntityID pgtype.UUID `json:"entity_id"`
GeometryID pgtype.UUID `json:"geometry_id"`
}
func (q *Queries) GetGeometryIDsByEntityIDs(ctx context.Context, dollar_1 []pgtype.UUID) ([]GetGeometryIDsByEntityIDsRow, error) {
rows, err := q.db.Query(ctx, getGeometryIDsByEntityIDs, dollar_1)
if err != nil {
return nil, err
}
defer rows.Close()
items := []GetGeometryIDsByEntityIDsRow{}
for rows.Next() {
var i GetGeometryIDsByEntityIDsRow
if err := rows.Scan(&i.EntityID, &i.GeometryID); err != nil {
return nil, err
}
items = append(items, i)
}
if err := rows.Err(); err != nil {
return nil, err
}
return items, nil
}
const searchGeometries = `-- name: SearchGeometries :many const searchGeometries = `-- name: SearchGeometries :many
SELECT SELECT
g.id, g.geo_type, g.draw_geometry, g.bound_with, g.time_start, g.time_end, g.project_id, g.id, g.geo_type, g.draw_geometry, g.bound_with, g.time_start, g.time_end, g.project_id,
+31
View File
@@ -188,6 +188,37 @@ func (q *Queries) DeleteWikisByIDs(ctx context.Context, dollar_1 []pgtype.UUID)
return err return err
} }
const getEntityIDsByWikiIDs = `-- name: GetEntityIDsByWikiIDs :many
SELECT wiki_id, entity_id
FROM entity_wikis
WHERE wiki_id = ANY($1::uuid[])
`
type GetEntityIDsByWikiIDsRow struct {
WikiID pgtype.UUID `json:"wiki_id"`
EntityID pgtype.UUID `json:"entity_id"`
}
func (q *Queries) GetEntityIDsByWikiIDs(ctx context.Context, dollar_1 []pgtype.UUID) ([]GetEntityIDsByWikiIDsRow, error) {
rows, err := q.db.Query(ctx, getEntityIDsByWikiIDs, dollar_1)
if err != nil {
return nil, err
}
defer rows.Close()
items := []GetEntityIDsByWikiIDsRow{}
for rows.Next() {
var i GetEntityIDsByWikiIDsRow
if err := rows.Scan(&i.WikiID, &i.EntityID); err != nil {
return nil, err
}
items = append(items, i)
}
if err := rows.Err(); err != nil {
return nil, err
}
return items, nil
}
const getWikiById = `-- name: GetWikiById :one const getWikiById = `-- name: GetWikiById :one
SELECT id, project_id, title, slug, is_deleted, created_at, updated_at SELECT id, project_id, title, slug, is_deleted, created_at, updated_at
FROM wikis FROM wikis
+61
View File
@@ -25,6 +25,7 @@ type EntityRepository interface {
Delete(ctx context.Context, id pgtype.UUID) error Delete(ctx context.Context, id pgtype.UUID) error
DeleteByIDs(ctx context.Context, ids []pgtype.UUID) error DeleteByIDs(ctx context.Context, ids []pgtype.UUID) error
GetByProjectID(ctx context.Context, projectID pgtype.UUID) ([]*models.EntityEntity, error) GetByProjectID(ctx context.Context, projectID pgtype.UUID) ([]*models.EntityEntity, error)
GetEntityIDsByWikiIDs(ctx context.Context, wikiIDs []string) (map[string][]string, error)
GetEntityIDsByGeometryIDs(ctx context.Context, geometryIDs []string) (map[string][]string, error) GetEntityIDsByGeometryIDs(ctx context.Context, geometryIDs []string) (map[string][]string, error)
WithTx(tx pgx.Tx) EntityRepository WithTx(tx pgx.Tx) EntityRepository
} }
@@ -473,3 +474,63 @@ func (r *entityRepository) GetEntityIDsByGeometryIDs(ctx context.Context, geomet
return result, nil return result, nil
} }
func (r *entityRepository) GetEntityIDsByWikiIDs(ctx context.Context, wikiIDs []string) (map[string][]string, error) {
if len(wikiIDs) == 0 {
return make(map[string][]string), nil
}
keys := make([]string, len(wikiIDs))
for i, id := range wikiIDs {
keys[i] = cache.Key("entity_wikis:wiki", id)
}
raws := r.c.MGet(ctx, keys...)
result := make(map[string][]string, len(wikiIDs))
missingWikiIDs := make([]string, 0, len(wikiIDs))
missingPgIDs := make([]pgtype.UUID, 0, len(wikiIDs))
for i, b := range raws {
if len(b) > 0 {
var entityIDs []string
if err := json.Unmarshal(b, &entityIDs); err == nil {
result[wikiIDs[i]] = entityIDs
continue
}
}
missingWikiIDs = append(missingWikiIDs, wikiIDs[i])
pgID, err := convert.StringToUUID(wikiIDs[i])
if err == nil {
missingPgIDs = append(missingPgIDs, pgID)
}
}
if len(missingPgIDs) > 0 {
rows, err := r.q.GetEntityIDsByWikiIDs(ctx, missingPgIDs)
if err != nil {
return nil, err
}
dbMap := make(map[string][]string, len(missingWikiIDs))
for _, id := range missingWikiIDs {
dbMap[id] = []string{}
}
for _, row := range rows {
wID := convert.UUIDToString(row.WikiID)
eID := convert.UUIDToString(row.EntityID)
dbMap[wID] = append(dbMap[wID], eID)
}
missingToCache := make(map[string]any, len(dbMap))
for wID, eIDs := range dbMap {
result[wID] = eIDs
missingToCache[cache.Key("entity_wikis:wiki", wID)] = eIDs
}
if len(missingToCache) > 0 {
_ = r.c.MSet(ctx, missingToCache, constants.NormalCacheDuration)
}
}
return result, nil
}
@@ -32,6 +32,7 @@ type GeometryRepository interface {
BulkDeleteEntityGeometriesByGeometryID(ctx context.Context, geometryID pgtype.UUID) error BulkDeleteEntityGeometriesByGeometryID(ctx context.Context, geometryID pgtype.UUID) error
DeleteEntityGeometry(ctx context.Context, entityID pgtype.UUID, geometryID pgtype.UUID) error DeleteEntityGeometry(ctx context.Context, entityID pgtype.UUID, geometryID pgtype.UUID) error
DeleteEntityGeometriesByProjectID(ctx context.Context, projectID pgtype.UUID) error DeleteEntityGeometriesByProjectID(ctx context.Context, projectID pgtype.UUID) error
GetGeometryIDsByEntityIDs(ctx context.Context, entityIDs []string) (map[string][]string, error)
WithTx(tx pgx.Tx) GeometryRepository WithTx(tx pgx.Tx) GeometryRepository
} }
@@ -565,3 +566,63 @@ func (r *geometryRepository) SearchByEntityName(ctx context.Context, params sqlc
return geometries, nil return geometries, nil
} }
func (r *geometryRepository) GetGeometryIDsByEntityIDs(ctx context.Context, entityIDs []string) (map[string][]string, error) {
if len(entityIDs) == 0 {
return make(map[string][]string), nil
}
keys := make([]string, len(entityIDs))
for i, id := range entityIDs {
keys[i] = cache.Key("entity_geometries:entity", id)
}
raws := r.c.MGet(ctx, keys...)
result := make(map[string][]string, len(entityIDs))
missingEntityIDs := make([]string, 0, len(entityIDs))
missingPgIDs := make([]pgtype.UUID, 0, len(entityIDs))
for i, b := range raws {
if len(b) > 0 {
var geometryIDs []string
if err := json.Unmarshal(b, &geometryIDs); err == nil {
result[entityIDs[i]] = geometryIDs
continue
}
}
missingEntityIDs = append(missingEntityIDs, entityIDs[i])
pgID, err := convert.StringToUUID(entityIDs[i])
if err == nil {
missingPgIDs = append(missingPgIDs, pgID)
}
}
if len(missingPgIDs) > 0 {
rows, err := r.q.GetGeometryIDsByEntityIDs(ctx, missingPgIDs)
if err != nil {
return nil, err
}
dbMap := make(map[string][]string, len(missingEntityIDs))
for _, id := range missingEntityIDs {
dbMap[id] = []string{}
}
for _, row := range rows {
eID := convert.UUIDToString(row.EntityID)
gID := convert.UUIDToString(row.GeometryID)
dbMap[eID] = append(dbMap[eID], gID)
}
missingToCache := make(map[string]any, len(dbMap))
for eID, gIDs := range dbMap {
result[eID] = gIDs
missingToCache[cache.Key("entity_geometries:entity", eID)] = gIDs
}
if len(missingToCache) > 0 {
_ = r.c.MSet(ctx, missingToCache, constants.NormalCacheDuration)
}
}
return result, nil
}
+8 -1
View File
@@ -6,9 +6,16 @@ import (
"github.com/gofiber/fiber/v3" "github.com/gofiber/fiber/v3"
) )
func RelationRoutes(router fiber.Router, wikiController *controllers.WikiController, entityController *controllers.EntityController) { func RelationRoutes(
router fiber.Router,
wikiController *controllers.WikiController,
entityController *controllers.EntityController,
relationController *controllers.RelationController,
) {
relation := router.Group("/relations") relation := router.Group("/relations")
relation.Get("", relationController.GetRelations)
relation.Get("/wikis-by-entities", wikiController.GetWikisByEntityIDs) relation.Get("/wikis-by-entities", wikiController.GetWikisByEntityIDs)
relation.Get("/entities-by-geometries", entityController.GetEntitiesByGeometryIDs) relation.Get("/entities-by-geometries", entityController.GetEntitiesByGeometryIDs)
relation.Get("/wiki-contents/preview", wikiController.GetWikiContentsPreviewByIDs) relation.Get("/wiki-contents/preview", wikiController.GetWikiContentsPreviewByIDs)
} }
-188
View File
@@ -1,188 +0,0 @@
package services
import (
"context"
"errors"
"history-api/internal/dtos/request"
"history-api/internal/dtos/response"
"history-api/internal/gen/sqlc"
"history-api/internal/models"
"history-api/internal/repositories"
"testing"
"github.com/jackc/pgx/v5"
"github.com/jackc/pgx/v5/pgtype"
)
// mockGeometryRepository implements repositories.GeometryRepository for unit testing
type mockGeometryRepository struct {
mockGeometries []*models.GeometryEntity
mockErr error
}
func (m *mockGeometryRepository) GetByID(ctx context.Context, id pgtype.UUID) (*models.GeometryEntity, error) {
if m.mockErr != nil {
return nil, m.mockErr
}
if len(m.mockGeometries) > 0 {
return m.mockGeometries[0], nil
}
return nil, errors.New("not found")
}
func (m *mockGeometryRepository) GetByIDs(ctx context.Context, ids []string) ([]*models.GeometryEntity, error) {
return m.mockGeometries, m.mockErr
}
func (m *mockGeometryRepository) Search(ctx context.Context, params sqlc.SearchGeometriesParams) ([]*models.GeometryEntity, error) {
return m.mockGeometries, m.mockErr
}
func (m *mockGeometryRepository) SearchByEntityName(ctx context.Context, params sqlc.SearchGeometriesByEntityNameParams) ([]*models.EntityGeometriesSearchEntity, error) {
return nil, m.mockErr
}
func (m *mockGeometryRepository) Create(ctx context.Context, params sqlc.CreateGeometryParams) (*models.GeometryEntity, error) {
return nil, nil
}
func (m *mockGeometryRepository) Update(ctx context.Context, params sqlc.UpdateGeometryParams) (*models.GeometryEntity, error) {
return nil, nil
}
func (m *mockGeometryRepository) Delete(ctx context.Context, id pgtype.UUID) error {
return nil
}
func (m *mockGeometryRepository) CreateEntityGeometries(ctx context.Context, params sqlc.CreateEntityGeometriesParams) error {
return nil
}
func (m *mockGeometryRepository) BulkDeleteEntityGeometriesByEntityId(ctx context.Context, entityId pgtype.UUID) error {
return nil
}
func (m *mockGeometryRepository) GetByProjectID(ctx context.Context, projectID pgtype.UUID) ([]*models.GeometryEntity, error) {
return m.mockGeometries, m.mockErr
}
func (m *mockGeometryRepository) GetGeometriesByBoundWith(ctx context.Context, boundWith pgtype.UUID) ([]*models.GeometryEntity, error) {
return m.mockGeometries, m.mockErr
}
func (m *mockGeometryRepository) DeleteByIDs(ctx context.Context, ids []pgtype.UUID) error {
return nil
}
func (m *mockGeometryRepository) BulkDeleteEntityGeometriesByGeometryID(ctx context.Context, geometryID pgtype.UUID) error {
return nil
}
func (m *mockGeometryRepository) DeleteEntityGeometry(ctx context.Context, entityID pgtype.UUID, geometryID pgtype.UUID) error {
return nil
}
func (m *mockGeometryRepository) DeleteEntityGeometriesByProjectID(ctx context.Context, projectID pgtype.UUID) error {
return nil
}
func (m *mockGeometryRepository) WithTx(tx pgx.Tx) repositories.GeometryRepository {
return m
}
// Helper to float pointer
func floatPtr(f float64) *float64 {
return &f
}
// Helper to int pointer
func intPtr(i int32) *int32 {
return &i
}
func TestGetGeometryByID_InvalidID(t *testing.T) {
repo := &mockGeometryRepository{}
svc := NewGeometryService(repo)
_, fErr := svc.GetGeometryByID(context.Background(), "invalid-uuid-format")
if fErr == nil {
t.Fatal("Expected error for invalid UUID format, got nil")
}
if fErr.Code != 400 {
t.Errorf("Expected status 400, got %d", fErr.Code)
}
}
func TestSearchGeometries_NoBoundingBox(t *testing.T) {
repo := &mockGeometryRepository{}
svc := NewGeometryService(repo)
req := &request.SearchGeometryDto{
MinLng: nil, // Missing bounding box parameters
}
_, fErr := svc.SearchGeometries(context.Background(), req)
if fErr == nil {
t.Fatal("Expected error for missing bounding box, got nil")
}
if fErr.Code != 400 {
t.Errorf("Expected status 400, got %d", fErr.Code)
}
}
func TestSearchGeometries_InvalidBoundingBox(t *testing.T) {
repo := &mockGeometryRepository{}
svc := NewGeometryService(repo)
req := &request.SearchGeometryDto{
MinLng: floatPtr(105.0),
MinLat: floatPtr(21.0),
MaxLng: floatPtr(100.0), // MaxLng < MinLng (Invalid)
MaxLat: floatPtr(22.0),
}
_, fErr := svc.SearchGeometries(context.Background(), req)
if fErr == nil {
t.Fatal("Expected error for invalid bounding box coordinates, got nil")
}
if fErr.Code != 400 {
t.Errorf("Expected status 400, got %d", fErr.Code)
}
if fErr.Message != "Invalid bounding box coordinates" {
t.Errorf("Expected error message 'Invalid bounding box coordinates', got '%s'", fErr.Message)
}
}
func TestSearchGeometries_Valid(t *testing.T) {
mockGeometries := []*models.GeometryEntity{
{
ID: "87570494-0cfb-4e14-8789-7cfc0245037d",
GeoType: 3, // Polygon
Bbox: &response.Bbox{
MinLng: 102.0, MinLat: 8.0, MaxLng: 109.0, MaxLat: 23.0,
},
},
}
repo := &mockGeometryRepository{
mockGeometries: mockGeometries,
}
svc := NewGeometryService(repo)
req := &request.SearchGeometryDto{
MinLng: floatPtr(100.0),
MinLat: floatPtr(5.0),
MaxLng: floatPtr(110.0),
MaxLat: floatPtr(25.0),
}
res, fErr := svc.SearchGeometries(context.Background(), req)
if fErr != nil {
t.Fatalf("Expected no error, got %v", fErr)
}
if len(res) != 1 {
t.Fatalf("Expected 1 geometry response, got %d", len(res))
}
if res[0].ID != mockGeometries[0].ID {
t.Errorf("Expected ID %s, got %s", mockGeometries[0].ID, res[0].ID)
}
}
+240
View File
@@ -0,0 +1,240 @@
package services
import (
"context"
"history-api/internal/dtos/request"
"history-api/internal/dtos/response"
"history-api/internal/models"
"history-api/internal/repositories"
"github.com/gofiber/fiber/v3"
)
type RelationService interface {
GetRelations(ctx context.Context, req *request.GetRelationsDto) (interface{}, *fiber.Error)
}
type relationService struct {
wikiRepo repositories.WikiRepository
entityRepo repositories.EntityRepository
geometryRepo repositories.GeometryRepository
}
func NewRelationService(
wikiRepo repositories.WikiRepository,
entityRepo repositories.EntityRepository,
geometryRepo repositories.GeometryRepository,
) RelationService {
return &relationService{
wikiRepo: wikiRepo,
entityRepo: entityRepo,
geometryRepo: geometryRepo,
}
}
func (s *relationService) GetRelations(ctx context.Context, req *request.GetRelationsDto) (interface{}, *fiber.Error) {
switch req.Type {
case "wiki-entity":
return s.getEntitiesByWikiIDs(ctx, req.IDs)
case "entity-wiki":
return s.getWikisByEntityIDs(ctx, req.IDs)
case "geometry-entity":
return s.getEntitiesByGeometryIDs(ctx, req.IDs)
case "entity-geometry":
return s.getGeometriesByEntityIDs(ctx, req.IDs)
default:
return nil, fiber.NewError(fiber.StatusBadRequest, "Unsupported relation type")
}
}
func (s *relationService) getEntitiesByWikiIDs(ctx context.Context, wikiIDs []string) (map[string][]*response.EntityResponse, *fiber.Error) {
mapping, err := s.entityRepo.GetEntityIDsByWikiIDs(ctx, wikiIDs)
if err != nil {
return nil, fiber.NewError(fiber.StatusInternalServerError, "Failed to fetch entity IDs by wiki IDs")
}
totalEntityIDs := 0
for _, eIDs := range mapping {
totalEntityIDs += len(eIDs)
}
entityIDMap := make(map[string]struct{}, totalEntityIDs)
allEntityIDs := make([]string, 0, totalEntityIDs)
for _, eIDs := range mapping {
for _, eID := range eIDs {
if _, ok := entityIDMap[eID]; !ok {
entityIDMap[eID] = struct{}{}
allEntityIDs = append(allEntityIDs, eID)
}
}
}
entities, err := s.entityRepo.GetByIDs(ctx, allEntityIDs)
if err != nil {
return nil, fiber.NewError(fiber.StatusInternalServerError, "Failed to fetch entities")
}
entitiesByID := make(map[string]*models.EntityEntity, len(entities))
for _, e := range entities {
entitiesByID[e.ID] = e
}
result := make(map[string][]*response.EntityResponse, len(wikiIDs))
for _, idStr := range wikiIDs {
eIDs, exists := mapping[idStr]
result[idStr] = make([]*response.EntityResponse, 0, len(eIDs))
if exists {
for _, eID := range eIDs {
if e, found := entitiesByID[eID]; found {
result[idStr] = append(result[idStr], e.ToResponse())
}
}
}
}
return result, nil
}
func (s *relationService) getWikisByEntityIDs(ctx context.Context, entityIDs []string) (map[string][]*response.WikiResponse, *fiber.Error) {
mapping, err := s.wikiRepo.GetWikiIDsByEntityIDs(ctx, entityIDs)
if err != nil {
return nil, fiber.NewError(fiber.StatusInternalServerError, "Failed to fetch wiki IDs by entity IDs")
}
totalWikiIDs := 0
for _, wIDs := range mapping {
totalWikiIDs += len(wIDs)
}
wikiIDMap := make(map[string]struct{}, totalWikiIDs)
allWikiIDs := make([]string, 0, totalWikiIDs)
for _, wIDs := range mapping {
for _, wID := range wIDs {
if _, ok := wikiIDMap[wID]; !ok {
wikiIDMap[wID] = struct{}{}
allWikiIDs = append(allWikiIDs, wID)
}
}
}
wikis, err := s.wikiRepo.GetByIDs(ctx, allWikiIDs)
if err != nil {
return nil, fiber.NewError(fiber.StatusInternalServerError, "Failed to fetch wikis")
}
wikisByID := make(map[string]*models.WikiEntity, len(wikis))
for _, w := range wikis {
wikisByID[w.ID] = w
}
result := make(map[string][]*response.WikiResponse, len(entityIDs))
for _, idStr := range entityIDs {
wIDs, exists := mapping[idStr]
result[idStr] = make([]*response.WikiResponse, 0, len(wIDs))
if exists {
for _, wID := range wIDs {
if w, found := wikisByID[wID]; found {
result[idStr] = append(result[idStr], w.ToResponse())
}
}
}
}
return result, nil
}
func (s *relationService) getEntitiesByGeometryIDs(ctx context.Context, geometryIDs []string) (map[string][]*response.EntityResponse, *fiber.Error) {
mapping, err := s.entityRepo.GetEntityIDsByGeometryIDs(ctx, geometryIDs)
if err != nil {
return nil, fiber.NewError(fiber.StatusInternalServerError, "Failed to fetch entity IDs by geometry IDs")
}
totalEntityIDs := 0
for _, eIDs := range mapping {
totalEntityIDs += len(eIDs)
}
entityIDMap := make(map[string]struct{}, totalEntityIDs)
allEntityIDs := make([]string, 0, totalEntityIDs)
for _, eIDs := range mapping {
for _, eID := range eIDs {
if _, ok := entityIDMap[eID]; !ok {
entityIDMap[eID] = struct{}{}
allEntityIDs = append(allEntityIDs, eID)
}
}
}
entities, err := s.entityRepo.GetByIDs(ctx, allEntityIDs)
if err != nil {
return nil, fiber.NewError(fiber.StatusInternalServerError, "Failed to fetch entities")
}
entitiesByID := make(map[string]*models.EntityEntity, len(entities))
for _, e := range entities {
entitiesByID[e.ID] = e
}
result := make(map[string][]*response.EntityResponse, len(geometryIDs))
for _, idStr := range geometryIDs {
eIDs, exists := mapping[idStr]
result[idStr] = make([]*response.EntityResponse, 0, len(eIDs))
if exists {
for _, eID := range eIDs {
if e, found := entitiesByID[eID]; found {
result[idStr] = append(result[idStr], e.ToResponse())
}
}
}
}
return result, nil
}
func (s *relationService) getGeometriesByEntityIDs(ctx context.Context, entityIDs []string) (map[string][]*response.GeometryResponse, *fiber.Error) {
mapping, err := s.geometryRepo.GetGeometryIDsByEntityIDs(ctx, entityIDs)
if err != nil {
return nil, fiber.NewError(fiber.StatusInternalServerError, "Failed to fetch geometry IDs by entity IDs")
}
totalGeometryIDs := 0
for _, gIDs := range mapping {
totalGeometryIDs += len(gIDs)
}
geometryIDMap := make(map[string]struct{}, totalGeometryIDs)
allGeometryIDs := make([]string, 0, totalGeometryIDs)
for _, gIDs := range mapping {
for _, gID := range gIDs {
if _, ok := geometryIDMap[gID]; !ok {
geometryIDMap[gID] = struct{}{}
allGeometryIDs = append(allGeometryIDs, gID)
}
}
}
geometries, err := s.geometryRepo.GetByIDs(ctx, allGeometryIDs)
if err != nil {
return nil, fiber.NewError(fiber.StatusInternalServerError, "Failed to fetch geometries")
}
geometriesByID := make(map[string]*models.GeometryEntity, len(geometries))
for _, g := range geometries {
geometriesByID[g.ID] = g
}
result := make(map[string][]*response.GeometryResponse, len(entityIDs))
for _, idStr := range entityIDs {
gIDs, exists := mapping[idStr]
result[idStr] = make([]*response.GeometryResponse, 0, len(gIDs))
if exists {
for _, gID := range gIDs {
if g, found := geometriesByID[gID]; found {
result[idStr] = append(result[idStr], g.ToResponse())
}
}
}
}
return result, nil
}