diff --git a/cmd/api/server.go b/cmd/api/server.go index 8f67400..6bc0b90 100644 --- a/cmd/api/server.go +++ b/cmd/api/server.go @@ -162,6 +162,7 @@ func (s *FiberServer) SetupServer( statisticService := services.NewStatisticService(statisticRepo) battleReplayService := services.NewBattleReplayService(battleReplayRepo) goongService := services.NewGoongService(redis) + relationService := services.NewRelationService(wikiRepo, entityRepo, geometryRepo) // controller setup authController := controllers.NewAuthController(authService, oauth) @@ -181,6 +182,7 @@ func (s *FiberServer) SetupServer( statisticController := controllers.NewStatisticController(statisticService) battleReplayController := controllers.NewBattleReplayController(battleReplayService) goongController := controllers.NewGoongController(goongService) + relationController := controllers.NewRelationController(relationService) // route setup routes.AuthRoutes(s.App, authController, userRepo) @@ -193,11 +195,12 @@ func (s *FiberServer) SetupServer( routes.EntityRoutes(s.App, entityController) routes.GeometryRoutes(s.App, geometryController) 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.SubmissionRoutes(s.App, submissionController, userRepo) routes.ChatbotRoutes(s.App, chatbotController, userRepo) routes.StatisticRoutes(s.App, statisticController, userRepo) + routes.BattleReplayRoutes(s.App, battleReplayController) routes.GoongRoutes(s.App, goongController) routes.NotFoundRoute(s.App) diff --git a/db/query/geometries.sql b/db/query/geometries.sql index 5c96615..d6a0f14 100644 --- a/db/query/geometries.sql +++ b/db/query/geometries.sql @@ -248,3 +248,9 @@ SELECT )::uuid[] AS replay_ids FROM geometries 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[]); + diff --git a/db/query/wiki.sql b/db/query/wiki.sql index dd82b9f..c58038d 100644 --- a/db/query/wiki.sql +++ b/db/query/wiki.sql @@ -132,3 +132,9 @@ ORDER BY created_at DESC; SELECT entity_id, wiki_id FROM entity_wikis WHERE entity_id = ANY($1::uuid[]); + +-- name: GetEntityIDsByWikiIDs :many +SELECT wiki_id, entity_id +FROM entity_wikis +WHERE wiki_id = ANY($1::uuid[]); + diff --git a/docs/docs.go b/docs/docs.go index e84819a..9724823 100644 --- a/docs/docs.go +++ b/docs/docs.go @@ -895,6 +895,13 @@ const docTemplate = `{ "name": "has_bound", "in": "query" }, + { + "maximum": 100, + "minimum": 1, + "type": "integer", + "name": "limit", + "in": "query" + }, { "maximum": 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": { "get": { "description": "Get entities grouped by geometry IDs", @@ -4900,7 +4967,8 @@ const docTemplate = `{ "type": "string" }, "question": { - "type": "string" + "type": "string", + "maxLength": 500 } } }, diff --git a/docs/swagger.json b/docs/swagger.json index edda57e..e1b2707 100644 --- a/docs/swagger.json +++ b/docs/swagger.json @@ -888,6 +888,13 @@ "name": "has_bound", "in": "query" }, + { + "maximum": 100, + "minimum": 1, + "type": "integer", + "name": "limit", + "in": "query" + }, { "maximum": 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": { "get": { "description": "Get entities grouped by geometry IDs", @@ -4893,7 +4960,8 @@ "type": "string" }, "question": { - "type": "string" + "type": "string", + "maxLength": 500 } } }, diff --git a/docs/swagger.yaml b/docs/swagger.yaml index 03c6257..5ebf2e3 100644 --- a/docs/swagger.yaml +++ b/docs/swagger.yaml @@ -82,6 +82,7 @@ definitions: project_id: type: string question: + maxLength: 500 type: string required: - question @@ -1327,6 +1328,11 @@ paths: - in: query name: has_bound type: boolean + - in: query + maximum: 100 + minimum: 1 + name: limit + type: integer - in: query maximum: 90 minimum: -90 @@ -2655,6 +2661,48 @@ paths: summary: Get raster tile metadata tags: - 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: get: consumes: diff --git a/internal/controllers/relationController.go b/internal/controllers/relationController.go new file mode 100644 index 0000000..848bc62 --- /dev/null +++ b/internal/controllers/relationController.go @@ -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, + }) +} diff --git a/internal/dtos/request/relation.go b/internal/dtos/request/relation.go index 6e57160..276e363 100644 --- a/internal/dtos/request/relation.go +++ b/internal/dtos/request/relation.go @@ -11,3 +11,18 @@ type GetEntitiesByGeometryIDsDto struct { type GetWikiContentsPreviewDto struct { 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"` +} + + diff --git a/internal/gen/sqlc/geometries.sql.go b/internal/gen/sqlc/geometries.sql.go index 99cf9f9..f4baebd 100644 --- a/internal/gen/sqlc/geometries.sql.go +++ b/internal/gen/sqlc/geometries.sql.go @@ -538,6 +538,37 @@ func (q *Queries) GetGeometryById(ctx context.Context, id pgtype.UUID) (GetGeome 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 SELECT g.id, g.geo_type, g.draw_geometry, g.bound_with, g.time_start, g.time_end, g.project_id, diff --git a/internal/gen/sqlc/wiki.sql.go b/internal/gen/sqlc/wiki.sql.go index 478176b..48c6f30 100644 --- a/internal/gen/sqlc/wiki.sql.go +++ b/internal/gen/sqlc/wiki.sql.go @@ -188,6 +188,37 @@ func (q *Queries) DeleteWikisByIDs(ctx context.Context, dollar_1 []pgtype.UUID) 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 SELECT id, project_id, title, slug, is_deleted, created_at, updated_at FROM wikis diff --git a/internal/repositories/entityRepository.go b/internal/repositories/entityRepository.go index 57342e1..3657084 100644 --- a/internal/repositories/entityRepository.go +++ b/internal/repositories/entityRepository.go @@ -25,6 +25,7 @@ type EntityRepository interface { Delete(ctx context.Context, id pgtype.UUID) error DeleteByIDs(ctx context.Context, ids []pgtype.UUID) 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) WithTx(tx pgx.Tx) EntityRepository } @@ -473,3 +474,63 @@ func (r *entityRepository) GetEntityIDsByGeometryIDs(ctx context.Context, geomet 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 +} + diff --git a/internal/repositories/geometryRepository.go b/internal/repositories/geometryRepository.go index 1b00b41..a859492 100644 --- a/internal/repositories/geometryRepository.go +++ b/internal/repositories/geometryRepository.go @@ -32,6 +32,7 @@ type GeometryRepository interface { BulkDeleteEntityGeometriesByGeometryID(ctx context.Context, geometryID pgtype.UUID) error DeleteEntityGeometry(ctx context.Context, entityID pgtype.UUID, geometryID 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 } @@ -565,3 +566,63 @@ func (r *geometryRepository) SearchByEntityName(ctx context.Context, params sqlc 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 +} + diff --git a/internal/routes/relationRoute.go b/internal/routes/relationRoute.go index 8c25638..adeb478 100644 --- a/internal/routes/relationRoute.go +++ b/internal/routes/relationRoute.go @@ -6,9 +6,16 @@ import ( "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.Get("", relationController.GetRelations) relation.Get("/wikis-by-entities", wikiController.GetWikisByEntityIDs) relation.Get("/entities-by-geometries", entityController.GetEntitiesByGeometryIDs) relation.Get("/wiki-contents/preview", wikiController.GetWikiContentsPreviewByIDs) } + diff --git a/internal/services/geometryService_test.go b/internal/services/geometryService_test.go deleted file mode 100644 index 2505222..0000000 --- a/internal/services/geometryService_test.go +++ /dev/null @@ -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) - } -} diff --git a/internal/services/relationService.go b/internal/services/relationService.go new file mode 100644 index 0000000..7432f38 --- /dev/null +++ b/internal/services/relationService.go @@ -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 +}