diff --git a/upickle/implicits/src-2/upickle/implicits/internal/Macros.scala b/upickle/implicits/src-2/upickle/implicits/internal/Macros.scala index db3b5b6dd..dfc4dcc16 100644 --- a/upickle/implicits/src-2/upickle/implicits/internal/Macros.scala +++ b/upickle/implicits/src-2/upickle/implicits/internal/Macros.scala @@ -242,6 +242,13 @@ object Macros { hasDefaults: Seq[Boolean], targetType: c.Type, varargs: Boolean) = { + val allowUnknownKeysAnnotation = targetType.typeSymbol + .annotations + .find(_.tpe == typeOf[upickle.implicits.ignoreUnknownKeys]) + .flatMap(_.scalaArgs.headOption) + .map { case Literal(Constant(b)) => b.asInstanceOf[Boolean] } + .headOption + val defaults = deriveDefaults(companion, hasDefaults) val localReaders = for (i <- rawArgs.indices) yield TermName("localReader" + i) @@ -271,7 +278,19 @@ object Macros { for(i <- mappedArgs.indices) yield cq"${mappedArgs(i)} => $i" } - case _ => -1 + case _ => + ${ + println(allowUnknownKeysAnnotation) + allowUnknownKeysAnnotation match { + case None => + q""" + if (${c.prefix}.ignoreUnknownKeys) -1 + else throw new upickle.core.Abort("Unknown Key: " + s.toString) + """ + case Some(false) => q"""throw new upickle.core.Abort("Unknown Key: " + s.toString)""" + case Some(true) => q"-1" + } + } } } diff --git a/upickle/implicits/src-3/upickle/implicits/Readers.scala b/upickle/implicits/src-3/upickle/implicits/Readers.scala index a3b9ecc5b..a384cd150 100644 --- a/upickle/implicits/src-3/upickle/implicits/Readers.scala +++ b/upickle/implicits/src-3/upickle/implicits/Readers.scala @@ -12,7 +12,9 @@ trait ReadersVersionSpecific with Annotator with CaseClassReadWriters: - abstract class CaseClassReadereader[T](paramCount: Int, missingKeyCount: Long) extends CaseClassReader[T] { + abstract class CaseClassReadereader[T](paramCount: Int, + missingKeyCount: Long, + ignoreUnknownKeys: Boolean) extends CaseClassReader[T] { def visitors0: Product lazy val visitors = visitors0 def fromProduct(p: Product): T @@ -31,6 +33,9 @@ trait ReadersVersionSpecific def visitKeyValue(v: Any): Unit = val k = objectAttributeKeyReadMap(v.toString).toString currentIndex = keyToIndex(k) + if (currentIndex == -1 && !ignoreUnknownKeys) { + throw new upickle.core.Abort("Unknown Key: " + k.toString) + } def visitEnd(index: Int): T = storeDefaults(this) @@ -55,7 +60,11 @@ trait ReadersVersionSpecific inline def macroR[T](using m: Mirror.Of[T]): Reader[T] = inline m match { case m: Mirror.ProductOf[T] => - val reader = new CaseClassReadereader[T](macros.paramsCount[T], macros.checkErrorMissingKeysCount[T]()){ + val reader = new CaseClassReadereader[T]( + macros.paramsCount[T], + macros.checkErrorMissingKeysCount[T](), + macros.extractIgnoreUnknownKeys[T]().headOption.getOrElse(this.ignoreUnknownKeys) + ){ override def visitors0 = compiletime.summonAll[Tuple.Map[m.MirroredElemTypes, Reader]] override def fromProduct(p: Product): T = m.fromProduct(p) override def keyToIndex(x: String): Int = macros.keyToIndex[T](x) diff --git a/upickle/implicits/src-3/upickle/implicits/macros.scala b/upickle/implicits/src-3/upickle/implicits/macros.scala index 5711baec2..c3dc8e94e 100644 --- a/upickle/implicits/src-3/upickle/implicits/macros.scala +++ b/upickle/implicits/src-3/upickle/implicits/macros.scala @@ -41,6 +41,18 @@ def extractKey[A](using Quotes)(sym: quotes.reflect.Symbol): Option[String] = .find(_.tpe =:= TypeRepr.of[upickle.implicits.key]) .map{case Apply(_, Literal(StringConstant(s)) :: Nil) => s} +inline def extractIgnoreUnknownKeys[T](): List[Boolean] = ${extractIgnoreUnknownKeysImpl[T]} +def extractIgnoreUnknownKeysImpl[T](using Quotes, Type[T]): Expr[List[Boolean]] = + import quotes.reflect._ + Expr.ofList( + TypeRepr.of[T].typeSymbol + .annotations + .find(_.tpe =:= TypeRepr.of[upickle.implicits.ignoreUnknownKeys]) + .map{case Apply(_, Literal(BooleanConstant(b)) :: Nil) => b} + .map(Expr(_)) + .toList + ) + inline def paramsCount[T]: Int = ${paramsCountImpl[T]} def paramsCountImpl[T](using Quotes, Type[T]) = { Expr(fieldLabelsImpl0[T].size) diff --git a/upickle/implicits/src/upickle/implicits/CaseClassReadWriters.scala b/upickle/implicits/src/upickle/implicits/CaseClassReadWriters.scala index 3226d3115..6e604c800 100644 --- a/upickle/implicits/src/upickle/implicits/CaseClassReadWriters.scala +++ b/upickle/implicits/src/upickle/implicits/CaseClassReadWriters.scala @@ -11,7 +11,7 @@ import upickle.core.{Abort, AbortException, ArrVisitor, NoOpVisitor, ObjVisitor, * package to form the public API1 */ trait CaseClassReadWriters extends upickle.core.Types{ - + def ignoreUnknownKeys: Boolean = true abstract class CaseClassReader[V] extends SimpleReader[V] { override def expectedMsg = "expected dictionary" diff --git a/upickle/implicits/src/upickle/implicits/key.scala b/upickle/implicits/src/upickle/implicits/key.scala index 3852ecb18..39f49c9d7 100644 --- a/upickle/implicits/src/upickle/implicits/key.scala +++ b/upickle/implicits/src/upickle/implicits/key.scala @@ -2,4 +2,5 @@ package upickle.implicits import scala.annotation.StaticAnnotation -case class key(s: String) extends StaticAnnotation \ No newline at end of file +case class key(s: String) extends StaticAnnotation +case class ignoreUnknownKeys(b: Boolean) extends StaticAnnotation \ No newline at end of file diff --git a/upickle/src/upickle/Api.scala b/upickle/src/upickle/Api.scala index 5a39bb1a2..527a080d9 100644 --- a/upickle/src/upickle/Api.scala +++ b/upickle/src/upickle/Api.scala @@ -149,6 +149,14 @@ trait Api override def isJsonDictKey = true def write0[R](out: Visitor[_, R], v: T): R = readwriter.write0(out, v) } + + /** + * Configure whether you want upickle to skip unknown keys during de-serialization + * of `case class`es. Can be overriden for the entire serializer via `override def`, and + * further overriden for individual `case class`es via the annotation + * `@upickle.implicits.ignoreUnknownKeys(b: Boolean)` + */ + override def ignoreUnknownKeys: Boolean = true // End Api } diff --git a/upickle/test/src/upickle/MacroTests.scala b/upickle/test/src/upickle/MacroTests.scala index 9735fe0e8..630cdb136 100644 --- a/upickle/test/src/upickle/MacroTests.scala +++ b/upickle/test/src/upickle/MacroTests.scala @@ -78,6 +78,28 @@ object GenericIssue545{ implicit def apiResultRw[T: upickle.default.ReadWriter]: upickle.default.ReadWriter[ApiResult[T]] = upickle.default.macroRW[ApiResult[T]] } +object UnknownKeys{ + case class Default(id: Int, name: String) + + implicit val defaultRw: upickle.default.ReadWriter[Default] = upickle.default.macroRW[Default] + implicit val defaultRw2: DisallowPickler.ReadWriter[Default] = DisallowPickler.macroRW[Default] + + @upickle.implicits.ignoreUnknownKeys(false) + case class DisAllow(id: Int, name: String) + + implicit val disAllowRw: upickle.default.ReadWriter[DisAllow] = upickle.default.macroRW[DisAllow] + implicit val disAllowRw2: DisallowPickler.ReadWriter[DisAllow] = DisallowPickler.macroRW[DisAllow] + + @upickle.implicits.ignoreUnknownKeys(true) + case class Allow(id: Int, name: String) + + implicit val allowRw: upickle.default.ReadWriter[Allow] = upickle.default.macroRW[Allow] + implicit val allowRw2: DisallowPickler.ReadWriter[Allow] = DisallowPickler.macroRW[Allow] + + object DisallowPickler extends upickle.AttributeTagged { + override def ignoreUnknownKeys = false + } +} object MacroTests extends TestSuite { // Doesn't work :( @@ -578,13 +600,41 @@ object MacroTests extends TestSuite { test("genericIssue545"){ // Make sure case class default values are properly picked up for // generic case classes in Scala 3 - import upickle.implicits.key - upickle.default.read[GenericIssue545.Person]("{\"id\":1}") ==> GenericIssue545.Person(1) upickle.default.read[GenericIssue545.ApiResult[GenericIssue545.Person]]("{\"total_count\": 10}") ==> GenericIssue545.ApiResult[GenericIssue545.Person](None, 10) } + + test("unknownKeys"){ + // For upickle default, we defualt to allowing unknown keys, and explicitly annotating + // `@ignoreUnknownKeys(true)` does nothing, but `@ignoreUnknownKeys(false)` makes unknown + // keys an error (just for the annotated class) + upickle.default.read[UnknownKeys.Default]("""{"id":1, "name":"x", "omg": "wtf"}""") ==> + UnknownKeys.Default(1, "x") + + upickle.default.read[UnknownKeys.Allow]("""{"id":1, "name":"x", "omg": "wtf"}""") ==> + UnknownKeys.Allow(1, "x") + + intercept[upickle.core.AbortException]{ + upickle.default.read[UnknownKeys.DisAllow]("""{"id":1, "name":"x", "omg": "wtf"}""") + } + + // If the upickle API sets `override def ignoreUnknownKeys = false`, we default to treating unknown keys + // as an error, `@ignoreUnknownKeys(false)` does nothing, but `@ignoreUnknownKeys(true)` makes unknown + // keys get ignored (just for the annotated class) + intercept[upickle.core.AbortException] { + UnknownKeys.DisallowPickler.read[UnknownKeys.Default]("""{"id":1, "name":"x", "omg": "wtf"}""") ==> + UnknownKeys.Default(1, "x") + } + + UnknownKeys.DisallowPickler.read[UnknownKeys.Allow]("""{"id":1, "name":"x", "omg": "wtf"}""") ==> + UnknownKeys.Allow(1, "x") + + intercept[upickle.core.AbortException]{ + UnknownKeys.DisallowPickler.read[UnknownKeys.DisAllow]("""{"id":1, "name":"x", "omg": "wtf"}""") + } + } } }