forked from ghostdogpr/caliban
-
Notifications
You must be signed in to change notification settings - Fork 0
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
Implement "Field Selection Merging" validation (ghostdogpr#1084)
* feat: Validate field merging (5.3.2). * remove import * fix scala3 * Remove unused * Print types in error * wip: Use algorithm from Apollo * uncached xing * cleanup * test: Add fragment tests from spec * fix leaf case * cleanup test names * compare field names * style: remove braces * fix: memoize * cleanup * style: move stuff to utils * fix: scala2 * only run once at top level * zio-cached * Cache plain scala function This reverts commit 9a45e28. * Add return type * Pattern match on cache hit * Use Chunks for better perf * Use mutable map * Pattern match * use isObjectType * Add helper * Add benchmark * Add new sangria * rely on mutability * fix empty match * put -> update
- Loading branch information
Showing
9 changed files
with
988 additions
and
88 deletions.
There are no files selected for viewing
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,58 @@ | ||
package caliban.validation | ||
|
||
import caliban.introspection.adt._ | ||
import caliban.parsing.adt.Selection.{ Field, FragmentSpread, InlineFragment } | ||
import caliban.parsing.adt._ | ||
import Utils._ | ||
|
||
object FieldMap { | ||
val empty: FieldMap = Map.empty | ||
|
||
implicit class FieldMapOps(val self: FieldMap) extends AnyVal { | ||
def |+|(that: FieldMap): FieldMap = | ||
(self.keySet ++ that.keySet).map { k => | ||
k -> (self.get(k).getOrElse(Set.empty) ++ that.get(k).getOrElse(Set.empty)) | ||
}.toMap | ||
|
||
def show = | ||
self.map { case (k, fields) => | ||
s"$k -> ${fields.map(_.fieldDef.name).mkString(", ")}" | ||
}.mkString("\n") | ||
|
||
def addField( | ||
f: Field, | ||
parentType: __Type, | ||
selection: Field | ||
): FieldMap = { | ||
val responseName = f.alias.getOrElse(f.name) | ||
|
||
getFields(parentType) | ||
.flatMap(fields => fields.find(_.name == f.name)) | ||
.map { f => | ||
val sf = SelectedField(parentType, selection, f) | ||
val entry = self.get(responseName).map(_ + sf).getOrElse(Set(sf)) | ||
self + (responseName -> entry) | ||
} | ||
.getOrElse(self) | ||
} | ||
} | ||
|
||
def apply(context: Context, parentType: __Type, selectionSet: Iterable[Selection]): FieldMap = | ||
selectionSet.foldLeft(FieldMap.empty)({ case (fields, selection) => | ||
selection match { | ||
case FragmentSpread(name, directives) => | ||
context.fragments | ||
.get(name) | ||
.map { definition => | ||
val typ = getType(Some(definition.typeCondition), parentType, context) | ||
apply(context, typ, definition.selectionSet) |+| fields | ||
} | ||
.getOrElse(fields) | ||
case f: Field => | ||
fields.addField(f, parentType, f) | ||
case InlineFragment(typeCondition, _, selectionSet) => | ||
val typ = getType(typeCondition, parentType, context) | ||
apply(context, typ, selectionSet) |+| fields | ||
} | ||
}) | ||
} |
132 changes: 132 additions & 0 deletions
132
core/src/main/scala/caliban/validation/FragmentValidator.scala
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,132 @@ | ||
package caliban.validation | ||
|
||
import caliban.CalibanError.ValidationError | ||
import caliban.introspection.adt._ | ||
import caliban.parsing.adt.Selection | ||
import zio.{ Chunk, IO, UIO } | ||
import Utils._ | ||
import Utils.syntax._ | ||
import scala.collection.mutable | ||
|
||
object FragmentValidator { | ||
def findConflictsWithinSelectionSet( | ||
context: Context, | ||
parentType: __Type, | ||
selectionSet: List[Selection] | ||
): IO[ValidationError, Unit] = { | ||
val shapeCache = scala.collection.mutable.Map.empty[Iterable[Selection], Chunk[String]] | ||
val parentsCache = scala.collection.mutable.Map.empty[Iterable[Selection], Chunk[String]] | ||
val groupsCache = scala.collection.mutable.Map.empty[Set[SelectedField], Chunk[Set[SelectedField]]] | ||
|
||
def sameResponseShapeByName(context: Context, parentType: __Type, set: Iterable[Selection]): Chunk[String] = | ||
shapeCache.get(set) match { | ||
case Some(value) => value | ||
case None => | ||
val fields = FieldMap(context, parentType, set) | ||
val res = Chunk.fromIterable(fields.flatMap { case (name, values) => | ||
cross(values).flatMap { case (f1, f2) => | ||
if (doTypesConflict(f1.fieldDef.`type`(), f2.fieldDef.`type`())) { | ||
Chunk( | ||
s"$name has conflicting types: ${f1.parentType.name.getOrElse("")}.${f1.fieldDef.name} and ${f2.parentType.name | ||
.getOrElse("")}.${f2.fieldDef.name}. Try using an alias." | ||
) | ||
} else | ||
sameResponseShapeByName(context, parentType, f1.selection.selectionSet ++ f2.selection.selectionSet) | ||
} | ||
}) | ||
shapeCache.update(set, res) | ||
res | ||
} | ||
|
||
def sameForCommonParentsByName(context: Context, parentType: __Type, set: Iterable[Selection]): Chunk[String] = | ||
parentsCache.get(set) match { | ||
case Some(value) => value | ||
case None => | ||
val fields = FieldMap(context, parentType, set) | ||
val res = Chunk.fromIterable(fields.flatMap({ case (name, fields) => | ||
groupByCommonParents(context, parentType, fields).flatMap { group => | ||
val merged = group.flatMap(_.selection.selectionSet) | ||
requireSameNameAndArguments(group) ++ sameForCommonParentsByName(context, parentType, merged) | ||
} | ||
})) | ||
parentsCache.update(set, res) | ||
res | ||
} | ||
|
||
def doTypesConflict(t1: __Type, t2: __Type): Boolean = | ||
if (isNonNull(t1)) | ||
if (isNonNull(t2)) (t1.ofType, t2.ofType).mapN((p1, p2) => doTypesConflict(p1, p2)).getOrElse(true) | ||
else true | ||
else if (isNonNull(t2)) | ||
true | ||
else if (isListType(t1)) | ||
if (isListType(t2)) (t1.ofType, t2.ofType).mapN((p1, p2) => doTypesConflict(p1, p2)).getOrElse(true) | ||
else true | ||
else if (isListType(t2)) | ||
true | ||
else if (isLeafType(t1) && isLeafType(t2)) { | ||
t1.name != t2.name | ||
} else if (!isComposite(t1) || !isComposite(t2)) | ||
true | ||
else | ||
false | ||
|
||
def requireSameNameAndArguments(fields: Set[SelectedField]) = | ||
cross(fields).flatMap { case (f1, f2) => | ||
if (f1.fieldDef.name != f2.fieldDef.name) { | ||
List( | ||
s"${f1.parentType.name.getOrElse("")}.${f1.fieldDef.name} and ${f2.parentType.name.getOrElse("")}.${f2.fieldDef.name} are different fields." | ||
) | ||
} else if (f1.selection.arguments != f2.selection.arguments) | ||
List(s"${f1.fieldDef.name} and ${f2.fieldDef.name} have different arguments") | ||
else List() | ||
} | ||
|
||
def groupByCommonParents( | ||
context: Context, | ||
parentType: __Type, | ||
fields: Set[SelectedField] | ||
): Chunk[Set[SelectedField]] = | ||
groupsCache.get(fields) match { | ||
case Some(value) => value | ||
case None => | ||
val abstractGroup = fields.collect { | ||
case field if !isConcrete(field.parentType) => field | ||
} | ||
|
||
val concreteGroups = mutable.Map.empty[String, Set[SelectedField]] | ||
|
||
fields | ||
.foreach({ | ||
case field @ SelectedField( | ||
__Type(_, Some(name), _, _, _, _, _, _, _, _, _), | ||
_, | ||
_ | ||
) if isConcrete(field.parentType) => | ||
val value = concreteGroups.get(name).map(_ + field).getOrElse(Set(field)) | ||
concreteGroups.update(name, value) | ||
case _ => () | ||
}) | ||
|
||
val res = | ||
if (concreteGroups.size < 1) Chunk(fields) | ||
else Chunk.fromIterable(concreteGroups.values.map(_ ++ abstractGroup)) | ||
|
||
groupsCache.update(fields, res) | ||
res | ||
} | ||
|
||
val fields = FieldMap( | ||
context, | ||
parentType, | ||
selectionSet | ||
) | ||
|
||
val conflicts = sameResponseShapeByName(context, parentType, selectionSet) ++ | ||
sameForCommonParentsByName(context, parentType, selectionSet) | ||
|
||
IO.whenCase(conflicts) { case Chunk(head, _*) => | ||
IO.fail(ValidationError(head, "")) | ||
} | ||
} | ||
} |
Oops, something went wrong.