A Dataset only solution would look like this:
case class orig(city: String, product: String, Jan: Int, Feb: Int, Mar: Int)
case class newOne(city: String, product: String, metric_type: String, metric_value: Int)
val df = Seq(("c1", "p1", 123, 22, 34), ("c2", "p2", 234, 432, 43)).toDF("city", "product", "Jan", "Feb", "Mar")
val newDf = df.as[orig].flatMap(v => Seq(newOne(v.city, v.product, "Jan", v.Jan), newOne(v.city, v.product, "Feb", v.Feb), newOne(v.city, v.product, "Mar", v.Mar)))
newDf.show()
>>+----+-------+-----------+-----------+
>>|city|product|metric_type|metric_value|
>>+----+-------+-----------+-----------+
>>| c1| p1| Jan| 123|
>>| c1| p1| Feb| 22|
>>| c1| p1| Mar| 34|
>>| c2| p2| Jan| 234|
>>| c2| p2| Feb| 432|
>>| c2| p2| Mar| 43|
>>+----+-------+-----------+-----------+
Using dataframe API
While the OP asked specifically for dataset only without spark sql, for others who look at this question, I believe a dataframe solution should be used.
First it is important to understand that dataset API is part of the spark SQL API. Datasets and dataframes are interchangeable and actually dataframe is simply a DataSet[Row]. While dataset has both "typed" and "untyped" API, ignoring some of the API seems wrong to me.
Second, pure "typed" option has limitations. For example, if we had 100 months instead of 3 then doing it the way above would be impractical.
Lastly, Spark provides a lot of optimization on dataframes which are unavaiable when using typed API (as the typed API is opaque to Spark) and therefore in many cases would get worse performance.
I would suggest using the following dataframe solution:
val df = Seq(("c1", "p1", 123, 22, 34), ("c2", "p2", 234, 432, 43)).toDF("city", "product", "Jan", "Feb", "Mar")
val months = Seq("Jan", "Feb", "Mar")
val arrayedDF = df.withColumn("combined", array(months.head, months.tail: _*))_*)).select("city", "product", "combined")
val explodedDF = arrayedDF.selectExpr("city", "product", "posexplode(combined) as (pos, metricValue)")
val u = udf((p: Int) => months(p))
val targetDF = explodedDF.withColumn("metric_type", u($"pos")).drop("pos")
targetDF.show()
>>+----+-------+-----------+-----------+
>>|city|product|metricValue|metric_type|
>>+----+-------+-----------+-----------+
>>| c1| p1| 123| Jan|
>>| c1| p1| 22| Feb|
>>| c1| p1| 34| Mar|
>>| c2| p2| 234| Jan|
>>| c2| p2| 432| Feb|
>>| c2| p2| 43| Mar|
>>+----+-------+-----------+-----------+
While this is a little longer, it handles the more generic case.