Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -0,0 +1,75 @@
package com.opendatamask.adapter.input.rest

import com.opendatamask.domain.port.input.CustomDataMappingUseCase
import com.opendatamask.domain.port.input.dto.BulkCustomDataMappingRequest
import com.opendatamask.domain.port.input.dto.CustomDataMappingRequest
import com.opendatamask.domain.port.input.dto.CustomDataMappingResponse
import jakarta.validation.Valid
import org.springframework.http.HttpStatus
import org.springframework.http.ResponseEntity
import org.springframework.web.bind.annotation.*

@RestController
@RequestMapping("/api/workspaces/{workspaceId}/mappings")
class CustomDataMappingController(
private val customDataMappingService: CustomDataMappingUseCase
) {

@PostMapping
fun createMapping(
@PathVariable workspaceId: Long,
@Valid @RequestBody request: CustomDataMappingRequest
): ResponseEntity<CustomDataMappingResponse> =
ResponseEntity.status(HttpStatus.CREATED)
.body(customDataMappingService.createMapping(workspaceId, request))

@GetMapping("/{mappingId}")
fun getMapping(
@PathVariable workspaceId: Long,
@PathVariable mappingId: Long
): ResponseEntity<CustomDataMappingResponse> =
ResponseEntity.ok(customDataMappingService.getMapping(workspaceId, mappingId))

@GetMapping
fun listMappings(
@PathVariable workspaceId: Long,
@RequestParam(required = false) connectionId: Long?,
@RequestParam(required = false) tableName: String?
): ResponseEntity<List<CustomDataMappingResponse>> {
val hasConnectionId = connectionId != null
val hasTableName = !tableName.isNullOrBlank()
if (hasConnectionId != hasTableName) {
return ResponseEntity.badRequest().build()
}
val result = if (hasConnectionId && hasTableName) {
customDataMappingService.listMappingsForTable(workspaceId, connectionId!!, tableName!!)
} else {
customDataMappingService.listMappings(workspaceId)
}
return ResponseEntity.ok(result)
}

@PutMapping("/{mappingId}")
fun updateMapping(
@PathVariable workspaceId: Long,
@PathVariable mappingId: Long,
@Valid @RequestBody request: CustomDataMappingRequest
): ResponseEntity<CustomDataMappingResponse> =
ResponseEntity.ok(customDataMappingService.updateMapping(workspaceId, mappingId, request))

@DeleteMapping("/{mappingId}")
fun deleteMapping(
@PathVariable workspaceId: Long,
@PathVariable mappingId: Long
): ResponseEntity<Void> {
customDataMappingService.deleteMapping(workspaceId, mappingId)
return ResponseEntity.noContent().build()
}

@PostMapping("/bulk")
fun saveBulkMappings(
@PathVariable workspaceId: Long,
@Valid @RequestBody request: BulkCustomDataMappingRequest
): ResponseEntity<List<CustomDataMappingResponse>> =
ResponseEntity.ok(customDataMappingService.saveBulkMappings(workspaceId, request))
}
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
package com.opendatamask.adapter.input.rest

import com.opendatamask.domain.port.input.dto.ConnectionSchemaResponse
import com.opendatamask.domain.port.input.dto.ConnectionTestResult
import com.opendatamask.domain.port.input.dto.DataConnectionRequest
import com.opendatamask.domain.port.input.dto.DataConnectionResponse
Expand Down Expand Up @@ -62,5 +63,12 @@ class DataConnectionController(
): ResponseEntity<ConnectionTestResult> {
return ResponseEntity.ok(dataConnectionService.testConnection(workspaceId, connectionId))
}

@GetMapping("/{connectionId}/schema")
fun browseConnectionSchema(
@PathVariable workspaceId: Long,
@PathVariable connectionId: Long
): ResponseEntity<ConnectionSchemaResponse> =
ResponseEntity.ok(dataConnectionService.browseConnectionSchema(workspaceId, connectionId))
}

Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
package com.opendatamask.adapter.output.persistence

import com.opendatamask.domain.model.CustomDataMapping
import com.opendatamask.domain.port.output.CustomDataMappingPort
import org.springframework.data.jpa.repository.JpaRepository
import org.springframework.stereotype.Repository
import java.util.Optional

@Repository
interface CustomDataMappingRepository : JpaRepository<CustomDataMapping, Long>, CustomDataMappingPort {
override fun findById(id: Long): Optional<CustomDataMapping>
override fun findByWorkspaceId(workspaceId: Long): List<CustomDataMapping>
override fun findByWorkspaceIdAndConnectionIdAndTableName(
workspaceId: Long,
connectionId: Long,
tableName: String
): List<CustomDataMapping>
override fun save(mapping: CustomDataMapping): CustomDataMapping
override fun deleteById(id: Long)
override fun deleteByWorkspaceIdAndConnectionIdAndTableName(
workspaceId: Long,
connectionId: Long,
tableName: String
)
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,135 @@
package com.opendatamask.application.service

import com.opendatamask.domain.model.CustomDataMapping
import com.opendatamask.domain.model.GeneratorType
import com.opendatamask.domain.model.MappingAction
import com.opendatamask.domain.model.MaskingStrategy
import com.opendatamask.domain.port.input.CustomDataMappingUseCase
import com.opendatamask.domain.port.input.dto.BulkCustomDataMappingRequest
import com.opendatamask.domain.port.input.dto.CustomDataMappingRequest
import com.opendatamask.domain.port.input.dto.CustomDataMappingResponse
import com.opendatamask.domain.port.output.CustomDataMappingPort
import org.springframework.stereotype.Service
import org.springframework.transaction.annotation.Transactional

@Service
class CustomDataMappingService(
private val customDataMappingRepository: CustomDataMappingPort
) : CustomDataMappingUseCase {

@Transactional
override fun createMapping(workspaceId: Long, request: CustomDataMappingRequest): CustomDataMappingResponse {
validateMaskingCombination(request.action, request.maskingStrategy, request.fakeGeneratorType)
val mapping = CustomDataMapping(
workspaceId = workspaceId,
connectionId = request.connectionId,
tableName = request.tableName,
columnName = request.columnName,
action = request.action,
maskingStrategy = if (request.action == MappingAction.MASK) request.maskingStrategy else null,
fakeGeneratorType = if (request.action == MappingAction.MASK) request.fakeGeneratorType else null
)
Comment on lines +27 to +31
Copy link

Copilot AI Apr 16, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The service persists action = MASK even when maskingStrategy/fakeGeneratorType are missing in the request, which allows invalid mappings to be stored (e.g., MASK with null strategy). Add cross-field validation (DTO @AssertTrue or explicit checks) to enforce valid combinations before saving.

Copilot uses AI. Check for mistakes.
return customDataMappingRepository.save(mapping).toResponse()
}

@Transactional(readOnly = true)
override fun getMapping(workspaceId: Long, mappingId: Long): CustomDataMappingResponse =
findMapping(workspaceId, mappingId).toResponse()

@Transactional(readOnly = true)
override fun listMappings(workspaceId: Long): List<CustomDataMappingResponse> =
customDataMappingRepository.findByWorkspaceId(workspaceId).map { it.toResponse() }

@Transactional(readOnly = true)
override fun listMappingsForTable(
workspaceId: Long,
connectionId: Long,
tableName: String
): List<CustomDataMappingResponse> =
customDataMappingRepository
.findByWorkspaceIdAndConnectionIdAndTableName(workspaceId, connectionId, tableName)
.map { it.toResponse() }

@Transactional
override fun updateMapping(
workspaceId: Long,
mappingId: Long,
request: CustomDataMappingRequest
): CustomDataMappingResponse {
validateMaskingCombination(request.action, request.maskingStrategy, request.fakeGeneratorType)
val mapping = findMapping(workspaceId, mappingId)
mapping.connectionId = request.connectionId
mapping.tableName = request.tableName
mapping.columnName = request.columnName
mapping.action = request.action
mapping.maskingStrategy = if (request.action == MappingAction.MASK) request.maskingStrategy else null
mapping.fakeGeneratorType = if (request.action == MappingAction.MASK) request.fakeGeneratorType else null
return customDataMappingRepository.save(mapping).toResponse()
}

@Transactional
override fun deleteMapping(workspaceId: Long, mappingId: Long) {
val mapping = findMapping(workspaceId, mappingId)
customDataMappingRepository.deleteById(mapping.id)
}

@Transactional
override fun saveBulkMappings(
workspaceId: Long,
request: BulkCustomDataMappingRequest
): List<CustomDataMappingResponse> {
customDataMappingRepository.deleteByWorkspaceIdAndConnectionIdAndTableName(
workspaceId, request.connectionId, request.tableName
)
val mappings = request.columnMappings.map { entry ->
validateMaskingCombination(entry.action, entry.maskingStrategy, entry.fakeGeneratorType)
CustomDataMapping(
workspaceId = workspaceId,
connectionId = request.connectionId,
tableName = request.tableName,
columnName = entry.columnName,
action = entry.action,
maskingStrategy = if (entry.action == MappingAction.MASK) entry.maskingStrategy else null,
fakeGeneratorType = if (entry.action == MappingAction.MASK) entry.fakeGeneratorType else null
)
Comment on lines +91 to +94
Copy link

Copilot AI Apr 16, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

saveBulkMappings() will persist entries with action = MASK even when maskingStrategy is null (or FAKE without a generator), which allows invalid mappings to be stored. Add per-entry validation (DTO @AssertTrue / explicit checks) before saving.

Copilot uses AI. Check for mistakes.
}
return mappings.map { customDataMappingRepository.save(it).toResponse() }
}

private fun findMapping(workspaceId: Long, mappingId: Long): CustomDataMapping {
val mapping = customDataMappingRepository.findById(mappingId)
.orElseThrow { NoSuchElementException("Custom data mapping not found: $mappingId") }
if (mapping.workspaceId != workspaceId) {
throw NoSuchElementException("Mapping $mappingId does not belong to workspace $workspaceId")
}
return mapping
}

private fun validateMaskingCombination(
action: MappingAction,
maskingStrategy: MaskingStrategy?,
fakeGeneratorType: GeneratorType?
) {
if (action == MappingAction.MASK) {
if (maskingStrategy == null) {
throw IllegalArgumentException("maskingStrategy is required when action is MASK")
}
if (maskingStrategy == MaskingStrategy.FAKE && fakeGeneratorType == null) {
throw IllegalArgumentException("fakeGeneratorType is required when maskingStrategy is FAKE")
}
}
}

private fun CustomDataMapping.toResponse() = CustomDataMappingResponse(
id = id,
workspaceId = workspaceId,
connectionId = connectionId,
tableName = tableName,
columnName = columnName,
action = action,
maskingStrategy = maskingStrategy,
fakeGeneratorType = fakeGeneratorType,
createdAt = createdAt,
updatedAt = updatedAt
)
}
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import com.opendatamask.domain.port.input.DataConnectionUseCase
import com.opendatamask.domain.port.output.EncryptionPort
import com.opendatamask.domain.port.output.ConnectorFactoryPort
import com.opendatamask.domain.port.output.DataConnectionPort
import com.opendatamask.domain.port.input.dto.ConnectionSchemaResponse
import com.opendatamask.domain.port.input.dto.ConnectionTestResult
import com.opendatamask.domain.port.input.dto.DataConnectionRequest
import com.opendatamask.domain.port.input.dto.DataConnectionResponse
Expand Down Expand Up @@ -108,6 +109,34 @@ class DataConnectionService(
}
}

Copy link

Copilot AI Apr 16, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

browseConnectionSchema() is missing a @Transactional(readOnly = true) annotation even though it reads from the repository (and other read methods in this service are marked read-only). Adding it keeps behavior consistent and avoids unnecessary transaction semantics.

Suggested change
@Transactional(readOnly = true)

Copilot uses AI. Check for mistakes.
@Transactional(readOnly = true)
override fun browseConnectionSchema(workspaceId: Long, connectionId: Long): ConnectionSchemaResponse {
val connection = findConnection(workspaceId, connectionId)
val decryptedConnectionString = encryptionPort.decrypt(connection.connectionString)
val decryptedPassword = connection.password?.let { encryptionPort.decrypt(it) }

val connector = connectorFactory.createConnector(
type = connection.type,
connectionString = decryptedConnectionString,
username = connection.username,
password = decryptedPassword,
database = connection.database
)

val tables = connector.listTables().map { tableName ->
val columns = connector.listColumns(tableName).map { col ->
ConnectionSchemaResponse.ColumnSchemaInfo(
name = col.name,
type = col.type,
Comment on lines +126 to +130
Copy link

Copilot AI Apr 16, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

browseConnectionSchema() maps over all tables and calls connector.listColumns(tableName) for each one. For JDBC connectors, listColumns() opens a new DB connection per call, so schemas with many tables can cause a connection storm. Consider adding a connector API that returns tables+columns in one call / reusing a single connection, or limiting the number of tables returned.

Copilot uses AI. Check for mistakes.
nullable = col.nullable
)
}
ConnectionSchemaResponse.TableSchemaInfo(tableName = tableName, columns = columns)
}

return ConnectionSchemaResponse(connectionId = connectionId, tables = tables)
}

private fun findConnection(workspaceId: Long, connectionId: Long): DataConnection {
val connection = dataConnectionRepository.findById(connectionId)
.orElseThrow { NoSuchElementException("Connection not found: $connectionId") }
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,69 @@
package com.opendatamask.domain.model

import jakarta.persistence.*
import java.time.LocalDateTime

enum class MappingAction {
MIGRATE_AS_IS, MASK
}

enum class MaskingStrategy {
FAKE, HASH, NULL
}

@Entity
@Table(
name = "custom_data_mappings",
uniqueConstraints = [
UniqueConstraint(
name = "uk_cdm_workspace_connection_table_column",
columnNames = ["workspace_id", "connection_id", "table_name", "column_name"]
)
]
)
class CustomDataMapping(
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
val id: Long = 0,

@Column(name = "workspace_id", nullable = false)
var workspaceId: Long,

@Column(name = "connection_id", nullable = false)
var connectionId: Long,

@Column(name = "table_name", nullable = false)
var tableName: String,

@Column(name = "column_name", nullable = false)
var columnName: String,

@Enumerated(EnumType.STRING)
@Column(nullable = false)
var action: MappingAction,

@Enumerated(EnumType.STRING)
@Column
var maskingStrategy: MaskingStrategy? = null,

@Enumerated(EnumType.STRING)
@Column
var fakeGeneratorType: GeneratorType? = null,

@Column(nullable = false)
var createdAt: LocalDateTime = LocalDateTime.now(),

@Column(nullable = false)
var updatedAt: LocalDateTime = LocalDateTime.now()
) {
@PrePersist
fun prePersist() {
createdAt = LocalDateTime.now()
updatedAt = LocalDateTime.now()
}

@PreUpdate
fun preUpdate() {
updatedAt = LocalDateTime.now()
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
package com.opendatamask.domain.port.input

import com.opendatamask.domain.port.input.dto.BulkCustomDataMappingRequest
import com.opendatamask.domain.port.input.dto.CustomDataMappingRequest
import com.opendatamask.domain.port.input.dto.CustomDataMappingResponse

interface CustomDataMappingUseCase {
fun createMapping(workspaceId: Long, request: CustomDataMappingRequest): CustomDataMappingResponse
fun getMapping(workspaceId: Long, mappingId: Long): CustomDataMappingResponse
fun listMappings(workspaceId: Long): List<CustomDataMappingResponse>
fun listMappingsForTable(workspaceId: Long, connectionId: Long, tableName: String): List<CustomDataMappingResponse>
fun updateMapping(workspaceId: Long, mappingId: Long, request: CustomDataMappingRequest): CustomDataMappingResponse
fun deleteMapping(workspaceId: Long, mappingId: Long)
fun saveBulkMappings(workspaceId: Long, request: BulkCustomDataMappingRequest): List<CustomDataMappingResponse>
}
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
package com.opendatamask.domain.port.input

import com.opendatamask.domain.port.input.dto.ConnectionSchemaResponse
import com.opendatamask.domain.port.input.dto.ConnectionTestResult
import com.opendatamask.domain.port.input.dto.DataConnectionRequest
import com.opendatamask.domain.port.input.dto.DataConnectionResponse
Expand All @@ -11,4 +12,5 @@ interface DataConnectionUseCase {
fun updateConnection(workspaceId: Long, connectionId: Long, request: DataConnectionRequest): DataConnectionResponse
fun deleteConnection(workspaceId: Long, connectionId: Long)
fun testConnection(workspaceId: Long, connectionId: Long): ConnectionTestResult
fun browseConnectionSchema(workspaceId: Long, connectionId: Long): ConnectionSchemaResponse
}
Loading
Loading