Skip to content

Commit

Permalink
GraphQL generation from model initial commit
Browse files Browse the repository at this point in the history
  • Loading branch information
dantb committed May 24, 2023
1 parent 920a784 commit 117a66c
Show file tree
Hide file tree
Showing 5 changed files with 191 additions and 85 deletions.
2 changes: 1 addition & 1 deletion build.sbt
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,7 @@ ThisBuild / scalaVersion := Scala3 // the default Scala
val Cats = "2.9.0"
val CatsEffect = "3.4.10"
val Circe = "0.14.3"
val Grackle = "0.11.0"
val Grackle = "0.12.0"
val Http4s = "0.23.13"
val Jawn = "1.3.2"
val Literally = "1.1.0"
Expand Down
25 changes: 25 additions & 0 deletions core/src/main/scala/io/dantb/contentless/dsl/dsl.scala
Original file line number Diff line number Diff line change
Expand Up @@ -23,13 +23,38 @@ trait dsl extends Dsl

object dsl extends dsl:

final case class ContentModel(
types: List[ContentType[?]]
)

final case class ContentTpe(
id: ContentTypeId,
displayName: String,
displayField: Option[String],
description: Option[String],
fields: List[Field]
)

object ContentModel:
def of(contentTypes: ContentType[?]*): ContentModel =
ContentModel(
contentTypes.toList
// .map { contentType =>
// import contentType.*
// ContentTpe(id, displayName, displayField, description, codec.schema)
// }
)

trait ContentType[A]:
def id: ContentTypeId
def displayName: String
def displayField: Option[String]
def description: Option[String]
def codec: EntryCodec[A]

object ContentType:
def apply[A](using ct: ContentType[A]) = ct

def contentType[A](
id0: ContentTypeId,
displayName0: String,
Expand Down
148 changes: 148 additions & 0 deletions graphql/src/main/scala/io/dantb/contentless/generation.scala
Original file line number Diff line number Diff line change
@@ -0,0 +1,148 @@
package io.dantb.contentless.graphql

import cats.parse.Parser
import cats.syntax.all.*
import edu.gemini.grackle.{GraphQLParser, Problem, QueryMinimizer}
import edu.gemini.grackle.Ast.*
import edu.gemini.grackle.Value.*
import io.dantb.contentless.*
import io.dantb.contentless.FieldType as FT
import io.dantb.contentless.FieldType.*
import io.dantb.contentless.dsl.*

// TODO
// - functions to omit particular fields and include only specified fields.
// - function for one entry using a provided / derived decoder
// - function for all entries using provided / derived decoder
def generateQueryString[A: ContentType](
model: ContentModel
): GenerationResult[String] = generateDocument(model).map(QueryMinimizer.minimizeDocument)

def generateDocument[A: ContentType](
model: ContentModel
): GenerationResult[Document] = generateDocumentWithArgs(model, CollectionArguments.Default)

def generateDocumentWithArgs[A: ContentType](
model: ContentModel,
arguments: CollectionArguments
): GenerationResult[Document] =
codecFieldsSubqueries(model).map { ss =>
val fields: List[Selection.Field] = sysField :: ss
List(
OperationDefinition.QueryShorthand(
List(
Selection.Field(
None,
Name(s"${ContentType[A].id.asString}Collection"),
List(
Name("skip") -> Value.IntValue(arguments.skip),
Name("limit") -> Value.IntValue(arguments.limit),
Name("preview") -> Value.BooleanValue(arguments.preview),
Name("locale") -> Value.StringValue(arguments.locale)
),
Nil,
List(field("items")(fields*))
)
)
)
)
}

def codecFieldsSubqueries[A: ContentType](
model: ContentModel
): GenerationResult[List[Selection.Field]] =
ContentType[A].codec.schema.sortBy(_.id).traverse(field => subQueryFromField(field, model))

def subQueryFromField(f: Field, model: ContentModel): GenerationResult[Selection.Field] =
f.fieldType match
case _: Text => field(f.id).asRight
case _: FT.Media => imageField(f.id).asRight
case FT.Reference(linkContentTypes) => contentfulReferenceSelection(linkContentTypes, f.id, model)
case FT.Array(itemType, _) =>
contentfulReferenceArraySelection(itemType, f.id, model)
case _: RichText => richTextField(f.id).asRight
case _: Integer => field(f.id).asRight
case _: Number => field(f.id).asRight
case FT.Boolean => field(f.id).asRight
case _: Json => field(f.id).asRight
case _: DateTime => field(f.id).asRight
case FT.Location => locationField(f.id).asRight

def contentfulReferenceSelection(
linkContentTypes: Set[ContentTypeId],
fieldId: String,
model: ContentModel
): GenerationResult[Selection.Field] =
extractReferenceSubQueries(
linkContentTypes,
fieldId,
model,
groupFromField(fieldId, _, model)
)

// TODO: this assumes only one content type can be referenced. In the case of multiple, we need the "..." syntax.
def extractReferenceSubQueries(
linkContentTypes: Set[ContentTypeId],
fieldId: String,
model: ContentModel,
subQueriesForContentType: ContentType[?] => GenerationResult[Selection.Field]
): GenerationResult[Selection.Field] =
linkContentTypes.headOption
.map(ct =>
model.types
.collectFirst {
case c if c.id === ct => subQueriesForContentType(c)
}
.getOrElse(Left(GenerationError.MissingContentType(ct)))
)
.getOrElse(
Left(GenerationError.EmptyListOfReferences(fieldId))
)

def groupFromField(fieldId: String, ct: ContentType[?], model: ContentModel): GenerationResult[Selection.Field] =
val subQueries = ct.codec.schema.sortBy(_.id).traverse(subQueryFromField(_, model))
subQueries.map(subQueries => field(fieldId)((sysField :: subQueries)*))

// TODO: currently only supports arrays of references - handle primitive arrays too
def contentfulReferenceArraySelection(
itemType: FieldType,
fieldId: String,
model: ContentModel
): GenerationResult[Selection.Field] =
itemType match
case io.dantb.contentless.FieldType.Reference(linkContentTypes) =>
extractReferenceSubQueries(
linkContentTypes,
fieldId,
model,
groupFromField("items", _, model).map(fields => field(fieldId ++ "Collection")(fields))
)
// only 'symbol' (short text fields) are supported in arrays
case io.dantb.contentless.FieldType.Text(false, _, _, _, _) => field(fieldId).asRight
case other => Left(GenerationError.UnsupportedArrayType(other, fieldId))

def field(id: String)(subFields: Selection.Field*): Selection.Field =
Selection.Field(None, Name(id), List(), List(), subFields.toList)
def field(id: String): Selection.Field = Selection.Field(None, Name(id), List(), List(), Nil)

def imageField(fieldId: String): Selection.Field =
field(fieldId)(
sysField,
field("description"),
field("height"),
field("title"),
field("url"),
field("width")
)

val sysField: Selection.Field =
field("sys")(
field("id"),
field("publishedAt"),
field("firstPublishedAt"),
field("publishedVersion")
)

def locationField(fieldId: String): Selection.Field = field(fieldId)(field("lat"), field("lon"))

def richTextField(fieldId: String): Selection.Field = field(fieldId)(field("json"))
84 changes: 0 additions & 84 deletions graphql/src/main/scala/io/dantb/contentless/graphql.scala

This file was deleted.

17 changes: 17 additions & 0 deletions graphql/src/main/scala/io/dantb/contentless/model.scala
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
package io.dantb.contentless.graphql

import cats.parse.Parser
import io.dantb.contentless.{ContentTypeId, FieldType}

type GenerationResult[A] = Either[GenerationError, A]

enum GenerationError extends Throwable:
case InvalidGraphQLDocument(e: Parser.Error)
case MissingContentType(id: ContentTypeId)
case EmptyListOfReferences(fieldId: String)
case UnsupportedArrayType(fieldType: FieldType, fieldId: String)

// TODO: Locale should be an enum
final case class CollectionArguments(preview: Boolean, limit: Int, skip: Int, locale: String)
object CollectionArguments:
val Default = CollectionArguments(false, 10, 0, "en-GB")

0 comments on commit 117a66c

Please sign in to comment.