Saltar a contenido

kmapx — qué se puede y qué no (con el porqué)

Referencia honesta del alcance actual. Los límites están clasificados en tres tipos: (D) por diseño (no va a cambiar), (V1) alcance de v1 (podría crecer), (A) arquitectónico (límite de KSP, difícil de cambiar sin volverse compiler-plugin).


Lo que SE PUEDE hacer

Declaración

@MapTo(PersonDto::class)                          // modo embedded: fun Person.toPersonDto()
@MapTo(Summary::class, name = "asSummary")        // repetible + nombre custom
data class Person(val name: String, val age: Int)

@Mapper interface PersonMapper {                  // modo contract: object/class PersonMapperImpl
    fun toDto(p: Person): PersonDto               // dominio limpio (sin anotar Person/PersonDto)
}

Construcción

  • Orden determinista: @MapConstructor@MapFactory → constructor primario.
  • vars de cuerpo post-construcción vía .also { }.
  • Defaults del target: onNull = TARGET_DEFAULT (campo, mapper/mapeo o global) omite el argumento (hasta 2 campos).

Null-safety (T? → T es error salvo estrategia)

data class Dto(
    @MapField(onNull = OnNull.LITERAL, default = "N/A") val nick: String,  // ?: "N/A" (parseo tipado)
    @MapField(onNull = OnNull.THROW)          val email: String,            // ?: throw
    @MapField(onNull = OnNull.TYPE_DEFAULT)   val tags: List<String>,       // ?: emptyList() (escalares → 0/""/false)
    @MapField(onNull = OnNull.UNSAFE)         val legacy: String,           // !! (el único posible)
@MapField(ignore = true)                  val createdAt: Instant? = null, // Excluido (exige default)
)

Conversiones estructurales

@JvmInline value class UserId(val v: String)      // id.value / UserId(s)
List<Address>  List<AddressDto>                  // items.map { it.toAddressDto() }
Set / Array<T> / Result<T> / Map<K,V>             // una pasada, sin copias
Iterable / Collection / Sequence   List          // fuentes lazy/genéricas (Sequence: + .toList())

Converters

@Converter fun isoInstant(v: Instant): String = v.toString()        // global, por tipo
object Cents : Converts<Long,String> { ... }                        // calificado, object
@MapField(converter = Cents::class) val price: String

@Component class CustomerName(repo: CustomerRepo) : Converts<Long,String> { ... }  // bean
@Mapper(componentModel = SPRING) interface M { @MapField(target = "x", converter = CustomerName::class) ... }
@MapperConfig(componentModel = SPRING, onNull = THROW) interface Cfg   // Profile reutilizable
@Mapper(config = Cfg::class) interface N { ... }                      //   → hereda settings (cascada)
@InverseOf("toDto") fun fromDto(d: Dto): Entity                        // La vuelta, invertida y validada (KMX028)
- stdConverters = true (@MapTo/@Mapper/@MapperConfig o kmapx.stdConverters global): suma las conversiones estándar String↔UUID/BigDecimal/BigInteger/Instant, Long↔Instant (epoch millis). Solo JVM (usa tipos java.*).

Extensibilidad (0.2)

// Pack de converters listos — sin escribir ni un @Converter (ver guia-mapeo):
dependencies {
    implementation("io.github.kuroxbyte:kmapx-ext-jvm:<v>")   // java.time, UUID, BigDecimal, URI…
    ksp("io.github.kuroxbyte:kmapx-ext-jvm:<v>")
}
// Tu propio pack: implementá KmapxExtension (kmapx-spi) y registralo en META-INF/services.
- Un @Converter tuyo para un par siempre gana sobre el del pack; un pack jamás introduce ambigüedad (KMX009) ni suprime un error — solo añade caminos válidos. - El SPI (kmapx-spi) es experimental hasta 1.0 (@KmapxExperimentalSpi).

Renombrado y aplanado (en ambas sedes: campo y método)

@MapField(from = "firstName")            val name: String
@MapField(from = "address.country.code") val code: String       // ruta con nulabilidad por segmento

Enums, sealed, bidireccional, patch

@MapEntry(target = "PENDING") CREATED                       // enums con nombres distintos
@MapSubtype(target = Approved::class)                       // sealed paralelos
@BiMapTo(Dto::class)                                        // ambas direcciones, invertibilidad VALIDADA
@Mapper interface P { fun apply(t: T, patch: Pt): T }  // PATCH por forma: retorno == 1er param
val note: Patch<String?> = Patch.Keep                       // set-null explícito

Integraciones y config

  • @Mapper(componentModel = SPRING | KOIN)@Component / módulo Koin.
  • @Mapper(inheritFrom = Base::class) — hereda config por método.
  • @SuppressKmapx("KMX021") — silencia un WARNING puntual.
  • Bloque kmapx { }: useSerialNames, onNull (cascada: campo > mapper/mapeo > global), warningsAsErrors, report.
  • KMP: generación por target (JVM/JS/Wasm/Native).
  • Reporte de cobertura JSON/HTML (kmapx.report).

Lo que NO se puede (y por qué)

Conversiones

No se puede Resultado Por qué
Long → Int (narrowing con pérdida), enum → String KMX004 (D) lista CERRADA; nada de conversiones implícitas silenciosas. Usá @Converter. El widening SIN pérdida (Int→Long, Float→Double…) SÍ es automático.
Cruce de contenedor: List → Set, Map → List KMX004 (D) la lista de contenedores es cerrada.
IntArray/LongArray (arrays primitivos) elemento a elemento KMX004 (passthrough solo si idéntico) (D) solo Array<T>; los primitivos son passthrough.
Iterable/SequenceSet/Array/Result KMX004 (V1) solo → List/Collection/Iterable.
Mapeo con @Mapping(expression = "java(...)") (strings con código) (D) jamás strings con código; usá un @Converter (refactor-safe).
Serializar un objeto a JSON/XML automáticamente (Meta → String) KMX004 (D) kmapx mapea ESTRUCTURA, no serializa: es específico del tipo (necesita su serializador) y una responsabilidad de tu librería de serialización. Escribí un @Converter fun (m: Meta): String = Json.encodeToString(m) — de una línea, explícito y testeable.

Anidados y composición

No se puede Resultado Por qué
Auto-mapear un anidado @Mapper (contract) de otro módulo KMX007 (V1) el embedded @MapTo cross-module YA se resuelve (marcador @GeneratedMapping en el classpath); el contract queda pendiente. Declará el par en el consumidor o usá @Converter.
Auto-generar el mapper anidado sin declararlo KMX007 (D) resolución por referencia: el par tiene que existir (@MapTo/@Converter).
Des-aplanar (construir anidado desde campos planos) (V1) las rutas solo LEEN (aplanan), no construyen.
Ciclo de mapeo (A→B→A) KMX008 (D) se detecta y se corta (no recursión infinita).
Jerarquías sealed anidadas (>1 nivel) KMX024 (V1) un nivel en v1.

Modo interfaz / DI

No se puede Resultado Por qué
@MapField(converter=) con bean (class) en modo embedded (extension) KMX034 (A) una función top-level no tiene dónde inyectar; solo modo contract.
Inyectar un bean que no tenga forma Converts<A,B> (un service cualquiera) (D) guardrail: mantiene al mapper como traducción, no application-service.
Enriquecer un campo con un repo dentro del mapper (lógica que no es A→B) (D) va en la capa de aplicación (mapper puro); el converter inyectado solo cubre el caso A→B.
Verificar en compile-time que el bean esté registrado en Koin falla en runtime (A) el registro vive fuera de la unidad de compilación. (En Spring SÍ se valida: KMX035.)
@Qualifier/scopes/prototype para el bean inyectado (V1) se asume singleton.
Interfaz @Mapper genérica o herencia entre @Mapper KMX015 (V1) no soportado en v1.

PATCH

No se puede Resultado Por qué
Setear null en un PATCH normal (campo T?) null = conservar (D) semántica JSON-Merge-Patch; para borrar usá Patch<T> (Set(null)).
onNull = LITERAL/THROW/… de @MapField en PATCH inaplicable (D) en un PATCH el null del patch significa "conservar el target" (no es una violación T?→T), así que la estrategia no tiene dónde actuar. El converter calificado SÍ se aplica (a Patch<T> y al fallback).
Config por método (@MapField) en métodos PATCH (V1) el patch no consume la sede de método; su config vive en la clase target.
afterApply distinto por método en un patcher multi-método (V1) un solo hook afterApply por interfaz.
Merge de colecciones (patch de una lista fusiona elementos) reemplazo completo (V1) null = no tocar; si viene, reemplaza toda la colección.

Enums / bidireccional

No se puede Resultado Por qué
@BiMapTo con fallback de clase (@MapEntry catch-all sobre el enum) KMX028 (D) un fallback es fan-in (varios entries → uno): la vuelta no sabe a cuál volver. Los overrides POR ENTRY sí se invierten solos.
Entry del source sin par (ni por nombre ni @MapEntry) KMX026 (D) exhaustividad total, sin else.

Arquitectura / DX

No se puede Resultado Por qué
DSL tipo Mappie (to::x fromProperty from::y) (A) KSP no lee el cuerpo de lambdas; requeriría ser compiler-plugin.
Referencia de propiedad type-safe en anotación (from = Src::name) (A) las anotaciones de Kotlin no aceptan KProperty (solo String/KClass/enum).
Mezclar cuerpo escrito a mano + generado en el mismo mapper (abstract class mapper) (A) KSP no ve/edita cuerpos; solo genera archivos nuevos.
expect/actual anotadas KMX025 (V1) no-goal v1; anotá la actual por target o mapeá una clase común.
@Converter con 2+ params, suspend, receiver, o no-top-level KMX019 (D) debe ser función pura (A) -> B.
Más de 2 defaults con omisión condicional KMX022 (D) el when explota; anotá los extra con estrategia.

Cómo leer los "por qué"

  • (D) Por diseño: son los principios que fundan el proyecto (cero conversiones implícitas, cero strings con código, cero reflection, cero DI entre mappers). No van a cambiar — son la garantía de "todo mapeo inseguro es error de compilación".
  • (V1) Alcance de v1: decisiones de acotar, no de imposibilidad. Pueden crecer con demanda (cross-module, sede de método en PATCH, converter en PATCH, Sequence→Set, des-aplanar…). Muchas "conversiones que faltan" no esperan a una release: un pack (kmapx-ext-*) o tu propio KmapxExtension (SPI) las añade desde fuera, sin tocar la librería.
  • (A) Arquitectónico: límites reales de KSP (no lee lambdas/expresiones; anotaciones sin KProperty). Cambiarlos implicaría abandonar KSP por un compiler-plugin — otra tecnología.

Ante la duda, el compilador te dice qué falta: cada KMXnnn trae ubicación exacta y Fix:.