From 647b201124dbc764de488ae4fddcfc65188cc5a3 Mon Sep 17 00:00:00 2001 From: Alex Dupre Date: Fri, 9 Jul 2021 13:22:37 +0200 Subject: [PATCH] Add support to split the single client file into multiple files. Two new options have been added: --splitFiles to split the client code generation into multiple files --enableFmt to disable formatting the generated files with scalafmt The huge gitlab schema compiled in the scripted tests has been converted to use these new options. --- .../caliban/codegen/CalibanSettings.scala | 30 +- .../codegen/CalibanSourceGenerator.scala | 11 +- .../src/sbt-test/codegen/test-compile/test | 8 +- docs/docs/client.html | 10 +- .../scala/caliban/tools/ClientWriter.scala | 277 ++++++++++++++---- .../main/scala/caliban/tools/Codegen.scala | 21 +- .../main/scala/caliban/tools/Formatter.scala | 13 +- .../main/scala/caliban/tools/Options.scala | 12 +- .../caliban/tools/ClientWriterSpec.scala | 71 ++++- .../caliban/tools/ClientWriterViewSpec.scala | 2 +- .../scala/caliban/tools/OptionsSpec.scala | 22 +- 11 files changed, 387 insertions(+), 90 deletions(-) diff --git a/codegen-sbt/src/main/scala/caliban/codegen/CalibanSettings.scala b/codegen-sbt/src/main/scala/caliban/codegen/CalibanSettings.scala index 48719e4297..15c0c61a5b 100644 --- a/codegen-sbt/src/main/scala/caliban/codegen/CalibanSettings.scala +++ b/codegen-sbt/src/main/scala/caliban/codegen/CalibanSettings.scala @@ -13,6 +13,8 @@ sealed trait CalibanSettings { def genView: Option[Boolean] def scalarMappings: Seq[(String, String)] def imports: Seq[String] + def splitFiles: Option[Boolean] + def enableFmt: Option[Boolean] def append(other: Type): Type } @@ -24,7 +26,9 @@ case class CalibanFileSettings( packageName: Option[String], genView: Option[Boolean], scalarMappings: Seq[(String, String)], - imports: Seq[String] + imports: Seq[String], + splitFiles: Option[Boolean], + enableFmt: Option[Boolean] ) extends CalibanSettings { type Type = CalibanFileSettings val headers = Seq.empty // Not applicable for file generator @@ -37,7 +41,9 @@ case class CalibanFileSettings( packageName = other.packageName.orElse(packageName), genView = other.genView.orElse(genView), scalarMappings = scalarMappings ++ other.scalarMappings, - imports = imports ++ other.imports + imports = imports ++ other.imports, + splitFiles = other.splitFiles.orElse(splitFiles), + enableFmt = other.enableFmt.orElse(enableFmt) ) def clientName(value: String): CalibanFileSettings = this.copy(clientName = Some(value)) @@ -47,6 +53,8 @@ case class CalibanFileSettings( def scalarMapping(mapping: (String, String)*): CalibanFileSettings = this.copy(scalarMappings = this.scalarMappings ++ mapping) def imports(values: String*): CalibanFileSettings = this.copy(imports = this.imports ++ values) + def splitFiles(value: Boolean): CalibanFileSettings = this.copy(splitFiles = Some(value)) + def enableFmt(value: Boolean): CalibanFileSettings = this.copy(enableFmt = Some(value)) } case class CalibanUrlSettings( @@ -57,7 +65,9 @@ case class CalibanUrlSettings( packageName: Option[String], genView: Option[Boolean], scalarMappings: Seq[(String, String)], - imports: Seq[String] + imports: Seq[String], + splitFiles: Option[Boolean], + enableFmt: Option[Boolean] ) extends CalibanSettings { type Type = CalibanUrlSettings def append(other: CalibanUrlSettings): CalibanUrlSettings = @@ -69,7 +79,9 @@ case class CalibanUrlSettings( packageName = other.packageName.orElse(packageName), genView = other.genView.orElse(genView), scalarMappings = scalarMappings ++ other.scalarMappings, - imports = imports ++ other.imports + imports = imports ++ other.imports, + splitFiles = other.splitFiles.orElse(splitFiles), + enableFmt = other.enableFmt.orElse(enableFmt) ) def clientName(value: String): CalibanUrlSettings = this.copy(clientName = Some(value)) @@ -81,6 +93,8 @@ case class CalibanUrlSettings( def scalarMapping(mapping: (String, String)*): CalibanUrlSettings = this.copy(scalarMappings = this.scalarMappings ++ mapping) def imports(values: String*): CalibanUrlSettings = this.copy(imports = this.imports ++ values) + def splitFiles(value: Boolean): CalibanUrlSettings = this.copy(splitFiles = Some(value)) + def enableFmt(value: Boolean): CalibanUrlSettings = this.copy(enableFmt = Some(value)) } object CalibanSettings { @@ -92,7 +106,9 @@ object CalibanSettings { packageName = Option.empty[String], genView = Option.empty[Boolean], scalarMappings = Seq.empty[(String, String)], - imports = Seq.empty[String] + imports = Seq.empty[String], + splitFiles = Option.empty[Boolean], + enableFmt = Option.empty[Boolean] ) def emptyUrl(url: URL): CalibanUrlSettings = CalibanUrlSettings( @@ -103,6 +119,8 @@ object CalibanSettings { packageName = Option.empty[String], genView = Option.empty[Boolean], scalarMappings = Seq.empty[(String, String)], - imports = Seq.empty[String] + imports = Seq.empty[String], + splitFiles = Option.empty[Boolean], + enableFmt = Option.empty[Boolean] ) } diff --git a/codegen-sbt/src/main/scala/caliban/codegen/CalibanSourceGenerator.scala b/codegen-sbt/src/main/scala/caliban/codegen/CalibanSourceGenerator.scala index 4fda59dcbe..94b5d58b57 100644 --- a/codegen-sbt/src/main/scala/caliban/codegen/CalibanSourceGenerator.scala +++ b/codegen-sbt/src/main/scala/caliban/codegen/CalibanSourceGenerator.scala @@ -84,8 +84,15 @@ object CalibanSourceGenerator { ) // NB: Presuming zio-config can read toString'd booleans val scalarMappings = pairList("--scalarMappings", settings.scalarMappings) val imports = list("--imports", settings.imports) - - scalafmtPath ++ headers ++ packageName ++ genView ++ scalarMappings ++ imports + val splitFiles = singleOpt( + "--splitFiles", + settings.splitFiles.map(_.toString()) + ) // NB: Presuming zio-config can read toString'd booleans + val enableFmt = singleOpt( + "--enableFmt", + settings.enableFmt.map(_.toString()) + ) // NB: Presuming zio-config can read toString'd booleans + scalafmtPath ++ headers ++ packageName ++ genView ++ scalarMappings ++ imports ++ splitFiles ++ enableFmt } def apply( diff --git a/codegen-sbt/src/sbt-test/codegen/test-compile/test b/codegen-sbt/src/sbt-test/codegen/test-compile/test index ab7c44bcb6..98fd901c60 100644 --- a/codegen-sbt/src/sbt-test/codegen/test-compile/test +++ b/codegen-sbt/src/sbt-test/codegen/test-compile/test @@ -25,9 +25,11 @@ $ mkdir src/main/scala/genview/client > calibanGenClient project/schema-to-check-name-uniqueness.graphql src/main/scala/genview/client/ClientNameUniqueness.scala --packageName genview.client --genView true $ exists src/main/scala/genview/client/ClientNameUniqueness.scala $ exec sh verify.sh StarshipView ./src/main/scala/genview/client/ClientNameUniqueness.scala -> calibanGenClient project/gitlab-schema.graphql src/main/scala/genview/client/ClientGitLab.scala --packageName genview.client --genView true -$ exists src/main/scala/genview/client/ClientGitLab.scala -$ exec sh verify.sh VulnerableProjectsByGradeView ./src/main/scala/genview/client/ClientGitLab.scala +> calibanGenClient project/gitlab-schema.graphql src/main/scala/genview/client/ClientGitLab.scala --packageName genview.client --genView true --splitFiles true --enableFmt false +$ exists src/main/scala/genview/client/package.scala +$ exec sh verify.sh VulnerableProjectsByGrade ./src/main/scala/genview/client/package.scala +$ exists src/main/scala/genview/client/VulnerableProjectsByGrade.scala +$ exec sh verify.sh VulnerableProjectsByGradeView ./src/main/scala/genview/client/VulnerableProjectsByGrade.scala > compile $ exists target/scala-2.12/src_managed/main/caliban-codegen-sbt/caliban/Client.scala diff --git a/docs/docs/client.html b/docs/docs/client.html index 5660e07fe8..b45d5700cb 100644 --- a/docs/docs/client.html +++ b/docs/docs/client.html @@ -64,10 +64,12 @@ def genView(value: Boolean): CalibanSettings // Provide a case class and helper method to select all fields on an object (default: false) def scalarMapping(mapping: (String,String)*): CalibanSettings // A mapping from GraphQL scalar types to JVM types, as unknown scalar types are represented as String by default. def imports(values: String*): CalibanSettings // A list of imports to be added to the top of a generated client + def splitFiles(value: Boolean): CalibanSettings // Split single client object into multiple files (default: false) + def enableFmt(value: Boolean): CalibanSettings // Enable code formatting with scalafmt (default: true) // Only defined for `url` settings, for use in supplying extra headers when fetching the schema itself def headers(pairs: (String,String)*): CalibanSettings -

# calibanGenClient

If you prefer to generate the client explicitly rather than automatically, you can use calibanGenClient on the SBT CLI as follows:

calibanGenClient schemaPath outputPath [--scalafmtPath path] [--headers name:value,name2:value2] [--genView true|false] [--scalarMappings gqlType:f.q.d.n.Type,gqlType2:f.q.d.n.Type2] [--imports a.b.c._,c.d.E]
+

# calibanGenClient

If you prefer to generate the client explicitly rather than automatically, you can use calibanGenClient on the SBT CLI as follows:

calibanGenClient schemaPath outputPath [--scalafmtPath path] [--headers name:value,name2:value2] [--genView true|false] [--scalarMappings gqlType:f.q.d.n.Type,gqlType2:f.q.d.n.Type2] [--imports a.b.c._,c.d.E] [--splitFiles true|false] [--enableFmt true|false]
 
 calibanGenClient project/schema.graphql src/main/client/Client.scala --genView true  
 

This command will generate a Scala file in outputPath containing helper functions for all the types defined in the provided GraphQL schema defined at schemaPath. @@ -80,7 +82,11 @@ option. Provide --genView true option if you want to generate a view for the GraphQL types. If you want to force a mapping between a GraphQL type and a Scala class (such as scalars), you can use the ---scalarMappings option. Also you can add imports for example for your ArgEncoder implicits by providing --imports option.

# Query building

Once the boilerplate code is generated, you can start building queries. For each type in your schema, a corresponding Scala object has been created. For each field in your schema, a corresponding Scala function has been created.

For example, given the following schema:

type Character {
+--scalarMappings option. Also you can add imports for example for your ArgEncoder implicits by providing --imports option.
+Use the --splitFiles true option if you want to generate multiple files within the same package instead of a single file.
+In this case the filename part of the outputPath will be ignored, but the value will still be used to determine the mandatory package name and destination directory.
+This can be helpful with large schemas and incremental compilation.
+Provide --enableFmt option if you don't need to format generated files.

# Query building

Once the boilerplate code is generated, you can start building queries. For each type in your schema, a corresponding Scala object has been created. For each field in your schema, a corresponding Scala function has been created.

For example, given the following schema:

type Character {
   name: String!
   nicknames: [String!]!
   origin: Origin!
diff --git a/tools/src/main/scala/caliban/tools/ClientWriter.scala b/tools/src/main/scala/caliban/tools/ClientWriter.scala
index f19774617c..6215182402 100644
--- a/tools/src/main/scala/caliban/tools/ClientWriter.scala
+++ b/tools/src/main/scala/caliban/tools/ClientWriter.scala
@@ -19,8 +19,11 @@ object ClientWriter {
     objectName: String = "Client",
     packageName: Option[String] = None,
     genView: Boolean = false,
-    additionalImports: Option[List[String]] = None
-  )(implicit scalarMappings: ScalarMappings): String = {
+    additionalImports: Option[List[String]] = None,
+    splitFiles: Boolean = false
+  )(implicit scalarMappings: ScalarMappings): List[(String, String)] = {
+    require(packageName.isDefined || !splitFiles, "splitFiles option requires a package name")
+
     val schemaDef = schema.schemaDefinition
 
     implicit val mappingClashedTypeNames: MappingClashedTypeNames = MappingClashedTypeNames(
@@ -32,7 +35,8 @@ object ClientWriter {
           case UnionTypeDefinition(_, name, _, _)       => name
           case ScalarTypeDefinition(_, name, _)         => name
           case InterfaceTypeDefinition(_, name, _, _)   => name
-        }
+        },
+        if (splitFiles) List("package") else Nil
       )
     )
 
@@ -47,6 +51,18 @@ object ClientWriter {
       safeTypeName(name) -> op
     }.toMap)
 
+    val objectTypes =
+      if (splitFiles)
+        schema.objectTypeDefinitions
+          .filterNot(obj =>
+            reservedType(obj) ||
+              schemaDef.exists(_.query.getOrElse("Query") == obj.name) ||
+              schemaDef.exists(_.mutation.getOrElse("Mutation") == obj.name) ||
+              schemaDef.exists(_.subscription.getOrElse("Subscription") == obj.name)
+          )
+          .map(writeObjectType)
+      else Nil
+
     val objects = schema.objectTypeDefinitions
       .filterNot(obj =>
         reservedType(obj) ||
@@ -54,74 +70,192 @@ object ClientWriter {
           schemaDef.exists(_.mutation.getOrElse("Mutation") == obj.name) ||
           schemaDef.exists(_.subscription.getOrElse("Subscription") == obj.name)
       )
-      .map(writeObject(_, genView))
-      .mkString("\n")
+      .map { typedef =>
+        val content     = writeObject(typedef, genView)
+        val fullContent =
+          if (splitFiles)
+            s"""import caliban.client.FieldBuilder._
+               |import caliban.client._
+               |
+               |$content
+               |""".stripMargin
+          else
+            s"""${writeObjectType(typedef)}
+               |$content
+               |""".stripMargin
+        safeTypeName(typedef.name) -> fullContent
+      }
 
-    val inputs = schema.inputObjectTypeDefinitions.map(writeInputObject).mkString("\n")
+    val inputs = schema.inputObjectTypeDefinitions.map { typedef =>
+      val content     = writeInputObject(typedef)
+      val fullContent =
+        if (splitFiles)
+          s"""import caliban.client._
+             |import caliban.client.__Value._
+             |
+             |$content
+             |""".stripMargin
+        else
+          content
+      safeTypeName(typedef.name) -> fullContent
+    }
 
     val enums = schema.enumTypeDefinitions
       .filter(e => !scalarMappings.scalarMap.exists(_.contains(e.name)))
-      .map(writeEnum)
-      .mkString("\n")
+      .map { typedef =>
+        val content     = writeEnum(typedef)
+        val fullContent =
+          if (splitFiles)
+            s"""import caliban.client.CalibanClientError.DecodingError
+               |import caliban.client._
+               |import caliban.client.__Value._
+               |
+               |$content
+               |""".stripMargin
+          else
+            content
+        safeTypeName(typedef.name) -> fullContent
+      }
 
     val scalars = schema.scalarTypeDefinitions
       .filterNot(s => isScalarSupported(s.name))
       .map(writeScalar)
-      .mkString("\n")
+
+    val queryTypes =
+      if (splitFiles)
+        schema
+          .objectTypeDefinition(schemaDef.flatMap(_.query).getOrElse("Query"))
+          .map(writeRootQueryType)
+          .toList
+      else Nil
 
     val queries = schema
       .objectTypeDefinition(schemaDef.flatMap(_.query).getOrElse("Query"))
-      .map(writeRootQuery)
-      .getOrElse("")
+      .map { typedef =>
+        val content     = writeRootQuery(typedef)
+        val fullContent =
+          if (splitFiles)
+            s"""import caliban.client.FieldBuilder._
+               |import caliban.client._
+               |
+               |$content
+               |""".stripMargin
+          else
+            s"""${writeRootQueryType(typedef)}
+               |$content
+               |""".stripMargin
+        safeTypeName(typedef.name) -> fullContent
+      }
+
+    val mutationTypes =
+      if (splitFiles)
+        schema
+          .objectTypeDefinition(schemaDef.flatMap(_.mutation).getOrElse("Mutation"))
+          .map(writeRootMutationType)
+          .toList
+      else Nil
 
     val mutations = schema
       .objectTypeDefinition(schemaDef.flatMap(_.mutation).getOrElse("Mutation"))
-      .map(writeRootMutation)
-      .getOrElse("")
+      .map { typedef =>
+        val content     = writeRootMutation(typedef)
+        val fullContent =
+          if (splitFiles)
+            s"""import caliban.client.FieldBuilder._
+               |import caliban.client._
+               |
+               |$content
+               |""".stripMargin
+          else
+            s"""${writeRootMutationType(typedef)}
+               |$content
+               |""".stripMargin
+        safeTypeName(typedef.name) -> fullContent
+      }
+
+    val subscriptionTypes =
+      if (splitFiles)
+        schema
+          .objectTypeDefinition(schemaDef.flatMap(_.subscription).getOrElse("Subscription"))
+          .map(writeRootSubscriptionType)
+          .toList
+      else Nil
 
     val subscriptions = schema
       .objectTypeDefinition(schemaDef.flatMap(_.subscription).getOrElse("Subscription"))
-      .map(writeRootSubscription)
-      .getOrElse("")
-
-    val additionalImportsString = additionalImports.fold("")(_.map(i => s"import $i").mkString("\n"))
-
-    val imports =
-      s"""${if (enums.nonEmpty)
-        """import caliban.client.CalibanClientError.DecodingError
-          |""".stripMargin
-      else ""}${if (objects.nonEmpty || queries.nonEmpty || mutations.nonEmpty || subscriptions.nonEmpty)
-        """import caliban.client.FieldBuilder._
-          |""".stripMargin
-      else
-        ""}${if (
-        enums.nonEmpty || objects.nonEmpty || queries.nonEmpty || mutations.nonEmpty || subscriptions.nonEmpty || inputs.nonEmpty
-      )
-        """import caliban.client._
-          |""".stripMargin
-      else ""}${if (enums.nonEmpty || inputs.nonEmpty)
-        """import caliban.client.__Value._
-          |""".stripMargin
-      else ""}"""
-
-    s"""${packageName.fold("")(p => s"package $p\n\n")}$imports\n
-       |$additionalImportsString
-       |
-       |object $objectName {
-       |
-       |  $scalars
-       |  $enums
-       |  $objects
-       |  $inputs
-       |  $queries
-       |  $mutations
-       |  $subscriptions
-       |  
-       |}""".stripMargin
+      .map { typedef =>
+        val content     = writeRootSubscription(typedef)
+        val fullContent =
+          if (splitFiles)
+            s"""import caliban.client.FieldBuilder._
+               |import caliban.client._
+               |
+               |$content
+               |""".stripMargin
+          else
+            s"""${writeRootSubscriptionType(typedef)}
+               |$content
+               |""".stripMargin
+        safeTypeName(typedef.name) -> fullContent
+      }
+
+    if (splitFiles) {
+      val parentPackageName = packageName.filter(_.contains(".")).map(_.reverse.dropWhile(_ != '.').drop(1).reverse)
+      val packageObject = "package" ->
+        s"""${parentPackageName.fold("")(p => s"package $p\n")}
+           |package object ${packageName.get.reverse.takeWhile(_ != '.').reverse} {
+           |  ${(scalars ::: objectTypes ::: queryTypes ::: mutationTypes ::: subscriptionTypes).mkString("\n")}
+           |}
+           |""".stripMargin
+      val classFiles    =
+        (enums ::: objects ::: inputs ::: queries.toList ::: mutations.toList ::: subscriptions.toList).map {
+          case (name, content) =>
+            val fullContent =
+              s"""${packageName.fold("")(p => s"package $p\n\n")}$content\n
+                 |""".stripMargin
+            name -> fullContent
+        }
+      packageObject :: classFiles
+    } else {
+      val additionalImportsString = additionalImports.fold("")(_.map(i => s"import $i").mkString("\n"))
+
+      val imports =
+        s"""${if (enums.nonEmpty)
+          """import caliban.client.CalibanClientError.DecodingError
+            |""".stripMargin
+        else ""}${if (objects.nonEmpty || queries.nonEmpty || mutations.nonEmpty || subscriptions.nonEmpty)
+          """import caliban.client.FieldBuilder._
+            |""".stripMargin
+        else
+          ""}${if (
+          enums.nonEmpty || objects.nonEmpty || queries.nonEmpty || mutations.nonEmpty || subscriptions.nonEmpty || inputs.nonEmpty
+        )
+          """import caliban.client._
+            |""".stripMargin
+        else ""}${if (enums.nonEmpty || inputs.nonEmpty)
+          """import caliban.client.__Value._
+            |""".stripMargin
+        else ""}"""
+
+      List(objectName -> s"""${packageName.fold("")(p => s"package $p\n\n")}$imports\n
+                            |$additionalImportsString
+                            |
+                            |object $objectName {
+                            |
+                            |  ${scalars.mkString("\n")}
+                            |  ${enums.map(_._2).mkString("\n")}
+                            |  ${objects.map(_._2).mkString("\n")}
+                            |  ${inputs.map(_._2).mkString("\n")}
+                            |  ${queries.map(_._2).mkString("\n")}
+                            |  ${mutations.map(_._2).mkString("\n")}
+                            |  ${subscriptions.map(_._2).mkString("\n")}
+                            |
+                            |}""".stripMargin)
+    }
   }
 
-  private def getMappingsClashedNames(typeNames: List[String]): Map[String, String] =
-    typeNames
+  private def getMappingsClashedNames(typeNames: List[String], reservedNames: List[String] = Nil): Map[String, String] =
+    (reservedNames ::: typeNames)
       .map(name => name.toLowerCase -> name)
       .groupBy(_._1)
       .collect {
@@ -145,6 +279,11 @@ object ClientWriter {
   def reservedType(typeDefinition: ObjectTypeDefinition): Boolean =
     typeDefinition.name == "Query" || typeDefinition.name == "Mutation" || typeDefinition.name == "Subscription"
 
+  def writeRootQueryType(
+    typedef: ObjectTypeDefinition
+  ): String =
+    s"type ${typedef.name} = _root_.caliban.client.Operations.RootQuery"
+
   def writeRootQuery(
     typedef: ObjectTypeDefinition
   )(implicit
@@ -152,12 +291,16 @@ object ClientWriter {
     mappingClashedTypeNames: MappingClashedTypeNames,
     scalarMappings: ScalarMappings
   ): String =
-    s"""type ${typedef.name} = _root_.caliban.client.Operations.RootQuery
-       |object ${typedef.name} {
+    s"""object ${typedef.name} {
        |  ${typedef.fields.map(writeField(_, "_root_.caliban.client.Operations.RootQuery")).mkString("\n  ")}
        |}
        |""".stripMargin
 
+  def writeRootMutationType(
+    typedef: ObjectTypeDefinition
+  ): String =
+    s"type ${typedef.name} = _root_.caliban.client.Operations.RootMutation"
+
   def writeRootMutation(
     typedef: ObjectTypeDefinition
   )(implicit
@@ -165,12 +308,16 @@ object ClientWriter {
     mappingClashedTypeNames: MappingClashedTypeNames,
     scalarMappings: ScalarMappings
   ): String =
-    s"""type ${typedef.name} = _root_.caliban.client.Operations.RootMutation
-       |object ${typedef.name} {
+    s"""object ${typedef.name} {
        |  ${typedef.fields.map(writeField(_, "_root_.caliban.client.Operations.RootMutation")).mkString("\n  ")}
        |}
        |""".stripMargin
 
+  def writeRootSubscriptionType(
+    typedef: ObjectTypeDefinition
+  ): String =
+    s"type ${typedef.name} = _root_.caliban.client.Operations.RootSubscription"
+
   def writeRootSubscription(
     typedef: ObjectTypeDefinition
   )(implicit
@@ -178,12 +325,23 @@ object ClientWriter {
     mappingClashedTypeNames: MappingClashedTypeNames,
     scalarMappings: ScalarMappings
   ): String =
-    s"""type ${typedef.name} = _root_.caliban.client.Operations.RootSubscription
-       |object ${typedef.name} {
+    s"""object ${typedef.name} {
        |  ${typedef.fields.map(writeField(_, "_root_.caliban.client.Operations.RootSubscription")).mkString("\n  ")}
        |}
        |""".stripMargin
 
+  def writeObjectType(
+    typedef: ObjectTypeDefinition
+  )(implicit
+    mappingClashedTypeNames: MappingClashedTypeNames,
+    scalarMappings: ScalarMappings
+  ): String = {
+
+    val objectName: String = safeTypeName(typedef.name)
+
+    s"type $objectName"
+  }
+
   def writeObject(
     typedef: ObjectTypeDefinition,
     genView: Boolean
@@ -200,8 +358,7 @@ object ClientWriter {
         "\n  " + writeView(typedef.name, fields.map(_.typeInfo))
       else ""
 
-    s"""type $objectName
-       |object $objectName {$view
+    s"""object $objectName {$view
        |  ${fields.map(writeFieldInfo).mkString("\n  ")}
        |}
        |""".stripMargin
diff --git a/tools/src/main/scala/caliban/tools/Codegen.scala b/tools/src/main/scala/caliban/tools/Codegen.scala
index b05fb06092..b38734e47b 100644
--- a/tools/src/main/scala/caliban/tools/Codegen.scala
+++ b/tools/src/main/scala/caliban/tools/Codegen.scala
@@ -28,22 +28,31 @@ object Codegen {
     }
     val genView            = arguments.genView.getOrElse(false)
     val scalarMappings     = arguments.scalarMappings
+    val splitFiles         = arguments.splitFiles.getOrElse(false)
+    val enableFmt          = arguments.enableFmt.getOrElse(true)
     val loader             = getSchemaLoader(arguments.schemaPath, arguments.headers)
     for {
       schema    <- loader.load
       code       = genType match {
                      case GenType.Schema =>
-                       SchemaWriter.write(schema, packageName, effect, arguments.imports, abstractEffectType)(
-                         ScalarMappings(scalarMappings)
+                       List(
+                         objectName -> SchemaWriter.write(schema, packageName, effect, arguments.imports, abstractEffectType)(
+                           ScalarMappings(scalarMappings)
+                         )
                        )
                      case GenType.Client =>
-                       ClientWriter.write(schema, objectName, packageName, genView, arguments.imports)(
+                       ClientWriter.write(schema, objectName, packageName, genView, arguments.imports, splitFiles)(
                          ScalarMappings(scalarMappings)
                        )
                    }
-      formatted <- Formatter.format(code, arguments.fmtPath)
-      _         <- Task(new PrintWriter(new File(arguments.toPath)))
-                     .bracket(q => UIO(q.close()), pw => Task(pw.println(formatted)))
+      formatted <- if (enableFmt) Formatter.format(code, arguments.fmtPath) else Task.succeed(code)
+      _         <- Task.collectAll(formatted.map { case (objectName, objectCode) =>
+                     val path =
+                       if (splitFiles) s"${arguments.toPath.reverse.dropWhile(_ != '/').reverse}$objectName.scala"
+                       else arguments.toPath
+                     Task(new PrintWriter(new File(path)))
+                       .bracket(q => UIO(q.close()), pw => Task(pw.println(objectCode)))
+                   })
     } yield ()
   }
 
diff --git a/tools/src/main/scala/caliban/tools/Formatter.scala b/tools/src/main/scala/caliban/tools/Formatter.scala
index 50f4c0d5cd..b8bbecc206 100644
--- a/tools/src/main/scala/caliban/tools/Formatter.scala
+++ b/tools/src/main/scala/caliban/tools/Formatter.scala
@@ -7,15 +7,20 @@ import zio.Task
 
 object Formatter {
 
-  def format(str: String, fmtPath: Option[String]): Task[String] = Task {
+  def format(str: String, fmtPath: Option[String]): Task[String]                                  =
+    format(List("Nil.scala" -> str), fmtPath).map(_.head._2)
+
+  def format(strs: List[(String, String)], fmtPath: Option[String]): Task[List[(String, String)]] = Task {
     val scalafmt          = Scalafmt.create(this.getClass.getClassLoader)
     val defaultConfigPath = Paths.get(".scalafmt.conf")
     val defaultConfig     =
       if (Files.exists(defaultConfigPath)) defaultConfigPath else Paths.get("")
     val config            = fmtPath.fold(defaultConfig)(Paths.get(_))
-    val result            = scalafmt
-      .withRespectVersion(false)
-      .format(config, Paths.get("Nil.scala"), str)
+    val result            = strs.map { case (name, code) =>
+      name -> scalafmt
+        .withRespectVersion(false)
+        .format(config, Paths.get(s"$name.scala"), code)
+    }
     scalafmt.clear()
     result
   }
diff --git a/tools/src/main/scala/caliban/tools/Options.scala b/tools/src/main/scala/caliban/tools/Options.scala
index 5d6aca1b34..27fb19eae4 100644
--- a/tools/src/main/scala/caliban/tools/Options.scala
+++ b/tools/src/main/scala/caliban/tools/Options.scala
@@ -13,7 +13,9 @@ final case class Options(
   effect: Option[String],
   scalarMappings: Option[Map[String, String]],
   imports: Option[List[String]],
-  abstractEffectType: Option[Boolean]
+  abstractEffectType: Option[Boolean],
+  splitFiles: Option[Boolean],
+  enableFmt: Option[Boolean]
 )
 
 object Options {
@@ -26,7 +28,9 @@ object Options {
     effect: Option[String],
     scalarMappings: Option[List[String]],
     imports: Option[List[String]],
-    abstractEffectType: Option[Boolean]
+    abstractEffectType: Option[Boolean],
+    splitFiles: Option[Boolean],
+    enableFmt: Option[Boolean]
   )
 
   def fromArgs(args: List[String]): Option[Options] =
@@ -65,7 +69,9 @@ object Options {
               }.toMap
             },
             rawOpts.imports,
-            rawOpts.abstractEffectType
+            rawOpts.abstractEffectType,
+            rawOpts.splitFiles,
+            rawOpts.enableFmt
           )
         }
       case _                             => None
diff --git a/tools/src/test/scala/caliban/tools/ClientWriterSpec.scala b/tools/src/test/scala/caliban/tools/ClientWriterSpec.scala
index 8641ce0f4f..3528b7ce63 100644
--- a/tools/src/test/scala/caliban/tools/ClientWriterSpec.scala
+++ b/tools/src/test/scala/caliban/tools/ClientWriterSpec.scala
@@ -17,7 +17,24 @@ object ClientWriterSpec extends DefaultRunnableSpec {
     .parseQuery(schema)
     .flatMap(doc =>
       Formatter.format(
-        ClientWriter.write(doc, additionalImports = Some(additionalImports))(
+        ClientWriter
+          .write(doc, additionalImports = Some(additionalImports))(
+            ScalarMappings(Some(scalarMappings))
+          )
+          .head
+          ._2,
+        None
+      )
+    )
+
+  def genSplit(
+    schema: String,
+    scalarMappings: Map[String, String] = Map.empty
+  ): Task[List[(String, String)]] = Parser
+    .parseQuery(schema)
+    .flatMap(doc =>
+      Formatter.format(
+        ClientWriter.write(doc, packageName = Some("test"), splitFiles = true)(
           ScalarMappings(Some(scalarMappings))
         ),
         None
@@ -711,6 +728,58 @@ object Client {
 """
           )
         }
+      },
+      testM("schema with splitFiles") {
+        val schema =
+          """
+             schema {
+               query: Q
+             }
+
+             type Q {
+               characters: [Character!]!
+             }
+
+             type Character {
+               name: String!
+               nicknames: [String!]!
+             }
+            """.stripMargin
+
+        assertM(genSplit(schema))(
+          equalTo(
+            List(
+              "package"   -> """package object test {
+                             |  type Character
+                             |  type Q = _root_.caliban.client.Operations.RootQuery
+                             |}
+                             |""".stripMargin,
+              "Character" -> """package test
+                               |
+                               |import caliban.client.FieldBuilder._
+                               |import caliban.client._
+                               |
+                               |object Character {
+                               |  def name: SelectionBuilder[Character, String]            = _root_.caliban.client.SelectionBuilder.Field("name", Scalar())
+                               |  def nicknames: SelectionBuilder[Character, List[String]] =
+                               |    _root_.caliban.client.SelectionBuilder.Field("nicknames", ListOf(Scalar()))
+                               |}
+                               |""".stripMargin,
+              "Q"         -> """package test
+                       |
+                       |import caliban.client.FieldBuilder._
+                       |import caliban.client._
+                       |
+                       |object Q {
+                       |  def characters[A](
+                       |    innerSelection: SelectionBuilder[Character, A]
+                       |  ): SelectionBuilder[_root_.caliban.client.Operations.RootQuery, List[A]] =
+                       |    _root_.caliban.client.SelectionBuilder.Field("characters", ListOf(Obj(innerSelection)))
+                       |}
+                       |""".stripMargin
+            )
+          )
+        )
       }
     ) @@ TestAspect.sequential
 }
diff --git a/tools/src/test/scala/caliban/tools/ClientWriterViewSpec.scala b/tools/src/test/scala/caliban/tools/ClientWriterViewSpec.scala
index 5c44e36363..9d809c466c 100644
--- a/tools/src/test/scala/caliban/tools/ClientWriterViewSpec.scala
+++ b/tools/src/test/scala/caliban/tools/ClientWriterViewSpec.scala
@@ -12,7 +12,7 @@ object ClientWriterViewSpec extends DefaultRunnableSpec {
   val gen: String => Task[String] = (schema: String) =>
     Parser
       .parseQuery(schema)
-      .flatMap(doc => Formatter.format(ClientWriter.write(doc, genView = true)(ScalarMappings(None)), None))
+      .flatMap(doc => Formatter.format(ClientWriter.write(doc, genView = true)(ScalarMappings(None)).head._2, None))
 
   override def spec: ZSpec[TestEnvironment, Any] =
     suite("ClientWriterViewSpec")(
diff --git a/tools/src/test/scala/caliban/tools/OptionsSpec.scala b/tools/src/test/scala/caliban/tools/OptionsSpec.scala
index 5dbbf53052..7e1211801c 100644
--- a/tools/src/test/scala/caliban/tools/OptionsSpec.scala
+++ b/tools/src/test/scala/caliban/tools/OptionsSpec.scala
@@ -24,6 +24,8 @@ object OptionsSpec extends DefaultRunnableSpec {
                 None,
                 None,
                 None,
+                None,
+                None,
                 None
               )
             )
@@ -46,6 +48,8 @@ object OptionsSpec extends DefaultRunnableSpec {
                 None,
                 None,
                 None,
+                None,
+                None,
                 None
               )
             )
@@ -58,7 +62,7 @@ object OptionsSpec extends DefaultRunnableSpec {
         assert(result)(
           equalTo(
             Some(
-              Options("schema", "output", None, None, None, None, None, None, None, None)
+              Options("schema", "output", None, None, None, None, None, None, None, None, None, None)
             )
           )
         )
@@ -93,6 +97,8 @@ object OptionsSpec extends DefaultRunnableSpec {
                 None,
                 None,
                 None,
+                None,
+                None,
                 None
               )
             )
@@ -115,6 +121,8 @@ object OptionsSpec extends DefaultRunnableSpec {
                 Some("cats.effect.IO"),
                 None,
                 None,
+                None,
+                None,
                 None
               )
             )
@@ -137,6 +145,8 @@ object OptionsSpec extends DefaultRunnableSpec {
                 None,
                 None,
                 None,
+                None,
+                None,
                 None
               )
             )
@@ -159,6 +169,8 @@ object OptionsSpec extends DefaultRunnableSpec {
                 None,
                 Some(Map("Long" -> "scala.Long")),
                 None,
+                None,
+                None,
                 None
               )
             )
@@ -181,6 +193,8 @@ object OptionsSpec extends DefaultRunnableSpec {
                 None,
                 None,
                 Some(List("a.b.Clazz", "b.c._")),
+                None,
+                None,
                 None
               )
             )
@@ -203,7 +217,9 @@ object OptionsSpec extends DefaultRunnableSpec {
                 Some("F"),
                 None,
                 None,
-                Some(true)
+                Some(true),
+                None,
+                None
               )
             )
           )
@@ -225,6 +241,8 @@ object OptionsSpec extends DefaultRunnableSpec {
                 None,
                 None,
                 None,
+                None,
+                None,
                 None
               )
             )