From f4a17179d6507c3b7556eab824d955b8c7fdb7c2 Mon Sep 17 00:00:00 2001 From: Ross Lawley Date: Mon, 21 Nov 2016 10:45:58 +0000 Subject: [PATCH] Added Macro support for Case Classes SCALA-168 --- .../mongodb/scala/bson/codecs/Macros.scala | 86 ++++++ .../codecs/macrocodecs/CaseClassCodec.scala | 271 ++++++++++++++++++ .../macrocodecs/CaseClassProvider.scala | 57 ++++ .../bson/codecs/macrocodecs/MacroCodec.scala | 196 +++++++++++++ .../mongodb/scala/bson/codecs/package.scala | 2 +- .../scala/bson/codecs/MacrosSpec.scala | 245 ++++++++++++++++ docs/reference/content/bson/macros.md | 86 ++++++ .../scala/MongoCollectionCaseClassSpec.scala | 87 ++++++ project/Dependencies.scala | 2 + project/MongoScalaBuild.scala | 3 +- 10 files changed, 1033 insertions(+), 2 deletions(-) create mode 100644 bson/src/main/scala/org/mongodb/scala/bson/codecs/Macros.scala create mode 100644 bson/src/main/scala/org/mongodb/scala/bson/codecs/macrocodecs/CaseClassCodec.scala create mode 100644 bson/src/main/scala/org/mongodb/scala/bson/codecs/macrocodecs/CaseClassProvider.scala create mode 100644 bson/src/main/scala/org/mongodb/scala/bson/codecs/macrocodecs/MacroCodec.scala create mode 100644 bson/src/test/scala/org/mongodb/scala/bson/codecs/MacrosSpec.scala create mode 100644 docs/reference/content/bson/macros.md create mode 100644 driver/src/it/scala/org/mongodb/scala/MongoCollectionCaseClassSpec.scala diff --git a/bson/src/main/scala/org/mongodb/scala/bson/codecs/Macros.scala b/bson/src/main/scala/org/mongodb/scala/bson/codecs/Macros.scala new file mode 100644 index 00000000..1318b360 --- /dev/null +++ b/bson/src/main/scala/org/mongodb/scala/bson/codecs/Macros.scala @@ -0,0 +1,86 @@ +/* + * Copyright 2016 MongoDB, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.mongodb.scala.bson.codecs + +import scala.annotation.compileTimeOnly +import scala.language.experimental.macros +import scala.language.implicitConversions + +import org.bson.codecs.Codec +import org.bson.codecs.configuration.{ CodecProvider, CodecRegistry } + +import org.mongodb.scala.bson.codecs.macrocodecs.{ CaseClassCodec, CaseClassProvider } + +/** + * Macro based Codecs + * + * Allows the compile time creation of Codecs for case classes. + * + * The recommended approach is to use the implicit [[Macros.createCodecProvider]] method to help build a codecRegistry: + * ``` + * import org.mongodb.scala.bson.codecs.Macros.createCodecProvider + * import org.bson.codecs.configuration.CodecRegistries.{fromRegistries, fromProviders} + * + * case class Contact(phone: String) + * case class User(_id: Int, username: String, age: Int, hobbies: List[String], contacts: List[Contact]) + * + * val codecRegistry = fromRegistries(fromProviders(classOf[User], classOf[Contact]), MongoClient.DEFAULT_CODEC_REGISTRY) + * ``` + * + * @since 2.0 + */ +object Macros { + + /** + * Creates a CodecProvider for a case class + * + * @tparam T the case class to create a Codec from + * @return the CodecProvider for the case class + */ + @compileTimeOnly("Creating a CodecProvider utilises Macros and must be run at compile time.") + def createCodecProvider[T](): CodecProvider = macro CaseClassProvider.createCaseClassProvider[T] + + /** + * Creates a CodecProvider for a case class using the given class to represent the case class + * + * @param clazz the clazz that is the case class + * @tparam T the case class to create a Codec from + * @return the CodecProvider for the case class + */ + @compileTimeOnly("Creating a CodecProvider utilises Macros and must be run at compile time.") + implicit def createCodecProvider[T](clazz: Class[T]): CodecProvider = macro CaseClassProvider.createCaseClassProviderWithClass[T] + + /** + * Creates a Codec for a case class + * + * @tparam T the case class to create a Codec from + * @return the Codec for the case class + */ + @compileTimeOnly("Creating a Codec utilises Macros and must be run at compile time.") + def createCodec[T](): Codec[T] = macro CaseClassCodec.createCodecNoArgs[T] + + /** + * Creates a Codec for a case class + * + * @param codecRegistry the Codec Registry to use + * @tparam T the case class to create a codec from + * @return the Codec for the case class + */ + @compileTimeOnly("Creating a Codec utilises Macros and must be run at compile time.") + def createCodec[T](codecRegistry: CodecRegistry): Codec[T] = macro CaseClassCodec.createCodec[T] + +} diff --git a/bson/src/main/scala/org/mongodb/scala/bson/codecs/macrocodecs/CaseClassCodec.scala b/bson/src/main/scala/org/mongodb/scala/bson/codecs/macrocodecs/CaseClassCodec.scala new file mode 100644 index 00000000..2fb3ec16 --- /dev/null +++ b/bson/src/main/scala/org/mongodb/scala/bson/codecs/macrocodecs/CaseClassCodec.scala @@ -0,0 +1,271 @@ +/* + * Copyright 2016 MongoDB, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.mongodb.scala.bson.codecs.macrocodecs + +import scala.collection.MapLike +import scala.reflect.macros.whitebox + +import org.bson.codecs.Codec +import org.bson.codecs.configuration.CodecRegistry + +private[codecs] object CaseClassCodec { + + def createCodecNoArgs[T: c.WeakTypeTag](c: whitebox.Context)(): c.Expr[Codec[T]] = { + import c.universe._ + createCodec[T](c)(c.Expr[CodecRegistry]( + q""" + import org.mongodb.scala.bson.codecs.DEFAULT_CODEC_REGISTRY + DEFAULT_CODEC_REGISTRY + """ + )).asInstanceOf[c.Expr[Codec[T]]] + } + + // scalastyle:off method.length + def createCodec[T: c.WeakTypeTag](c: whitebox.Context)(codecRegistry: c.Expr[CodecRegistry]): c.Expr[Codec[T]] = { + import c.universe._ + + // Declared types + val mainType = weakTypeOf[T] + + val stringType = typeOf[String] + val mapTypeSymbol = typeOf[MapLike[_, _, _]].typeSymbol + + // Names + val classTypeName = mainType.typeSymbol.name.toTypeName + val codecName = TypeName(s"${classTypeName}MacroCodec") + + // Type checkers + def isCaseClass(t: Type): Boolean = t.typeSymbol.isClass && t.typeSymbol.asClass.isCaseClass + def isMap(t: Type): Boolean = t.baseClasses.contains(mapTypeSymbol) + def isOption(t: Type): Boolean = t.typeSymbol == definitions.OptionClass + def isTuple(t: Type): Boolean = definitions.TupleClass.seq.contains(t.typeSymbol) + def isSealed(t: Type): Boolean = t.typeSymbol.isClass && t.typeSymbol.asClass.isSealed + def isCaseClassOrSealed(t: Type): Boolean = isCaseClass(t) || isSealed(t) + + // Data converters + def keyName(t: Type): Literal = Literal(Constant(t.typeSymbol.name.decodedName.toString)) + def keyNameTerm(t: TermName): Literal = Literal(Constant(t.toString)) + + def allSubclasses(s: Symbol): Set[Symbol] = { + val directSubClasses = s.asClass.knownDirectSubclasses + directSubClasses ++ directSubClasses.flatMap({ s: Symbol => allSubclasses(s) }) + } + val subClasses: List[Type] = allSubclasses(mainType.typeSymbol).map(_.asClass.toType).filter(isCaseClass).toList + if (isSealed(mainType) && subClasses.isEmpty) c.abort(c.enclosingPosition, "No known subclasses of the sealed class") + val knownTypes = (mainType +: subClasses).reverse + def fields: Map[Type, List[(TermName, Type)]] = knownTypes.map(t => (t, t.members.sorted.filter(_.isMethod).map(_.asMethod).filter(_.isGetter) + .map(m => (m.name, m.returnType.asSeenFrom(t, t.typeSymbol))))).toMap + + // Primitives type map + val primitiveTypesMap: Map[Type, Type] = Map( + typeOf[Boolean] -> typeOf[java.lang.Boolean], + typeOf[Byte] -> typeOf[java.lang.Byte], + typeOf[Char] -> typeOf[java.lang.Character], + typeOf[Double] -> typeOf[java.lang.Double], + typeOf[Float] -> typeOf[java.lang.Float], + typeOf[Int] -> typeOf[java.lang.Integer], + typeOf[Long] -> typeOf[java.lang.Long], + typeOf[Short] -> typeOf[java.lang.Short] + ) + + /** + * Flattens the type args for any given type. + * + * Removes the key field from Maps as they have to be strings. + * Removes Option type as the Option value is wrapped automatically below. + * Throws if the case class contains a Tuple + * + * @param t the type to flatten the arguments for + * @return a list of the type arguments for the type + */ + def flattenTypeArgs(t: Type): List[c.universe.Type] = { + val typeArgs = if (isMap(t)) { + if (t.typeArgs.head != stringType) c.abort(c.enclosingPosition, "Maps must contain string types for keys") + t.typeArgs.tail + } else { + t.typeArgs + } + val types = t +: typeArgs.flatMap(x => flattenTypeArgs(x)) + if (types.exists(isTuple)) c.abort(c.enclosingPosition, "Tuples currently aren't supported in case classes") + types.filter(x => !isOption(x)).map(x => primitiveTypesMap.getOrElse(x, x)) + } + + /** + * Maps the given field names to type args for the values in the field + * + * ``` + * addresses: Seq[Address] => (addresses, List[classOf[Seq], classOf[Address]]) + * nestedAddresses: Seq[Seq[Address]] => (addresses, List[classOf[Seq], classOf[Seq], classOf[Address]]) + * ``` + * + * @return a map of the field names with a list of the contain types + */ + def createFieldTypeArgsMap(fields: List[(TermName, Type)]) = { + val setTypeArgs = fields.map({ + case (name, f) => + val key = keyNameTerm(name) + q""" + typeArgs += ($key -> { + val tpeArgs = mutable.ListBuffer.empty[Class[_]] + ..${flattenTypeArgs(f).map(t => q"tpeArgs += classOf[${t.finalResultType}]")} + tpeArgs.toList + })""" + }) + + q""" + val typeArgs = mutable.Map[String, List[Class[_]]]() + ..$setTypeArgs + typeArgs.toMap + """ + } + + /** + * For each case class sets the Map of the given field names and their field types. + */ + def createClassFieldTypeArgsMap = { + val setClassFieldTypeArgs = fields.map(field => + q""" + classFieldTypeArgs += (${keyName(field._1)} -> ${createFieldTypeArgsMap(field._2)}) + """) + + q""" + val classFieldTypeArgs = mutable.Map[String, Map[String, List[Class[_]]]]() + ..$setClassFieldTypeArgs + classFieldTypeArgs.toMap + """ + } + + /** + * Creates a `Map[String, Class[_]]` mapping the case class name and the type. + * + * @return the case classes map + */ + def caseClassesMap = { + val setSubClasses = knownTypes.map(t => q"caseClassesMap += (${keyName(t)} -> classOf[${t.finalResultType}])") + q""" + val caseClassesMap = mutable.Map[String, Class[_]]() + ..$setSubClasses + caseClassesMap.toMap + """ + } + + /** + * Creates a `Map[Class[_], Boolean]` mapping field types to a boolean representing if they are a case class. + * + * @return the class to case classes map + */ + def classToCaseClassMap = { + val flattenedFieldTypes = fields.flatMap({ case (t, types) => types.map(f => f._2) :+ t }) + val setclassToCaseClassMap = flattenedFieldTypes.map(t => q"""classToCaseClassMap ++= ${ + flattenTypeArgs(t).map(t => + q"(classOf[${t.finalResultType}], ${isCaseClassOrSealed(t)})") + }""") + + q""" + val classToCaseClassMap = mutable.Map[Class[_], Boolean]() + ..$setclassToCaseClassMap + classToCaseClassMap.toMap + """ + } + + /** + * Handles the writing of case class fields. + * + * @param fields the list of fields + * @return the tree that writes the case class fields + */ + def writeClassValues(fields: List[(TermName, Type)]): List[Tree] = { + fields.map({ + case (name, f) => + val key = keyNameTerm(name) + f match { + case optional if isOption(optional) => q""" + val localVal = instanceValue.$name + writer.writeName($key) + if (localVal.isDefined) { + this.writeValue(writer, localVal.get, encoderContext) + } else { + this.writeValue(writer, this.bsonNull, encoderContext) + }""" + case _ => q""" + val localVal = instanceValue.$name + writer.writeName($key) + this.writeValue(writer, localVal, encoderContext) + """ + } + }) + } + + /** + * Writes the Case Class fields and values to the BsonWriter + */ + def writeValue: Tree = { + val cases: Seq[Tree] = { + fields.map(field => cq""" ${keyName(field._1)} => + val instanceValue = value.asInstanceOf[${field._1}] + ..${writeClassValues(field._2)}""").toSeq + } + + q""" + writer.writeStartDocument() + this.writeClassFieldName(writer, className, encoderContext) + className match { case ..$cases } + writer.writeEndDocument() + """ + } + + def fieldSetters(fields: List[(TermName, Type)]) = { + fields.map({ + case (name, f) => + val key = keyNameTerm(name) + f match { + case optional if isOption(optional) => q"$name = Option(fieldData($key)).asInstanceOf[$f]" + case _ => q"$name = fieldData($key).asInstanceOf[$f]" + } + }) + } + + def getInstance = { + val cases = knownTypes.map { st => + cq"${keyName(st)} => new $st(..${fieldSetters(fields(st))})" + } :+ cq"""_ => throw new CodecConfigurationException("Unexpected class type: " + className)""" + q"className match { case ..$cases }" + } + + c.Expr[Codec[T]]( + q""" + import scala.collection.mutable + import org.bson.BsonWriter + import org.bson.codecs.EncoderContext + import org.bson.codecs.configuration.{CodecRegistry, CodecConfigurationException} + import org.mongodb.scala.bson.codecs.macrocodecs.MacroCodec + + case class $codecName(codecRegistry: CodecRegistry) extends MacroCodec[$classTypeName] { + val caseClassesMap = $caseClassesMap + val classToCaseClassMap = $classToCaseClassMap + val classFieldTypeArgsMap = $createClassFieldTypeArgsMap + val encoderClass = classOf[$classTypeName] + def getInstance(className: String, fieldData: Map[String, Any]) = $getInstance + def writeCaseClassData(className: String, writer: BsonWriter, value: $mainType, encoderContext: EncoderContext) = $writeValue + } + + ${codecName.toTermName}($codecRegistry).asInstanceOf[Codec[$mainType]] + """ + ) + } + // scalastyle:on method.length +} diff --git a/bson/src/main/scala/org/mongodb/scala/bson/codecs/macrocodecs/CaseClassProvider.scala b/bson/src/main/scala/org/mongodb/scala/bson/codecs/macrocodecs/CaseClassProvider.scala new file mode 100644 index 00000000..d550d422 --- /dev/null +++ b/bson/src/main/scala/org/mongodb/scala/bson/codecs/macrocodecs/CaseClassProvider.scala @@ -0,0 +1,57 @@ +/* + * Copyright 2016 MongoDB, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.mongodb.scala.bson.codecs.macrocodecs + +import scala.reflect.macros.whitebox + +import org.bson.codecs.configuration.{ CodecProvider, CodecRegistry } + +private[codecs] object CaseClassProvider { + + def createCaseClassProviderWithClass[T: c.WeakTypeTag](c: whitebox.Context)(clazz: c.Expr[Class[T]]): c.Expr[CodecProvider] = + createCaseClassProvider[T](c)().asInstanceOf[c.Expr[CodecProvider]] + + def createCaseClassProvider[T: c.WeakTypeTag](c: whitebox.Context)(): c.Expr[CodecProvider] = { + import c.universe._ + + // Declared type + val mainType = weakTypeOf[T] + def isSealed(t: Type): Boolean = t.typeSymbol.isClass && t.typeSymbol.asClass.isSealed + + // Names + def exprCodecRegistry = c.Expr[CodecRegistry](q"codecRegistry") + def codec = CaseClassCodec.createCodec[T](c)(exprCodecRegistry) + + c.Expr[CodecProvider]( + q""" + import org.bson.codecs.Codec + import org.bson.codecs.configuration.{ CodecProvider, CodecRegistry } + + new CodecProvider { + @SuppressWarnings(Array("unchecked")) + def get[C](clazz: Class[C], codecRegistry: CodecRegistry): Codec[C] = { + if (classOf[$mainType].isAssignableFrom(clazz)) { + $codec.asInstanceOf[Codec[C]] + } else { + null + } + } + } + """ + ) + } +} diff --git a/bson/src/main/scala/org/mongodb/scala/bson/codecs/macrocodecs/MacroCodec.scala b/bson/src/main/scala/org/mongodb/scala/bson/codecs/macrocodecs/MacroCodec.scala new file mode 100644 index 00000000..5084b0ad --- /dev/null +++ b/bson/src/main/scala/org/mongodb/scala/bson/codecs/macrocodecs/MacroCodec.scala @@ -0,0 +1,196 @@ +/* + * Copyright 2016 MongoDB, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.mongodb.scala.bson.codecs.macrocodecs + +import scala.collection.JavaConverters._ +import scala.collection.mutable + +import org.bson.codecs.configuration.{ CodecConfigurationException, CodecRegistries, CodecRegistry } +import org.bson.{ BsonReader, BsonType, BsonValue, BsonWriter } +import org.bson.codecs.{ Codec, DecoderContext, Encoder, EncoderContext } + +import org.mongodb.scala.bson.BsonNull + +/** + * + * @tparam T the case class type for the codec + * @since 2.0 + */ +trait MacroCodec[T] extends Codec[T] { + + /** + * Creates a `Map[String, Class[_]]` mapping the case class name and the type. + */ + val caseClassesMap: Map[String, Class[_]] + + /** + * Creates a `Map[Class[_], Boolean]` mapping field types to a boolean representing if they are a case class. + */ + val classToCaseClassMap: Map[Class[_], Boolean] + + /** + * A nested map of case class name to a Map of the given field names and a list of the field types. + */ + val classFieldTypeArgsMap: Map[String, Map[String, List[Class[_]]]] + + /** + * The case class type for the codec + */ + val encoderClass: Class[T] + + /** + * The `CodecRegistry` for use with the codec + */ + val codecRegistry: CodecRegistry + + /** + * Creates a new instance of the case class with the provided data + * + * @param className the name of the class to be instantiated + * @param fieldsData the Map of data for the class + * @return the new instance of the class + */ + def getInstance(className: String, fieldsData: Map[String, Any]): T + + /** + * The method that writes the data for the case class + * + * @param className the name of the current case class being written + * @param writer the `BsonWriter` + * @param value the value to the case class + * @param encoderContext the `EncoderContext` + */ + def writeCaseClassData(className: String, writer: BsonWriter, value: T, encoderContext: EncoderContext): Unit + + /** + * The field used to save the class name when saving sealed case classes. + */ + val classFieldName = "_t" + lazy val hasClassFieldName: Boolean = caseClassesMap.size > 1 + lazy val caseClassesMapInv: Map[Class[_], String] = caseClassesMap.map(_.swap) + protected val registry: CodecRegistry = CodecRegistries.fromRegistries(List(codecRegistry, CodecRegistries.fromCodecs(this)).asJava) + protected val unknownTypeArgs: List[Class[BsonValue]] = List[Class[BsonValue]](classOf[BsonValue]) + protected val bsonNull = BsonNull() + + override def encode(writer: BsonWriter, value: T, encoderContext: EncoderContext): Unit = writeValue(writer, value, encoderContext) + + override def decode(reader: BsonReader, decoderContext: DecoderContext): T = { + val className = getClassname(reader, decoderContext) + val fieldTypeArgsMap = classFieldTypeArgsMap(className) + val map = mutable.Map[String, Any]() + reader.readStartDocument() + while (reader.readBsonType ne BsonType.END_OF_DOCUMENT) { + val name = reader.readName + val typeArgs = if (name == classFieldName) List(classOf[String]) else fieldTypeArgsMap.getOrElse(name, unknownTypeArgs) + map += (name -> readValue(reader, decoderContext, typeArgs.head, typeArgs.tail, fieldTypeArgsMap)) + } + reader.readEndDocument() + getInstance(className, map.toMap) + } + + override def getEncoderClass: Class[T] = encoderClass + + protected def getClassname(reader: BsonReader, decoderContext: DecoderContext): String = { + if (hasClassFieldName) { + // Find the class name + reader.mark() + reader.readStartDocument() + var optionalClassName: Option[String] = None + while (optionalClassName.isEmpty && (reader.readBsonType ne BsonType.END_OF_DOCUMENT)) { + val name = reader.readName + if (name == classFieldName) { + optionalClassName = Some(codecRegistry.get(classOf[String]).decode(reader, decoderContext)) + } else { + reader.skipValue() + } + } + reader.reset() + + // Validate the class name + if (optionalClassName.isEmpty) { + throw new CodecConfigurationException(s"Could not decode sealed case class. Missing '$classFieldName' field.") + } + + val className = optionalClassName.get + if (!caseClassesMap.contains(className)) { + throw new CodecConfigurationException(s"Could not decode sealed case class, unknown class $className.") + } + className + } else { + caseClassesMap.head._1 + } + } + + protected def writeClassFieldName(writer: BsonWriter, className: String, encoderContext: EncoderContext): Unit = { + if (hasClassFieldName) { + writer.writeName(classFieldName) + this.writeValue(writer, className, encoderContext) + } + } + + protected def writeValue[V](writer: BsonWriter, value: V, encoderContext: EncoderContext): Unit = { + val clazz = value.getClass + caseClassesMapInv.get(clazz) match { + case Some(className) => writeCaseClassData(className: String, writer: BsonWriter, value.asInstanceOf[T], encoderContext: EncoderContext) + case None => + val codec = registry.get(clazz).asInstanceOf[Encoder[V]] + encoderContext.encodeWithChildContext(codec, writer, value) + } + } + + protected def readValue[V](reader: BsonReader, decoderContext: DecoderContext, clazz: Class[V], typeArgs: List[Class[_]], + fieldTypeArgsMap: Map[String, List[Class[_]]]): V = { + val currentType = reader.getCurrentBsonType + currentType match { + case BsonType.DOCUMENT => readDocument(reader, decoderContext, clazz, typeArgs, fieldTypeArgsMap) + case BsonType.ARRAY => readArray(reader, decoderContext, clazz, typeArgs, fieldTypeArgsMap) + case BsonType.NULL => + reader.readNull() + null.asInstanceOf[V] // scalastyle:ignore + case _ => registry.get(clazz).decode(reader, decoderContext) + } + } + + protected def readArray[V](reader: BsonReader, decoderContext: DecoderContext, clazz: Class[V], typeArgs: List[Class[_]], + fieldTypeArgsMap: Map[String, List[Class[_]]]): V = { + reader.readStartArray() + val list = mutable.ListBuffer[Any]() + while (reader.readBsonType ne BsonType.END_OF_DOCUMENT) { + list.append(readValue(reader, decoderContext, typeArgs.head, typeArgs.tail, fieldTypeArgsMap)) + } + reader.readEndArray() + list.toList.asInstanceOf[V] + } + + protected def readDocument[V](reader: BsonReader, decoderContext: DecoderContext, clazz: Class[V], typeArgs: List[Class[_]], + fieldTypeArgsMap: Map[String, List[Class[_]]]): V = { + val isCaseClass = classToCaseClassMap.getOrElse(clazz, false) + if (isCaseClass) { + registry.get(clazz).decode(reader, decoderContext) + } else { + val map = mutable.Map[String, Any]() + reader.readStartDocument() + while (reader.readBsonType ne BsonType.END_OF_DOCUMENT) { + val name = reader.readName + val fieldClazzTypeArgs = fieldTypeArgsMap.getOrElse(name, typeArgs) + map += (name -> readValue(reader, decoderContext, fieldClazzTypeArgs.head, fieldClazzTypeArgs.tail, fieldTypeArgsMap)) + } + reader.readEndDocument() + map.toMap.asInstanceOf[V] + } + } +} diff --git a/bson/src/main/scala/org/mongodb/scala/bson/codecs/package.scala b/bson/src/main/scala/org/mongodb/scala/bson/codecs/package.scala index 85fa838c..8d7b13cc 100644 --- a/bson/src/main/scala/org/mongodb/scala/bson/codecs/package.scala +++ b/bson/src/main/scala/org/mongodb/scala/bson/codecs/package.scala @@ -16,7 +16,7 @@ package org.mongodb.scala.bson -import org.bson.codecs.configuration.CodecRegistries._ +import org.bson.codecs.configuration.CodecRegistries.{ fromRegistries, fromProviders } import org.bson.codecs.configuration.CodecRegistry import com.mongodb.async.client.MongoClients diff --git a/bson/src/test/scala/org/mongodb/scala/bson/codecs/MacrosSpec.scala b/bson/src/test/scala/org/mongodb/scala/bson/codecs/MacrosSpec.scala new file mode 100644 index 00000000..4af762e9 --- /dev/null +++ b/bson/src/test/scala/org/mongodb/scala/bson/codecs/MacrosSpec.scala @@ -0,0 +1,245 @@ +/* + * Copyright 2016 MongoDB, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.mongodb.scala.bson.codecs + +import java.nio.ByteBuffer +import java.util +import java.util.Date + +import scala.collection.JavaConverters._ +import scala.reflect.ClassTag + +import org.bson._ +import org.bson.codecs.configuration.{ CodecConfigurationException, CodecProvider, CodecRegistries } +import org.bson.codecs.{ Codec, DecoderContext, EncoderContext } +import org.bson.io.{ BasicOutputBuffer, ByteBufferBsonInput, OutputBuffer } + +import org.mongodb.scala.bson.codecs.Macros.createCodecProvider +import org.mongodb.scala.bson.collection.immutable.Document +import org.scalatest.{ FlatSpec, Matchers } + +//scalastyle:off +class MacrosSpec extends FlatSpec with Matchers { + + case class Empty() + case class Person(firstName: String, lastName: String) + case class DefaultValue(name: String, active: Boolean = false) + case class SeqOfStrings(name: String, value: Seq[String]) + case class RecursiveSeq(name: String, value: Seq[RecursiveSeq]) + + case class Binary(binary: Array[Byte]) { + /** + * Custom equals + * + * Because `Array[Byte]` only does equality based on identity we use the implicit `deep` helper to compare the actual values. + * + * @param arg the other value + * @return true if equal else false + */ + override def equals(arg: Any): Boolean = arg match { + case that: Binary => that.binary.deep == binary.deep + case _ => false + } + } + case class AllTheBsonTypes(documentMap: Map[String, String], array: Seq[String], date: Date, boolean: Boolean, + double: Double, int32: Int, int64: Long, string: String, binary: Binary, none: Option[String]) + + case class MapOfStrings(name: String, value: Map[String, String]) + case class SeqOfMapOfStrings(name: String, value: Seq[Map[String, String]]) + case class RecursiveMapOfStrings(name: String, value: Seq[Map[String, RecursiveMapOfStrings]]) + + case class ContainsCaseClass(name: String, friend: Person) + case class ContainsSeqCaseClass(name: String, friends: Seq[Person]) + case class ContainsNestedSeqCaseClass(name: String, friends: Seq[Seq[Person]]) + case class ContainsMapOfCaseClasses(name: String, friends: Map[String, Person]) + case class ContainsMapOfMapOfCaseClasses(name: String, friends: Map[String, Map[String, Person]]) + + case class OptionalValue(name: String, value: Option[String]) + case class OptionalCaseClass(name: String, value: Option[Person]) + case class OptionalRecursive(name: String, value: Option[OptionalRecursive]) + + sealed class Tree + case class Branch(b1: Tree, b2: Tree, value: Int) extends Tree + case class Leaf(value: Int) extends Tree + + case class ContainsADT(name: String, tree: Tree) + case class ContainsSeqADT(name: String, trees: Seq[Tree]) + case class ContainsNestedSeqADT(name: String, trees: Seq[Seq[Tree]]) + + sealed class Graph + case class Node(name: String, value: Option[Graph]) extends Graph + + sealed class NotImplemented + case class UnsupportedTuple(value: (String, String)) + case class UnsupportedMap(value: Map[Int, Int]) + + "Macros" should "be able to round trip simple case classes" in { + roundTrip(Empty(), "{}", classOf[Empty]) + roundTrip(Person("Bob", "Jones"), """{firstName: "Bob", lastName: "Jones"}""", classOf[Person]) + roundTrip(DefaultValue(name = "Bob"), """{name: "Bob", active: false}""", classOf[DefaultValue]) + roundTrip(SeqOfStrings("Bob", Seq("scala", "jvm")), """{name: "Bob", value: ["scala", "jvm"]}""", classOf[SeqOfStrings]) + roundTrip(RecursiveSeq("Bob", Seq(RecursiveSeq("Charlie", Seq.empty[RecursiveSeq]))), """{name: "Bob", value: [{name: "Charlie", value: []}]}""", classOf[RecursiveSeq]) + roundTrip(MapOfStrings("Bob", Map("brother" -> "Tom Jones")), """{name: "Bob", value: {brother: "Tom Jones"}}""", classOf[MapOfStrings]) + roundTrip(SeqOfMapOfStrings("Bob", Seq(Map("brother" -> "Tom Jones"))), """{name: "Bob", value: [{brother: "Tom Jones"}]}""", classOf[SeqOfMapOfStrings]) + } + + it should "be able to round trip nested case classes" in { + roundTrip(ContainsCaseClass("Charlie", Person("Bob", "Jones")), """{name: "Charlie", friend: {firstName: "Bob", lastName: "Jones"}}""", classOf[ContainsCaseClass], classOf[Person]) + roundTrip(ContainsSeqCaseClass("Charlie", Seq(Person("Bob", "Jones"))), """{name: "Charlie", friends: [{firstName: "Bob", lastName: "Jones"}]}""", classOf[ContainsSeqCaseClass], classOf[Person]) + roundTrip( + ContainsNestedSeqCaseClass("Charlie", Seq(Seq(Person("Bob", "Jones")), Seq(Person("Tom", "Jones")))), + """{name: "Charlie", friends: [[{firstName: "Bob", lastName: "Jones"}], [{firstName: "Tom", lastName: "Jones"}]]}""", classOf[ContainsNestedSeqCaseClass], classOf[Person] + ) + } + + it should "be able to round trip nested case classes in maps" in { + roundTrip( + ContainsMapOfCaseClasses("Bob", Map("mother" -> Person("Jane", "Jones"))), + """{name: "Bob", friends: {mother: {firstName: "Jane", lastName: "Jones"}}}""", classOf[ContainsMapOfCaseClasses], classOf[Person] + ) + roundTrip( + ContainsMapOfMapOfCaseClasses("Bob", Map("maternal" -> Map("mother" -> Person("Jane", "Jones")))), + """{name: "Bob", friends: {maternal: {mother: {firstName: "Jane", lastName: "Jones"}}}}""", + classOf[ContainsMapOfMapOfCaseClasses], classOf[Person] + ) + } + + it should "be able to round trip optional values" in { + roundTrip(OptionalValue("Bob", None), """{name: "Bob", value: null}""", classOf[OptionalValue]) + roundTrip(OptionalValue("Bob", Some("value")), """{name: "Bob", value: "value"}""", classOf[OptionalValue]) + roundTrip(OptionalCaseClass("Bob", None), """{name: "Bob", value: null}""", classOf[OptionalCaseClass]) + roundTrip( + OptionalCaseClass("Bob", Some(Person("Charlie", "Jones"))), + """{name: "Bob", value: {firstName: "Charlie", lastName: "Jones"}}""", classOf[OptionalCaseClass], classOf[Person] + ) + + roundTrip(OptionalRecursive("Bob", None), """{name: "Bob", value: null}""", classOf[OptionalRecursive]) + roundTrip( + OptionalRecursive("Bob", Some(OptionalRecursive("Charlie", None))), + """{name: "Bob", value: {name: "Charlie", value: null}}""", classOf[OptionalRecursive] + ) + } + + it should "roundtrip all the supported bson types" in { + val value = + roundTrip( + AllTheBsonTypes(Map("a" -> "b"), Seq("a", "b", "c"), new Date(123), boolean = true, 1.0, 10, 100L, "string", + Binary(Array[Byte](123)), None), + """{"documentMap" : { "a" : "b" }, "array" : ["a", "b", "c"], "date" : { "$date" : 123 }, "boolean" : true, + | "double" : 1.0, "int32" : 10, "int64" : { "$numberLong" : "100" }, "string" : "string", + | "binary" : { "binary": { "$binary" : "ew==", "$type" : "00" } }, "none" : null }""".stripMargin, + classOf[Binary], classOf[AllTheBsonTypes] + ) + } + + it should "support ADT sealed case classes" in { + val leaf = Leaf(1) + val branch = Branch(Branch(Leaf(1), Leaf(2), 3), Branch(Leaf(4), Leaf(5), 6), 3) // scalastyle:ignore + + def createJson(tree: Tree): String = { + tree match { + case l: Leaf => s"""{_t: "Leaf", value: ${l.value}}""" + case b: Branch => s"""{_t: "Branch", b1: ${createJson(b.b1)}, b2: ${createJson(b.b2)}, value: ${b.value}}""" + case _ => "{}" + } + } + val leafJson = createJson(leaf) + val branchJson = createJson(branch) + + roundTrip(leaf, leafJson, classOf[Tree]) + roundTrip(branch, branchJson, classOf[Tree]) + + roundTrip(ContainsADT("Bob", leaf), s"""{name: "Bob", tree: $leafJson}""", classOf[ContainsADT], classOf[Tree]) + roundTrip(ContainsADT("Bob", branch), s"""{name: "Bob", tree: $branchJson}""", classOf[ContainsADT], classOf[Tree]) + + roundTrip(ContainsSeqADT("Bob", List(leaf, branch)), s"""{name: "Bob", trees: [$leafJson, $branchJson]}""", classOf[ContainsSeqADT], classOf[Tree]) + roundTrip(ContainsNestedSeqADT("Bob", List(List(leaf), List(branch))), s"""{name: "Bob", trees: [[$leafJson], [$branchJson]]}""", + classOf[ContainsNestedSeqADT], classOf[Tree]) + } + + it should "support optional values in ADT sealed classes" in { + val nodeA = Node("nodeA", None) + val nodeB = Node("nodeB", Some(nodeA)) + + val nodeAJson = """{_t: "Node", name: "nodeA", value: null}""" + val nodeBJson = s"""{_t: "Node", name: "nodeB", value: $nodeAJson}""" + + roundTrip(nodeA, nodeAJson, classOf[Graph]) + roundTrip(nodeB, nodeBJson, classOf[Graph]) + } + + it should "support throw a CodecConfigurationException missing _t field" in { + val missing_t = """{name: "nodeA", value: null}""" + val registry = CodecRegistries.fromRegistries(CodecRegistries.fromProviders(classOf[Graph]), DEFAULT_CODEC_REGISTRY) + + val buffer = encode(registry.get(classOf[Document]), Document(missing_t)) + + an[CodecConfigurationException] should be thrownBy { + decode(registry.get(classOf[Graph]), buffer) + } + } + + it should "support throw a CodecConfigurationException with an unknown class name in the _t field" in { + val missing_t = """{_t: "Wibble", name: "nodeA", value: null}""" + val registry = CodecRegistries.fromRegistries(CodecRegistries.fromProviders(classOf[Graph]), DEFAULT_CODEC_REGISTRY) + val buffer = encode(registry.get(classOf[Document]), Document(missing_t)) + + an[CodecConfigurationException] should be thrownBy { + decode(registry.get(classOf[Graph]), buffer) + } + } + + it should "not compile case classes with unsupported values" in { + "Macros.createCodecProvider(classOf[UnsupportedTuple])" shouldNot compile + "Macros.createCodecProvider(classOf[UnsupportedMap])" shouldNot compile + } + + it should "not compile if there are no concrete implementations of a sealed class" in { + "Macros.createCodecProvider(classOf[NotImplemented])" shouldNot compile + } + + def roundTrip[T](value: T, expected: String, provider: CodecProvider, providers: CodecProvider*)(implicit ct: ClassTag[T]): Unit = { + val codecProviders: util.List[CodecProvider] = (provider +: providers).asJava + val registry = CodecRegistries.fromRegistries(CodecRegistries.fromProviders(codecProviders), DEFAULT_CODEC_REGISTRY) + val codec = registry.get(ct.runtimeClass).asInstanceOf[Codec[T]] + roundTripCodec(value, Document(expected), codec) + } + + def roundTripCodec[T](value: T, expected: Document, codec: Codec[T]): Unit = { + val encoded = encode(codec, value) + val actual = decode(documentCodec, encoded) + assert(expected == actual, s"Encoded document: (${actual.toJson()}) did not equal: (${expected.toJson()})") + + val roundTripped = decode(codec, encode(codec, value)) + assert(roundTripped == value, s"Round Tripped case class: ($roundTripped) did not equal the original: ($value)") + } + + def encode[T](codec: Codec[T], value: T): OutputBuffer = { + val buffer = new BasicOutputBuffer() + val writer = new BsonBinaryWriter(buffer) + codec.encode(writer, value, EncoderContext.builder.build) + buffer + } + + def decode[T](codec: Codec[T], buffer: OutputBuffer): T = { + val reader = new BsonBinaryReader(new ByteBufferBsonInput(new ByteBufNIO(ByteBuffer.wrap(buffer.toByteArray)))) + codec.decode(reader, DecoderContext.builder().build()) + } + + val documentCodec = DEFAULT_CODEC_REGISTRY.get(classOf[Document]) + +} diff --git a/docs/reference/content/bson/macros.md b/docs/reference/content/bson/macros.md new file mode 100644 index 00000000..ad925c7e --- /dev/null +++ b/docs/reference/content/bson/macros.md @@ -0,0 +1,86 @@ ++++ +date = "2016-11-20T14:14:00-00:00" +title = "Macros" +[menu.main] + parent = "BSON" + weight = 30 + pre = "" ++++ + +## Macros + +New in 2.0, the Scala driver allows you to use case classes to represent documents in a collection via the +[`Macros`]({{< apiref "org.mongodb.scala.bson.codecs.Macros" >}}) helper. Simple case classes and nested case classes are supported. +Hierarchical modelling can be achieve by using a sealed trait and having case classes implement the parent trait. + +Many simple Scala types are supported and they will be marshaled into their corresponding +[`BsonValue`]({{< apiref "org.mongodb.scala.bson.BsonValue" >}}) type. Below is a list of Scala types and their type-safe BSON representation: + +| Scala type | BSON type | +|-----------------------------------|-------------------| +| case class | Document | +| `Iterable` | Array | +| `Date` | Date | +| `Boolean` | Boolean | +| `Double` | Double | +| `Int` | Int32 | +| `Long` | Int64 | +| `String` | String | +| `Array[Byte]` | Binary | +| `None` | Null | + + +## Creating Codecs + +To create a codec for your case class use the `Macros` object helper methods. Unless there is a good reason you should use the +`Macros.createCodecProvider` method to create a [`CodecProvider`]({{< coreapiref "org/bson/codecs/configuration/CodecProvider.html">}}). +A `CodecProvider` will pass the configured [`CodecRegistry`]({{< coreapiref "org/bson/codecs/configuration/CodecRegistry.html">}}) to the +underlying [`Codec`]({{< coreapiref "org/bson/codecs/configuration/Codec.html">}}) and provide access to all the configured codecs. + +To create a `CodecProvider` all you need to do is to set the case class type when calling `createCodecProvider` like so: + +```scala +import org.mongodb.scala.bson.codecs.Macros + +case class Person(firstName: String, secondName: String) + +val personCodecProvider = Macros.createCodecProvider[Person]() +``` + +The `personCodecProvider` can then be used when converted into a `CodecRegistry` by using the [`CodecRegistries`]({{< coreapiref "org/bson/codecs/configuration/CodecRegistries.html">}}) static helpers. Below we create a new codec registry combining the new `personCodecProvider` and the the default codec registry: + +```scala +import org.mongodb.scala.bson.codecs.DEFAULT_CODEC_REGISTRY +import org.bson.codecs.configuration.CodecRegistries.{fromRegistries, fromProviders} + +val codecRegistry = fromRegistries( fromProviders(personCodecProvider), DEFAULT_CODEC_REGISTRY ) +``` + +The `Macros` helper also has an implicit `createCodecProvider` method that takes the `Class[T]` and will create a `CodecProvider` from that. +As you can see in the example below it's much more concise especially when defining multiple providers: + +```scala +import org.mongodb.scala.bson.codecs.Macros._ +import org.mongodb.scala.bson.codecs.DEFAULT_CODEC_REGISTRY +import org.bson.codecs.configuration.CodecRegistries.{fromRegistries, fromProviders} + +case class Address(firstLine: String, secondLine: String, thirdLine: String, town: String, zipCode: String) +case class ClubMember(person: Person, address: Address, paid: Boolean) + +val codecRegistry = fromRegistries( fromProviders(classOf[ClubMember], classOf[Person], classOf[Address]), DEFAULT_CODEC_REGISTRY ) +``` + +## Sealed classes and ADTs + +Hierarchical class structures are supported via sealed classes. Each subclass is handled specifically by the generated codec, so you only +need create a `CodecProvider` for the parent sealed class. Internally an extra field (`_t`) is stored alongside the data so that +the correct subclass can be hyrdated when decoding the data. Below is an example of a tree like structure containing branch and leaf nodes: + + +```scala +sealed class Tree +case class Branch(b1: Tree, b2: Tree, value: Int) extends Tree +case class Leaf(value: Int) extends Tree + +val codecRegistry = fromRegistries( fromProviders(classOf[Tree]), DEFAULT_CODEC_REGISTRY ) +``` diff --git a/driver/src/it/scala/org/mongodb/scala/MongoCollectionCaseClassSpec.scala b/driver/src/it/scala/org/mongodb/scala/MongoCollectionCaseClassSpec.scala new file mode 100644 index 00000000..9fc8fdd5 --- /dev/null +++ b/driver/src/it/scala/org/mongodb/scala/MongoCollectionCaseClassSpec.scala @@ -0,0 +1,87 @@ +/* + * Copyright 2016 MongoDB, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.mongodb.scala + +import org.mongodb.scala.bson.codecs.Macros.createCodecProvider +import org.bson.codecs.configuration.CodecRegistries.{fromProviders, fromRegistries} +import org.bson.codecs.configuration.CodecRegistry + +class MongoCollectionCaseClassSpec extends RequiresMongoDBISpec { + + case class Contact(phone: String) + case class User(_id: Int, username: String, age: Int, hobbies: List[String], contacts: List[Contact]) + case class Optional(_id: Int, optional: Option[Int]) + + val codecRegistry: CodecRegistry = fromRegistries(fromProviders(classOf[User], classOf[Contact], classOf[Optional]), + MongoClient.DEFAULT_CODEC_REGISTRY) + + "The Scala driver" should "handle case classes" in withDatabase(databaseName) { + database => + val collection = database.getCollection[User](collectionName).withCodecRegistry(codecRegistry) + + val user = User( + _id = 1, + age = 30, + username = "Bob", + hobbies = List[String]("hiking", "music"), + contacts = List(Contact("123 12314"), Contact("234 234234")) + ) + collection.insertOne(user).futureValue + + info("The collection should have the expected document") + val expectedDocument = Document( + """{_id: 1, age: 30, username: "Bob", hobbies: ["hiking", "music"], + | contacts: [{phone: "123 12314"}, {phone: "234 234234"}]}""".stripMargin) + collection.find[Document]().first().futureValue.head should equal(expectedDocument) + + info("The collection should find and return the user") + collection.find[User]().first().futureValue.head should equal(user) + } + + it should "handle optional values" in withDatabase(databaseName) { + database => + val collection = database.getCollection[Optional](collectionName).withCodecRegistry(codecRegistry) + + val none = Optional(_id = 1, None) + collection.insertOne(none).futureValue + + info("The collection should have the expected document") + val expectedDocument = Document("{_id: 1, optional: null}") + collection.find[Document]().first().futureValue.head should equal(expectedDocument) + + info("The collection should find and return the optional") + collection.find[Optional]().first().futureValue.head should equal(none) + + collection.drop().futureValue + + val some = Optional(_id = 1, Some(1)) + collection.insertOne(some).futureValue + + info("The collection should find and return the optional") + collection.find[Optional]().first().futureValue.head should equal(some) + } + + it should "handle converting to case classes where there is extra data" in withDatabase(databaseName) { + database => + val collection = database.getCollection[Contact](collectionName).withCodecRegistry(codecRegistry) + + database.getCollection(collectionName).insertOne(Document("""{_id: 5, phone: "555 232323", active: true}""")).futureValue.head + val contact = Contact("555 232323") + collection.find[Contact]().first().futureValue.head should equal(contact) + } + +} diff --git a/project/Dependencies.scala b/project/Dependencies.scala index 4cad10b9..1cf16a3b 100644 --- a/project/Dependencies.scala +++ b/project/Dependencies.scala @@ -14,6 +14,7 @@ * limitations under the License. */ +import sbt.Keys.scalaVersion import sbt._ object Dependencies { @@ -34,6 +35,7 @@ object Dependencies { // Libraries val mongodbDriver = "org.mongodb" % "mongodb-driver-async" % mongodbDriverVersion + val scalaReflect = scalaVersion("org.scala-lang" % "scala-reflect" % _) // Test val scalaTest = "org.scalatest" %% "scalatest" % scalaTestVersion % "it,test" diff --git a/project/MongoScalaBuild.scala b/project/MongoScalaBuild.scala index da2fbae0..cfc4f7bc 100644 --- a/project/MongoScalaBuild.scala +++ b/project/MongoScalaBuild.scala @@ -40,9 +40,10 @@ object MongoScalaBuild extends Build { scalaVersion := scalaCoreVersion, crossScalaVersions := scalaVersions, libraryDependencies ++= coreDependencies, + libraryDependencies <+= scalaReflect, resolvers := mongoScalaResolvers, scalacOptions ++= Seq("-unchecked", "-deprecation", "-feature", "-Xlint", "-Xlint:-missing-interpolator" - /*, "-Xlog-implicits", "-Yinfer-debug", "-Xprint:typer"*/) + /*,"-Ymacro-debug-verbose", "-Xlog-implicits", "-Yinfer-debug", "-Xprint:typer"*/) ) val versionSettings = Versioning.settings(baseVersion)