Repository: grafbase/grafbase
Pull request #3136: feat(engine): @is binding & initial validation
Author: login: Finistere
Created: 2025-05-06T18:48:53Z | Merged: 2025-05-07T09:45:25Z

I'm binding the `FieldSelectionMap` in the engine now. Validation isn't
finished though, especially for its original use in `@lookup`. But that should be enough for me to get started on the computed fields



Diff:
diff --git a/Cargo.lock b/Cargo.lock
index 3638ed8461..f6a263fa31 100644
--- a/Cargo.lock
+++ b/Cargo.lock
@@ -2866,6 +2866,7 @@ dependencies = [
  "grafbase-workspace-hack",
  "insta",
  "itertools 0.14.0",
+ "pretty_assertions",
  "winnow",
 ]
 
@@ -2954,6 +2955,7 @@ dependencies = [
  "blake3",
  "cynic-parser",
  "cynic-parser-deser",
+ "engine-field-selection-map",
  "engine-id-derives",
  "engine-id-newtypes",
  "engine-walker",
diff --git a/crates/engine/field-selection-map/Cargo.toml b/crates/engine/field-selection-map/Cargo.toml
index bab653c1a6..3d4144a5e9 100644
--- a/crates/engine/field-selection-map/Cargo.toml
+++ b/crates/engine/field-selection-map/Cargo.toml
@@ -12,5 +12,6 @@ winnow.workspace = true
 
 [dev-dependencies]
 insta.workspace = true
+pretty_assertions.workspace = true
 # Not sure how to make it work with Hakari, it'll add debug feature everywhere with this...
 # winnow = { workspace = true, features = ["debug"] }
diff --git a/crates/engine/field-selection-map/src/model.rs b/crates/engine/field-selection-map/src/model.rs
index dfb584d8ef..15def046a8 100644
--- a/crates/engine/field-selection-map/src/model.rs
+++ b/crates/engine/field-selection-map/src/model.rs
@@ -76,14 +76,20 @@ impl fmt::Display for PathSegment<'_> {
 
 /// Represents a path to a field, possibly nested.
 #[derive(Debug, Clone, PartialEq, Eq, Hash)]
-pub struct Path<'a>(pub Vec<PathSegment<'a>>);
+pub struct Path<'a> {
+    pub ty: Option<&'a str>,
+    pub segments: Vec<PathSegment<'a>>,
+}
 
 impl fmt::Display for Path<'_> {
     fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
+        if let Some(ty) = &self.ty {
+            write!(f, "<{}>.", ty)?
+        };
         write!(
             f,
             "{}",
-            self.0
+            self.segments
                 .iter()
                 .format_with(".", |segment, f| f(&format_args!("{}", segment)))
         )
diff --git a/crates/engine/field-selection-map/src/parser.rs b/crates/engine/field-selection-map/src/parser.rs
index d5e9e36b57..d25725a974 100644
--- a/crates/engine/field-selection-map/src/parser.rs
+++ b/crates/engine/field-selection-map/src/parser.rs
@@ -88,9 +88,11 @@ fn selected_list_value<'a>(input: &mut &'a str) -> Result<SelectedListValue<'a>>
 
 /// Parses a Path: segment1.segment2.segmentN
 fn path<'a>(input: &mut &'a str) -> Result<Path<'a>> {
-    separated(1.., path_segment, ws('.'))
-        .map(Path)
-        .context(StrContext::Label("path"))
+    (
+        opt(delimited('<', ws(name), ('>', multispace0, '.')).context(StrContext::Label("type name"))),
+        separated(1.., path_segment, ws('.')),
+    )
+        .map(|(ty, segments)| Path { ty, segments })
         .parse_next(input)
 }
 
@@ -132,6 +134,7 @@ where
 #[cfg(test)]
 mod tests {
     use super::*;
+    use pretty_assertions::assert_eq;
 
     #[test]
     fn parse_name() {
@@ -172,20 +175,23 @@ mod tests {
         let result = path.parse_next(&mut input).unwrap();
         assert_eq!(
             result,
-            Path(vec![
-                PathSegment {
-                    field: "field1",
-                    ty: None,
-                },
-                PathSegment {
-                    field: "field2",
-                    ty: Some("SomeType"),
-                },
-                PathSegment {
-                    field: "field3",
-                    ty: None,
-                },
-            ])
+            Path {
+                ty: None,
+                segments: vec![
+                    PathSegment {
+                        field: "field1",
+                        ty: None,
+                    },
+                    PathSegment {
+                        field: "field2",
+                        ty: Some("SomeType"),
+                    },
+                    PathSegment {
+                        field: "field3",
+                        ty: None,
+                    },
+                ],
+            }
         );
         assert_eq!(input, "");
     }
@@ -194,10 +200,13 @@ mod tests {
     fn test_simple_path() {
         let input = " simple_field ";
         let expected = SelectedValue {
-            alternatives: vec![SelectedValueEntry::Path(Path(vec![PathSegment {
-                field: "simple_field",
+            alternatives: vec![SelectedValueEntry::Path(Path {
                 ty: None,
-            }]))],
+                segments: vec![PathSegment {
+                    field: "simple_field",
+                    ty: None,
+                }],
+            })],
         };
         match parse(input) {
             Ok(result) => assert_eq!(result, expected),
@@ -209,16 +218,19 @@ mod tests {
     fn test_nested_path() {
         let input = "object.nested_field";
         let expected = SelectedValue {
-            alternatives: vec![SelectedValueEntry::Path(Path(vec![
-                PathSegment {
-                    field: "object",
-                    ty: None,
-                },
-                PathSegment {
-                    field: "nested_field",
-                    ty: None,
-                },
-            ]))],
+            alternatives: vec![SelectedValueEntry::Path(Path {
+                ty: None,
+                segments: vec![
+                    PathSegment {
+                        field: "object",
+                        ty: None,
+                    },
+                    PathSegment {
+                        field: "nested_field",
+                        ty: None,
+                    },
+                ],
+            })],
         };
         match parse(input) {
             Ok(result) => assert_eq!(result, expected),
@@ -230,10 +242,13 @@ mod tests {
     fn test_path_with_type() {
         let input = "field< MyType >";
         let expected = SelectedValue {
-            alternatives: vec![SelectedValueEntry::Path(Path(vec![PathSegment {
-                field: "field",
-                ty: Some("MyType"),
-            }]))],
+            alternatives: vec![SelectedValueEntry::Path(Path {
+                ty: None,
+                segments: vec![PathSegment {
+                    field: "field",
+                    ty: Some("MyType"),
+                }],
+            })],
         };
         match parse(input) {
             Ok(result) => assert_eq!(result, expected),
@@ -245,20 +260,58 @@ mod tests {
     fn test_nested_path_with_type() {
         let input = "obj<User>.address<Addr >. street";
         let expected = SelectedValue {
-            alternatives: vec![SelectedValueEntry::Path(Path(vec![
-                PathSegment {
-                    field: "obj",
-                    ty: Some("User"),
-                },
-                PathSegment {
-                    field: "address",
-                    ty: Some("Addr"),
-                },
-                PathSegment {
-                    field: "street",
-                    ty: None,
-                },
-            ]))],
+            alternatives: vec![SelectedValueEntry::Path(Path {
+                ty: None,
+                segments: vec![
+                    PathSegment {
+                        field: "obj",
+                        ty: Some("User"),
+                    },
+                    PathSegment {
+                        field: "address",
+                        ty: Some("Addr"),
+                    },
+                    PathSegment {
+                        field: "street",
+                        ty: None,
+                    },
+                ],
+            })],
+        };
+        match parse(input) {
+            Ok(result) => assert_eq!(result, expected),
+            Err(e) => panic!("{}", e),
+        }
+    }
+
+    #[test]
+    fn test_alternative_type_conditions() {
+        let input = "{ bookId: <Book>.id } | { movieId: <Movie>.id }";
+        let expected = SelectedValue {
+            alternatives: vec![
+                SelectedValueEntry::Object(SelectedObjectValue {
+                    fields: vec![SelectedObjectField {
+                        key: "bookId",
+                        value: Some(SelectedValue {
+                            alternatives: vec![SelectedValueEntry::Path(Path {
+                                ty: Some("Book"),
+                                segments: vec![PathSegment { field: "id", ty: None }],
+                            })],
+                        }),
+                    }],
+                }),
+                SelectedValueEntry::Object(SelectedObjectValue {
+                    fields: vec![SelectedObjectField {
+                        key: "movieId",
+                        value: Some(SelectedValue {
+                            alternatives: vec![SelectedValueEntry::Path(Path {
+                                ty: Some("Movie"),
+                                segments: vec![PathSegment { field: "id", ty: None }],
+                            })],
+                        }),
+                    }],
+                }),
+            ],
         };
         match parse(input) {
             Ok(result) => assert_eq!(result, expected),
@@ -298,25 +351,31 @@ mod tests {
                     SelectedObjectField {
                         key: "key1",
                         value: Some(SelectedValue {
-                            alternatives: vec![SelectedValueEntry::Path(Path(vec![PathSegment {
-                                field: "value1",
+                            alternatives: vec![SelectedValueEntry::Path(Path {
                                 ty: None,
-                            }]))],
+                                segments: vec![PathSegment {
+                                    field: "value1",
+                                    ty: None,
+                                }],
+                            })],
                         }),
                     },
                     SelectedObjectField {
                         key: "key2",
                         value: Some(SelectedValue {
-                            alternatives: vec![SelectedValueEntry::Path(Path(vec![
-                                PathSegment {
-                                    field: "nested",
-                                    ty: None,
-                                },
-                                PathSegment {
-                                    field: "path",
-                                    ty: None,
-                                },
-                            ]))],
+                            alternatives: vec![SelectedValueEntry::Path(Path {
+                                ty: None,
+                                segments: vec![
+                                    PathSegment {
+                                        field: "nested",
+                                        ty: None,
+                                    },
+                                    PathSegment {
+                                        field: "path",
+                                        ty: None,
+                                    },
+                                ],
+                            })],
                         }),
                     },
                 ],
@@ -333,10 +392,13 @@ mod tests {
         let input = " data.{ field1 field2 : sub.value } ";
         let expected = SelectedValue {
             alternatives: vec![SelectedValueEntry::ObjectWithPath {
-                path: Path(vec![PathSegment {
-                    field: "data",
+                path: Path {
                     ty: None,
-                }]),
+                    segments: vec![PathSegment {
+                        field: "data",
+                        ty: None,
+                    }],
+                },
                 object: SelectedObjectValue {
                     fields: vec![
                         SelectedObjectField {
@@ -346,13 +408,16 @@ mod tests {
                         SelectedObjectField {
                             key: "field2",
                             value: Some(SelectedValue {
-                                alternatives: vec![SelectedValueEntry::Path(Path(vec![
-                                    PathSegment { field: "sub", ty: None },
-                                    PathSegment {
-                                        field: "value",
-                                        ty: None,
-                                    },
-                                ]))],
+                                alternatives: vec![SelectedValueEntry::Path(Path {
+                                    ty: None,
+                                    segments: vec![
+                                        PathSegment { field: "sub", ty: None },
+                                        PathSegment {
+                                            field: "value",
+                                            ty: None,
+                                        },
+                                    ],
+                                })],
                             }),
                         },
                     ],
@@ -370,17 +435,26 @@ mod tests {
         let input = " items [ name | id ] ";
         let expected = SelectedValue {
             alternatives: vec![SelectedValueEntry::ListWithPath {
-                path: Path(vec![PathSegment {
-                    field: "items",
+                path: Path {
                     ty: None,
-                }]),
+                    segments: vec![PathSegment {
+                        field: "items",
+                        ty: None,
+                    }],
+                },
                 list: SelectedListValue(SelectedValue {
                     alternatives: vec![
-                        SelectedValueEntry::Path(Path(vec![PathSegment {
-                            field: "name",
+                        SelectedValueEntry::Path(Path {
                             ty: None,
-                        }])),
-                        SelectedValueEntry::Path(Path(vec![PathSegment { field: "id", ty: None }])),
+                            segments: vec![PathSegment {
+                                field: "name",
+                                ty: None,
+                            }],
+                        }),
+                        SelectedValueEntry::Path(Path {
+                            ty: None,
+                            segments: vec![PathSegment { field: "id", ty: None }],
+                        }),
                     ],
                 }),
             }],
@@ -396,38 +470,50 @@ mod tests {
         let input = " path1 | path2 < Type > . sub | { key : val } | path3 [ item ]";
         let expected = SelectedValue {
             alternatives: vec![
-                SelectedValueEntry::Path(Path(vec![PathSegment {
-                    field: "path1",
+                SelectedValueEntry::Path(Path {
                     ty: None,
-                }])),
-                SelectedValueEntry::Path(Path(vec![
-                    PathSegment {
-                        field: "path2",
-                        ty: Some("Type"),
-                    },
-                    PathSegment { field: "sub", ty: None },
-                ])),
+                    segments: vec![PathSegment {
+                        field: "path1",
+                        ty: None,
+                    }],
+                }),
+                SelectedValueEntry::Path(Path {
+                    ty: None,
+                    segments: vec![
+                        PathSegment {
+                            field: "path2",
+                            ty: Some("Type"),
+                        },
+                        PathSegment { field: "sub", ty: None },
+                    ],
+                }),
                 SelectedValueEntry::Object(SelectedObjectValue {
                     fields: vec![SelectedObjectField {
                         key: "key",
                         value: Some(SelectedValue {
-                            alternatives: vec![SelectedValueEntry::Path(Path(vec![PathSegment {
-                                field: "val",
+                            alternatives: vec![SelectedValueEntry::Path(Path {
                                 ty: None,
-                            }]))],
+                                segments: vec![PathSegment { field: "val", ty: None }],
+                            })],
                         }),
                     }],
                 }),
                 SelectedValueEntry::ListWithPath {
-                    path: Path(vec![PathSegment {
-                        field: "path3",
+                    path: Path {
                         ty: None,
-                    }]),
+                        segments: vec![PathSegment {
+                            field: "path3",
+                            ty: None,
+                        }],
+                    },
                     list: SelectedListValue(SelectedValue {
-                        alternatives: vec![SelectedValueEntry::Path(Path(vec![PathSegment {
-                            field: "item",
+                        alternatives: vec![SelectedValueEntry::Path(Path {
                             ty: None,
-                        }]))],
+                            segments: vec![PathSegment {
+                                field: "item",
+                                ty: None,
+                            }],
+                        })],
                     }),
                 },
             ],
@@ -445,27 +531,36 @@ mod tests {
         let expected = SelectedValue {
             alternatives: vec![
                 SelectedValueEntry::ListWithPath {
-                    path: Path(vec![
-                        PathSegment {
-                            field: "path1",
-                            ty: Some("T1"),
-                        },
-                        PathSegment {
-                            field: "field2",
-                            ty: None,
-                        },
-                    ]),
+                    path: Path {
+                        ty: None,
+                        segments: vec![
+                            PathSegment {
+                                field: "path1",
+                                ty: Some("T1"),
+                            },
+                            PathSegment {
+                                field: "field2",
+                                ty: None,
+                            },
+                        ],
+                    },
                     list: SelectedListValue(SelectedValue {
                         alternatives: vec![
-                            SelectedValueEntry::Path(Path(vec![PathSegment {
-                                field: "item1",
+                            SelectedValueEntry::Path(Path {
                                 ty: None,
-                            }])),
+                                segments: vec![PathSegment {
+                                    field: "item1",
+                                    ty: None,
+                                }],
+                            }),
                             SelectedValueEntry::ObjectWithPath {
-                                path: Path(vec![PathSegment {
-                                    field: "item2",
+                                path: Path {
                                     ty: None,
-                                }]),
+                                    segments: vec![PathSegment {
+                                        field: "item2",
+                                        ty: None,
+                                    }],
+                                },
                                 object: SelectedObjectValue {
                                     fields: vec![SelectedObjectField {
                                         key: "subkey",
@@ -481,10 +576,13 @@ mod tests {
                         SelectedObjectField {
                             key: "key1",
                             value: Some(SelectedValue {
-                                alternatives: vec![SelectedValueEntry::Path(Path(vec![PathSegment {
-                                    field: "val1",
+                                alternatives: vec![SelectedValueEntry::Path(Path {
                                     ty: None,
-                                }]))],
+                                    segments: vec![PathSegment {
+                                        field: "val1",
+                                        ty: None,
+                                    }],
+                                })],
                             }),
                         },
                         SelectedObjectField {
@@ -517,10 +615,13 @@ mod tests {
         let input = "path.{}";
         let expected = SelectedValue {
             alternatives: vec![SelectedValueEntry::ObjectWithPath {
-                path: Path(vec![PathSegment {
-                    field: "path",
+                path: Path {
                     ty: None,
-                }]),
+                    segments: vec![PathSegment {
+                        field: "path",
+                        ty: None,
+                    }],
+                },
                 object: SelectedObjectValue { fields: vec![] },
             }],
         };
@@ -536,29 +637,44 @@ mod tests {
         let input = " a | b.{ c: d [ e | { f : g.h<T> } ] } ";
         let expected = SelectedValue {
             alternatives: vec![
-                SelectedValueEntry::Path(Path(vec![PathSegment { field: "a", ty: None }])),
+                SelectedValueEntry::Path(Path {
+                    ty: None,
+                    segments: vec![PathSegment { field: "a", ty: None }],
+                }),
                 SelectedValueEntry::ObjectWithPath {
-                    path: Path(vec![PathSegment { field: "b", ty: None }]),
+                    path: Path {
+                        ty: None,
+                        segments: vec![PathSegment { field: "b", ty: None }],
+                    },
                     object: SelectedObjectValue {
                         fields: vec![SelectedObjectField {
                             key: "c",
                             value: Some(SelectedValue {
                                 alternatives: vec![SelectedValueEntry::ListWithPath {
-                                    path: Path(vec![PathSegment { field: "d", ty: None }]),
+                                    path: Path {
+                                        ty: None,
+                                        segments: vec![PathSegment { field: "d", ty: None }],
+                                    },
                                     list: SelectedListValue(SelectedValue {
                                         alternatives: vec![
-                                            SelectedValueEntry::Path(Path(vec![PathSegment { field: "e", ty: None }])),
+                                            SelectedValueEntry::Path(Path {
+                                                ty: None,
+                                                segments: vec![PathSegment { field: "e", ty: None }],
+                                            }),
                                             SelectedValueEntry::Object(SelectedObjectValue {
                                                 fields: vec![SelectedObjectField {
                                                     key: "f",
                                                     value: Some(SelectedValue {
-                                                        alternatives: vec![SelectedValueEntry::Path(Path(vec![
-                                                            PathSegment { field: "g", ty: None },
-                                                            PathSegment {
-                                                                field: "h",
-                                                                ty: Some("T"),
-                                                            },
-                                                        ]))],
+                                                        alternatives: vec![SelectedValueEntry::Path(Path {
+                                                            ty: None,
+                                                            segments: vec![
+                                                                PathSegment { field: "g", ty: None },
+                                                                PathSegment {
+                                                                    field: "h",
+                                                                    ty: Some("T"),
+                                                                },
+                                                            ],
+                                                        })],
                                                     }),
                                                 }],
                                             }),
@@ -586,25 +702,31 @@ mod tests {
                     SelectedObjectField {
                         key: "key1",
                         value: Some(SelectedValue {
-                            alternatives: vec![SelectedValueEntry::Path(Path(vec![PathSegment {
-                                field: "value1",
+                            alternatives: vec![SelectedValueEntry::Path(Path {
                                 ty: None,
-                            }]))],
+                                segments: vec![PathSegment {
+                                    field: "value1",
+                                    ty: None,
+                                }],
+                            })],
                         }),
                     },
                     SelectedObjectField {
                         key: "key2",
                         value: Some(SelectedValue {
-                            alternatives: vec![SelectedValueEntry::Path(Path(vec![
-                                PathSegment {
-                                    field: "nested",
-                                    ty: None,
-                                },
-                                PathSegment {
-                                    field: "path",
-                                    ty: None,
-                                },
-                            ]))],
+                            alternatives: vec![SelectedValueEntry::Path(Path {
+                                ty: None,
+                                segments: vec![
+                                    PathSegment {
+                                        field: "nested",
+                                        ty: None,
+                                    },
+                                    PathSegment {
+                                        field: "path",
+                                        ty: None,
+                                    },
+                                ],
+                            })],
                         }),
                     },
                 ],
@@ -620,20 +742,23 @@ mod tests {
     fn test_underscore_names() {
         let input = "_leading._deep_._member";
         let expected = SelectedValue {
-            alternatives: vec![SelectedValueEntry::Path(Path(vec![
-                PathSegment {
-                    field: "_leading",
-                    ty: None,
-                },
-                PathSegment {
-                    field: "_deep_",
-                    ty: None,
-                },
-                PathSegment {
-                    field: "_member",
-                    ty: None,
-                },
-            ]))],
+            alternatives: vec![SelectedValueEntry::Path(Path {
+                ty: None,
+                segments: vec![
+                    PathSegment {
+                        field: "_leading",
+                        ty: None,
+                    },
+                    PathSegment {
+                        field: "_deep_",
+                        ty: None,
+                    },
+                    PathSegment {
+                        field: "_member",
+                        ty: None,
+                    },
+                ],
+            })],
         };
         match parse(input) {
             Ok(result) => assert_eq!(result, expected),
@@ -642,10 +767,10 @@ mod tests {
 
         let input = "_"; // Single underscore
         let expected = SelectedValue {
-            alternatives: vec![SelectedValueEntry::Path(Path(vec![PathSegment {
-                field: "_",
+            alternatives: vec![SelectedValueEntry::Path(Path {
                 ty: None,
-            }]))],
+                segments: vec![PathSegment { field: "_", ty: None }],
+            })],
         };
         match parse(input) {
             Ok(result) => assert_eq!(result, expected),
@@ -668,10 +793,13 @@ mod tests {
                     SelectedObjectField {
                         key: "key",
                         value: Some(SelectedValue {
-                            alternatives: vec![SelectedValueEntry::Path(Path(vec![PathSegment {
-                                field: "value",
+                            alternatives: vec![SelectedValueEntry::Path(Path {
                                 ty: None,
-                            }]))],
+                                segments: vec![PathSegment {
+                                    field: "value",
+                                    ty: None,
+                                }],
+                            })],
                         }),
                     },
                 ],
@@ -933,4 +1061,52 @@ mod tests {
             }
         }
     }
+
+    #[test]
+    fn test_path_with_start_type() {
+        let input = "<Type>.field1.field2";
+        let expected = SelectedValue {
+            alternatives: vec![SelectedValueEntry::Path(Path {
+                ty: Some("Type"),
+                segments: vec![
+                    PathSegment {
+                        field: "field1",
+                        ty: None,
+                    },
+                    PathSegment {
+                        field: "field2",
+                        ty: None,
+                    },
+                ],
+            })],
+        };
+        match parse(input) {
+            Ok(result) => assert_eq!(result, expected),
+            Err(e) => panic!("{}", e),
+        }
+    }
+
+    #[test]
+    fn test_path_with_start_type_and_segment_type() {
+        let input = "<Type>.field1<InnerType>.field2";
+        let expected = SelectedValue {
+            alternatives: vec![SelectedValueEntry::Path(Path {
+                ty: Some("Type"),
+                segments: vec![
+                    PathSegment {
+                        field: "field1",
+                        ty: Some("InnerType"),
+                    },
+                    PathSegment {
+                        field: "field2",
+                        ty: None,
+                    },
+                ],
+            })],
+        };
+        match parse(input) {
+            Ok(result) => assert_eq!(result, expected),
+            Err(e) => panic!("{}", e),
+        }
+    }
 }
diff --git a/crates/engine/operation/src/bind/coercion/query.rs b/crates/engine/operation/src/bind/coercion/query.rs
index 0818a65f2e..6fd933d948 100644
--- a/crates/engine/operation/src/bind/coercion/query.rs
+++ b/crates/engine/operation/src/bind/coercion/query.rs
@@ -433,12 +433,19 @@ impl<'schema> QueryValueCoercionContext<'_, 'schema, '_> {
 }
 
 /// Determines whether a variable is compatible with the expected type
-fn are_type_compatibles(definition: Type<'_>, used_as: Type<'_>) -> bool {
-    (definition.definition_id == used_as.definition_id || definition.definition().as_composite_type().zip(used_as.definition().as_composite_type()).map(|(def, used)| def.is_subset_of(used)).unwrap_or_default())
-            // if not a list, the current type can be coerced into the proper list wrapping.
-            && (!definition.wrapping.is_list()
-                || definition.wrapping.list_wrappings().len() == used_as.wrapping.list_wrappings().len())
-            && (used_as.wrapping.is_nullable() || definition.wrapping.is_required())
+fn are_type_compatibles(ty: Type<'_>, used_as: Type<'_>) -> bool {
+    (ty.definition_id == used_as.definition_id
+        || ty
+            .definition()
+            .as_composite_type()
+            .zip(used_as.definition().as_composite_type())
+            .map(|(def, used)| def.is_subset_of(used))
+            .unwrap_or_default())
+        && (used_as.wrapping.is_equal_or_more_lenient_than(ty.wrapping)
+                // if not a list, the current type can be coerced into the proper list wrapping.
+            || (
+                !ty.wrapping.is_list() && (used_as.wrapping.is_nullable() || ty.wrapping.is_required())
+            ))
 }
 
 fn can_coerce_to_int(float: f64) -> bool {
diff --git a/crates/engine/schema/Cargo.toml b/crates/engine/schema/Cargo.toml
index 8847940dac..508b63e1be 100644
--- a/crates/engine/schema/Cargo.toml
+++ b/crates/engine/schema/Cargo.toml
@@ -19,6 +19,7 @@ cynic-parser.workspace = true
 cynic-parser-deser.workspace = true
 extension-catalog.workspace = true
 federated-graph.workspace = true
+field-selection-map = { path = "../field-selection-map", package = "engine-field-selection-map" }
 fxhash.workspace = true
 gateway-config.workspace = true
 grafbase-workspace-hack.workspace = true
diff --git a/crates/engine/schema/src/builder/coerce/field_selection_map/bind.rs b/crates/engine/schema/src/builder/coerce/field_selection_map/bind.rs
new file mode 100644
index 0000000000..d6257f73ab
--- /dev/null
+++ b/crates/engine/schema/src/builder/coerce/field_selection_map/bind.rs
@@ -0,0 +1,37 @@
+use crate::FieldDefinitionId;
+
+#[derive(Debug, Clone, PartialEq)]
+pub(crate) struct BoundSelectedValue<Id> {
+    pub alternatives: Vec<BoundSelectedValueEntry<Id>>,
+}
+
+#[derive(Debug, Clone, PartialEq)]
+pub(crate) enum BoundSelectedValueEntry<Id> {
+    Path(BoundPath),
+    ObjectWithPath {
+        path: BoundPath,
+        object: BoundSelectedObjectValue<Id>,
+    },
+    ListWithPath {
+        path: BoundPath,
+        list: BoundSelectedListValue<Id>,
+    },
+    Object(BoundSelectedObjectValue<Id>),
+}
+
+#[derive(Debug, Clone, PartialEq, Eq, Hash)]
+pub(crate) struct BoundPath(pub Vec<FieldDefinitionId>);
+
+#[derive(Debug, Clone, PartialEq)]
+pub(crate) struct BoundSelectedObjectValue<Id> {
+    pub fields: Vec<BoundSelectedObjectField<Id>>,
+}
+
+#[derive(Debug, Clone, PartialEq)]
+pub(crate) struct BoundSelectedObjectField<Id> {
+    pub field: Id,
+    pub value: Option<BoundSelectedValue<Id>>,
+}
+
+#[derive(Debug, Clone, PartialEq)]
+pub(crate) struct BoundSelectedListValue<Id>(pub BoundSelectedValue<Id>);
diff --git a/crates/engine/schema/src/builder/coerce/field_selection_map/mod.rs b/crates/engine/schema/src/builder/coerce/field_selection_map/mod.rs
new file mode 100644
index 0000000000..5f1c3ee283
--- /dev/null
+++ b/crates/engine/schema/src/builder/coerce/field_selection_map/mod.rs
@@ -0,0 +1,353 @@
+mod bind;
+mod target;
+
+pub(crate) use bind::*;
+use std::collections::VecDeque;
+use target::*;
+
+use ::field_selection_map::*;
+use wrapping::Wrapping;
+
+use crate::{
+    CompositeTypeId, EntityDefinitionId, FieldDefinitionId, InputValueDefinitionId, TypeDefinitionId, TypeRecord,
+    builder::GraphBuilder,
+};
+
+use super::field_set::is_disjoint;
+
+impl GraphBuilder<'_> {
+    pub(crate) fn parse_field_selection_map_for_argument(
+        &mut self,
+        output: EntityDefinitionId,
+        field_id: FieldDefinitionId,
+        argument_id: InputValueDefinitionId,
+        field_selection_map: &str,
+    ) -> Result<BoundSelectedValue<InputValueDefinitionId>, String> {
+        let selected_value = SelectedValue::try_from(field_selection_map)?;
+        let wrapping = self.graph[argument_id].ty_record.wrapping;
+        bind_selected_value(
+            self,
+            (output.into(), Wrapping::required()),
+            (Input::Argument { field_id, argument_id }, wrapping),
+            selected_value,
+        )
+    }
+
+    pub(crate) fn parse_field_selection_map_for_field(
+        &mut self,
+        output: EntityDefinitionId,
+        target: FieldDefinitionId,
+        field_selection_map: &str,
+    ) -> Result<BoundSelectedValue<FieldDefinitionId>, String> {
+        let selected_value = SelectedValue::try_from(field_selection_map)?;
+        let wrapping = self.graph[target].ty_record.wrapping;
+        bind_selected_value(
+            self,
+            (output.into(), Wrapping::required()),
+            (target, wrapping),
+            selected_value,
+        )
+    }
+}
+
+fn bind_selected_value<T: Target>(
+    ctx: &mut GraphBuilder<'_>,
+    output: (CompositeTypeId, Wrapping),
+    target: (T, Wrapping),
+    selected_value: SelectedValue<'_>,
+) -> Result<BoundSelectedValue<T::Id>, String> {
+    let alternatives = selected_value
+        .alternatives
+        .into_iter()
+        .map(|entry| bind_selected_value_entry(ctx, output, target, entry))
+        .collect::<Result<Vec<_>, _>>()?;
+    Ok(BoundSelectedValue { alternatives })
+}
+
+fn bind_selected_value_entry<T: Target>(
+    ctx: &mut GraphBuilder<'_>,
+    output: (CompositeTypeId, Wrapping),
+    target: (T, Wrapping),
+    selected_value: SelectedValueEntry<'_>,
+) -> Result<BoundSelectedValueEntry<T::Id>, String> {
+    match selected_value {
+        SelectedValueEntry::Path(path) => {
+            let (path, _) = bind_path(ctx, output, path)?;
+            let last = *path.0.last().unwrap();
+            ensure_type_compatibility(ctx, target, last)?;
+            Ok(BoundSelectedValueEntry::Path(path))
+        }
+        SelectedValueEntry::ObjectWithPath { path, object } => {
+            let (path, (output, output_wrapping)) = bind_path(ctx, output, path)?;
+            let output = output
+                .as_composite_type()
+                .ok_or_else(|| format!("Type {} does not have any fields", ctx[ctx.definition_name_id(output)]))?;
+            let object = bind_selected_object_value(ctx, (output, output_wrapping), target, object)?;
+            Ok(BoundSelectedValueEntry::ObjectWithPath { path, object })
+        }
+        SelectedValueEntry::ListWithPath { path, list } => {
+            let (path, (output, output_wrapping)) = bind_path(ctx, output, path)?;
+            let output = output
+                .as_composite_type()
+                .ok_or_else(|| format!("Type {} does not have any fields", ctx[ctx.definition_name_id(output)]))?;
+            let list = bind_selected_list_value(ctx, (output, output_wrapping), target, list)?;
+            Ok(BoundSelectedValueEntry::ListWithPath { path, list })
+        }
+        SelectedValueEntry::Object(object) => {
+            let object = bind_selected_object_value(ctx, output, target, object)?;
+            Ok(BoundSelectedValueEntry::Object(object))
+        }
+    }
+}
+
+fn bind_path(
+    ctx: &mut GraphBuilder<'_>,
+    (output, mut output_wrapping): (CompositeTypeId, Wrapping),
+    path: Path<'_>,
+) -> Result<(BoundPath, (TypeDefinitionId, Wrapping)), String> {
+    if output_wrapping.is_list() {
+        return Err(format!(
+            "Cannot select a field from {}, it's a list",
+            ctx.type_name(TypeRecord {
+                definition_id: output.into(),
+                wrapping: output_wrapping
+            })
+        ));
+    }
+    let mut output: TypeDefinitionId = if let Some(ty) = path.ty {
+        bind_type_condition(ctx, output, ty)?.into()
+    } else {
+        output.into()
+    };
+
+    let mut out = Vec::new();
+    let mut segments = VecDeque::from(path.segments);
+    while let Some(segment) = segments.pop_front() {
+        let field_ids = match output {
+            TypeDefinitionId::Interface(id) => ctx.graph[id].field_ids,
+            TypeDefinitionId::Object(id) => ctx.graph[id].field_ids,
+            _ => {
+                return Err(format!(
+                    "Type {} does not have any fields",
+                    ctx[ctx.definition_name_id(output)]
+                ));
+            }
+        };
+        let field_id = field_ids
+            .into_iter()
+            .find(|id| ctx[ctx.graph[*id].name_id] == segment.field)
+            .ok_or_else(|| {
+                format!(
+                    "Type {} does not have a field named '{}'",
+                    ctx[ctx.definition_name_id(output)],
+                    segment.field
+                )
+            })?;
+        out.push(field_id);
+        output = ctx.graph[field_id].ty_record.definition_id;
+        let parent_output_is_nullable = output_wrapping.is_nullable();
+        output_wrapping = ctx.graph[field_id].ty_record.wrapping;
+        if parent_output_is_nullable {
+            output_wrapping = output_wrapping.without_non_null();
+        }
+
+        if let Some(ty) = segment.ty {
+            if let Some(id) = output.as_composite_type() {
+                output = bind_type_condition(ctx, id, ty)?.into();
+            } else {
+                return Err(format!(
+                    "Field '{}' doesn't return an object, interface or union and thus cannot have type condition '{}'",
+                    ctx[ctx.graph[field_id].name_id], ty
+                ));
+            }
+        }
+    }
+
+    Ok((BoundPath(out), (output, output_wrapping)))
+}
+
+fn bind_type_condition(
+    ctx: &mut GraphBuilder<'_>,
+    parent: CompositeTypeId,
+    ty: &str,
+) -> Result<EntityDefinitionId, String> {
+    let Ok(ty_id) = ctx
+        .graph
+        .type_definitions_ordered_by_name
+        .binary_search_by(|probe| ctx[ctx.definition_name_id(*probe)].as_str().cmp(ty))
+        .map(|ix| ctx.graph.type_definitions_ordered_by_name[ix])
+    else {
+        return Err(format!("Type {} does not exist", ty));
+    };
+    let Some(ty_id) = ty_id.as_entity() else {
+        return Err(format!("Type {} is not an object or an interface", ty));
+    };
+    if is_disjoint(ctx, parent, ty_id.into()) {
+        return Err(format!(
+            "Type mismatch: Cannot use type condition {} on {}",
+            ty,
+            ctx[ctx.definition_name_id(parent.into())]
+        ));
+    }
+    Ok(ty_id)
+}
+
+fn bind_selected_object_value<T: Target>(
+    ctx: &mut GraphBuilder<'_>,
+    (output, output_wrapping): (CompositeTypeId, Wrapping),
+    (target, target_wrapping): (T, Wrapping),
+    object: SelectedObjectValue<'_>,
+) -> Result<BoundSelectedObjectValue<T::Id>, String> {
+    if output_wrapping.is_list() {
+        return Err(format!(
+            "Cannot select object fomr {}, it's a list",
+            ctx.type_name(TypeRecord {
+                definition_id: output.into(),
+                wrapping: output_wrapping
+            })
+        ));
+    }
+    if target_wrapping.is_list() {
+        return Err(format!("Cannot map object into {}, it's a list", target.display(ctx)));
+    }
+    if target_wrapping.is_required() && output_wrapping.is_nullable() {
+        return Err(format!(
+            "Cannot map nullable object {} into required one {}",
+            ctx.type_name(TypeRecord {
+                definition_id: output.into(),
+                wrapping: output_wrapping
+            }),
+            target.display(ctx),
+        ));
+    }
+    let fields = object
+        .fields
+        .into_iter()
+        .map(|field| bind_selected_object_field(ctx, output, target, field))
+        .collect::<Result<Vec<_>, _>>()?;
+    Ok(BoundSelectedObjectValue { fields })
+}
+
+fn bind_selected_object_field<T: Target>(
+    ctx: &mut GraphBuilder<'_>,
+    output: CompositeTypeId,
+    parent_target: T,
+    field: SelectedObjectField<'_>,
+) -> Result<BoundSelectedObjectField<T::Id>, String> {
+    let Some(target) = parent_target.field(ctx, field.key) else {
+        return Err(format!(
+            "Field '{}' does not exist on {}",
+            field.key,
+            parent_target.display(ctx)
+        ));
+    };
+    let value = if let Some(value) = field.value {
+        // The parent wrapping doesn't matter anymore, it was already handled.
+        Some(bind_selected_value(ctx, (output, Wrapping::required()), target, value)?)
+    } else {
+        let field_ids = match output {
+            CompositeTypeId::Interface(id) => ctx.graph[id].field_ids,
+            CompositeTypeId::Object(id) => ctx.graph[id].field_ids,
+            CompositeTypeId::Union(id) => {
+                return Err(format!(
+                    "Union {} does not have a field {}",
+                    ctx[ctx.graph[id].name_id], field.key,
+                ));
+            }
+        };
+        let field_id = field_ids
+            .into_iter()
+            .find(|id| ctx[ctx.graph[*id].name_id] == field.key)
+            .ok_or_else(|| {
+                format!(
+                    "Type {} does not have a field named '{}'",
+                    ctx[ctx.definition_name_id(output.into())],
+                    field.key
+                )
+            })?;
+        ensure_type_compatibility(ctx, target, field_id)?;
+        None
+    };
+
+    Ok(BoundSelectedObjectField {
+        field: target.0.id(),
+        value,
+    })
+}
+
+fn bind_selected_list_value<T: Target>(
+    ctx: &mut GraphBuilder<'_>,
+    (output, output_wrapping): (CompositeTypeId, Wrapping),
+    (target, target_wrapping): (T, Wrapping),
+    list: SelectedListValue<'_>,
+) -> Result<BoundSelectedListValue<T::Id>, String> {
+    if target_wrapping.is_required() && output_wrapping.is_nullable() {
+        return Err(format!(
+            "Cannot map nullable list {} into required one {}",
+            ctx.type_name(TypeRecord {
+                definition_id: output.into(),
+                wrapping: output_wrapping
+            }),
+            target.display(ctx),
+        ));
+    }
+    let Some(target_wrapping) = target_wrapping.without_list() else {
+        return Err(format!("Cannot map a list into {}", target.display(ctx)));
+    };
+    let Some(output_wrapping) = output_wrapping.without_list() else {
+        return Err(format!(
+            "Cannot select a list in {}",
+            ctx.type_name(TypeRecord {
+                definition_id: output.into(),
+                wrapping: output_wrapping
+            })
+        ));
+    };
+    let value = bind_selected_value(ctx, (output, output_wrapping), (target, target_wrapping), list.0)?;
+    Ok(BoundSelectedListValue(value))
+}
+
+fn ensure_type_compatibility<T: Target>(
+    ctx: &GraphBuilder<'_>,
+    (target, wrapping): (T, Wrapping),
+    field_id: FieldDefinitionId,
+) -> Result<(), String> {
+    let field = &ctx.graph[field_id];
+    if !wrapping.is_equal_or_more_lenient_than(field.ty_record.wrapping) {
+        return Err(format!(
+            "Incompatible wrapping, cannot map {}.{} ({}) into {} ({})",
+            ctx[ctx.definition_name_id(ctx.graph[field_id].parent_entity_id.into())],
+            ctx[ctx.graph[field_id].name_id],
+            ctx.type_name(ctx.graph[field_id].ty_record),
+            target.display(ctx),
+            ctx.type_name(TypeRecord {
+                definition_id: target.type_definition(&ctx.graph),
+                wrapping
+            })
+        ));
+    }
+    let is_compatible = match (
+        target.type_definition(&ctx.graph),
+        ctx.graph[field_id].ty_record.definition_id,
+    ) {
+        (TypeDefinitionId::Scalar(a), TypeDefinitionId::Scalar(b)) => a == b,
+        (TypeDefinitionId::Enum(a), TypeDefinitionId::Enum(b)) => a == b,
+        _ => false,
+    };
+
+    if !is_compatible {
+        return Err(format!(
+            "Cannot map {}.{} ({}) into {} ({})",
+            ctx[ctx.definition_name_id(ctx.graph[field_id].parent_entity_id.into())],
+            ctx[ctx.graph[field_id].name_id],
+            ctx.type_name(ctx.graph[field_id].ty_record),
+            target.display(ctx),
+            ctx.type_name(TypeRecord {
+                definition_id: target.type_definition(&ctx.graph),
+                wrapping
+            })
+        ));
+    }
+
+    Ok(())
+}
diff --git a/crates/engine/schema/src/builder/coerce/field_selection_map/target.rs b/crates/engine/schema/src/builder/coerce/field_selection_map/target.rs
new file mode 100644
index 0000000000..c0315be14d
--- /dev/null
+++ b/crates/engine/schema/src/builder/coerce/field_selection_map/target.rs
@@ -0,0 +1,124 @@
+use wrapping::Wrapping;
+
+use crate::{
+    EntityDefinitionId, FieldDefinitionId, Graph, InputObjectDefinitionId, InputValueDefinitionId, TypeDefinitionId,
+    builder::GraphBuilder,
+};
+
+pub(super) trait Target: Copy {
+    type Id: Copy;
+    fn id(self) -> Self::Id;
+    fn display(self, ctx: &GraphBuilder<'_>) -> String;
+    fn type_definition(self, graph: &Graph) -> TypeDefinitionId;
+    fn field(self, ctx: &GraphBuilder<'_>, name: &str) -> Option<(Self, Wrapping)>;
+}
+
+#[derive(Clone, Copy)]
+pub(super) enum Input {
+    InputField {
+        input_object_id: InputObjectDefinitionId,
+        input_field_id: InputValueDefinitionId,
+    },
+    Argument {
+        field_id: FieldDefinitionId,
+        argument_id: InputValueDefinitionId,
+    },
+}
+
+impl Target for Input {
+    type Id = InputValueDefinitionId;
+
+    fn id(self) -> Self::Id {
+        match self {
+            Input::InputField { input_field_id, .. } => input_field_id,
+            Input::Argument { argument_id, .. } => argument_id,
+        }
+    }
+
+    fn display(self, ctx: &GraphBuilder<'_>) -> String {
+        match self {
+            Input::InputField {
+                input_object_id,
+                input_field_id,
+            } => {
+                let input_object = &ctx.graph[input_object_id];
+                let field = &ctx.graph[input_field_id];
+                format!("{}.{}", &ctx[input_object.name_id], &ctx[field.name_id])
+            }
+            Input::Argument { field_id, argument_id } => {
+                let field = &ctx.graph[field_id];
+                let argument = &ctx.graph[argument_id];
+                format!(
+                    "{}.{}.{}",
+                    ctx[ctx.definition_name_id(field.parent_entity_id.into())],
+                    ctx[field.name_id],
+                    ctx[argument.name_id]
+                )
+            }
+        }
+    }
+
+    fn type_definition(self, graph: &Graph) -> TypeDefinitionId {
+        graph[self.id()].ty_record.definition_id
+    }
+
+    fn field(self, ctx: &GraphBuilder<'_>, name: &str) -> Option<(Self, Wrapping)> {
+        ctx.graph[self.id()]
+            .ty_record
+            .definition_id
+            .as_input_object()
+            .and_then(|input_object_id| {
+                ctx.graph[input_object_id]
+                    .input_field_ids
+                    .into_iter()
+                    .find(|id| ctx[ctx.graph[*id].name_id] == name)
+                    .map(|id| {
+                        (
+                            Input::InputField {
+                                input_object_id,
+                                input_field_id: id,
+                            },
+                            ctx.graph[id].ty_record.wrapping,
+                        )
+                    })
+            })
+    }
+}
+
+impl Target for FieldDefinitionId {
+    type Id = FieldDefinitionId;
+
+    fn id(self) -> Self::Id {
+        self
+    }
+
+    fn display(self, ctx: &GraphBuilder<'_>) -> String {
+        let field = &ctx.graph[self];
+        format!(
+            "{}.{}",
+            ctx[ctx.definition_name_id(field.parent_entity_id.into())],
+            ctx[field.name_id],
+        )
+    }
+
+    fn type_definition(self, graph: &Graph) -> TypeDefinitionId {
+        graph[self].ty_record.definition_id
+    }
+
+    fn field(self, ctx: &GraphBuilder<'_>, name: &str) -> Option<(Self, Wrapping)> {
+        ctx.graph[self]
+            .ty_record
+            .definition_id
+            .as_entity()
+            .and_then(|entity_id| {
+                let field_ids = match entity_id {
+                    EntityDefinitionId::Interface(id) => ctx.graph[id].field_ids,
+                    EntityDefinitionId::Object(id) => ctx.graph[id].field_ids,
+                };
+                field_ids
+                    .into_iter()
+                    .find(|id| ctx[ctx.graph[*id].name_id] == name)
+                    .map(|id| (id, ctx.graph[id].ty_record.wrapping))
+            })
+    }
+}
diff --git a/crates/engine/schema/src/builder/coerce/field_set.rs b/crates/engine/schema/src/builder/coerce/field_set.rs
index d3896d445f..08da06ec01 100644
--- a/crates/engine/schema/src/builder/coerce/field_set.rs
+++ b/crates/engine/schema/src/builder/coerce/field_set.rs
@@ -273,7 +273,7 @@ fn convert_field_arguments(
     Ok(IdRange::from(start..ctx.selections.arguments.len()))
 }
 
-fn is_disjoint(ctx: &GraphBuilder<'_>, left: CompositeTypeId, right: CompositeTypeId) -> bool {
+pub(super) fn is_disjoint(ctx: &GraphBuilder<'_>, left: CompositeTypeId, right: CompositeTypeId) -> bool {
     let left: &[ObjectDefinitionId] = match &left {
         CompositeTypeId::Object(id) => std::array::from_ref(id),
         CompositeTypeId::Interface(id) => &ctx.graph[*id].possible_type_ids,
diff --git a/crates/engine/schema/src/builder/coerce/mod.rs b/crates/engine/schema/src/builder/coerce/mod.rs
index 9be8dcb210..93d7aa1131 100644
--- a/crates/engine/schema/src/builder/coerce/mod.rs
+++ b/crates/engine/schema/src/builder/coerce/mod.rs
@@ -1,5 +1,6 @@
 mod error;
 mod extension;
+mod field_selection_map;
 mod field_set;
 mod input_value_set;
 mod path;
diff --git a/crates/engine/schema/src/builder/graph/directives/composite/mod.rs b/crates/engine/schema/src/builder/graph/directives/composite/mod.rs
index 50b4d01492..a4ef788293 100644
--- a/crates/engine/schema/src/builder/graph/directives/composite/mod.rs
+++ b/crates/engine/schema/src/builder/graph/directives/composite/mod.rs
@@ -1,5 +1,7 @@
 mod lookup;
 
+use cynic_parser_deser::ConstDeserializer as _;
+
 use crate::{SubgraphId, builder::sdl};
 
 use super::{DirectivesIngester, Error};
@@ -58,6 +60,76 @@ fn dispatch<'sdl>(
 
             // Nothing is ingested during this step, it's done when adding resolvers.
         }
+        "composite__is" => match def {
+            sdl::SdlDefinition::FieldDefinition(def) => {
+                let sdl::IsDirective {
+                    graph,
+                    field_selection_map,
+                } = dir.deserialize().map_err(|err| {
+                    (
+                        format!(
+                            "At {}, invalid composite__lookup directive: {}",
+                            def.to_site_string(ingester),
+                            err
+                        ),
+                        dir.arguments_span(),
+                    )
+                })?;
+                let _subgraph_id = ingester.subgraphs.try_get(graph, dir.arguments_span())?;
+                let output = ingester.graph[def.id].parent_entity_id;
+                ingester.parse_field_selection_map_for_field(output, def.id, field_selection_map)?;
+            }
+            sdl::SdlDefinition::ArgumentDefinition(def) => {
+                let sdl::IsDirective {
+                    graph,
+                    field_selection_map,
+                } = dir.deserialize().map_err(|err| {
+                    (
+                        format!(
+                            "At {}, invalid composite__lookup directive: {}",
+                            def.to_site_string(ingester),
+                            err
+                        ),
+                        dir.arguments_span(),
+                    )
+                })?;
+                let _subgraph_id = ingester.subgraphs.try_get(graph, dir.arguments_span())?;
+                let output = ingester.graph[def.field_id]
+                    .ty_record
+                    .definition_id
+                    .as_entity()
+                    .unwrap();
+                ingester.parse_field_selection_map_for_argument(output, def.field_id, def.id, field_selection_map)?;
+            }
+            _ => {
+                return Err((
+                    format!(
+                        "invalid location: {}, expected one of: {}",
+                        def.location().as_str(),
+                        [
+                            sdl::DirectiveLocation::Field.as_str(),
+                            sdl::DirectiveLocation::ArgumentDefinition.as_str()
+                        ]
+                        .join(", ")
+                    ),
+                    def.span(),
+                )
+                    .into());
+            }
+        },
+        "composite__require" => {
+            if !matches!(def, sdl::SdlDefinition::ArgumentDefinition(_),) {
+                return Err((
+                    format!(
+                        "invalid location: {}, expected one of: {}",
+                        def.location().as_str(),
+                        [sdl::DirectiveLocation::ArgumentDefinition.as_str()].join(", ")
+                    ),
+                    def.span(),
+                )
+                    .into());
+            }
+        }
         _ => return Err("unknown or unsupported directive".into()),
     }
 
diff --git a/crates/engine/schema/src/builder/graph/mod.rs b/crates/engine/schema/src/builder/graph/mod.rs
index 0e55b5aae4..cd5bc8d6a5 100644
--- a/crates/engine/schema/src/builder/graph/mod.rs
+++ b/crates/engine/schema/src/builder/graph/mod.rs
@@ -96,17 +96,19 @@ impl GraphBuilder<'_> {
         })
     }
 
+    pub(crate) fn definition_name_id(&self, ty: TypeDefinitionId) -> StringId {
+        match ty {
+            TypeDefinitionId::Scalar(id) => self.graph[id].name_id,
+            TypeDefinitionId::Object(id) => self.graph[id].name_id,
+            TypeDefinitionId::Interface(id) => self.graph[id].name_id,
+            TypeDefinitionId::Union(id) => self.graph[id].name_id,
+            TypeDefinitionId::Enum(id) => self.graph[id].name_id,
+            TypeDefinitionId::InputObject(id) => self.graph[id].name_id,
+        }
+    }
+
     pub(crate) fn type_name(&self, ty: TypeRecord) -> String {
-        let name = match ty.definition_id {
-            TypeDefinitionId::Scalar(id) => &self.ctx[self.graph[id].name_id],
-            TypeDefinitionId::Object(id) => &self.ctx[self.graph[id].name_id],
-            TypeDefinitionId::Interface(id) => &self.ctx[self.graph[id].name_id],
-            TypeDefinitionId::Union(id) => &self.ctx[self.graph[id].name_id],
-            TypeDefinitionId::Enum(id) => &self.ctx[self.graph[id].name_id],
-            TypeDefinitionId::InputObject(id) => &self.ctx[self.graph[id].name_id],
-        };
-        let mut s = String::new();
-        ty.wrapping.write_type_string(name, &mut s).unwrap();
-        s
+        let name = &self.ctx[self.definition_name_id(ty.definition_id)];
+        ty.wrapping.type_display(name).to_string()
     }
 }
diff --git a/crates/engine/schema/src/builder/sdl/directives.rs b/crates/engine/schema/src/builder/sdl/directives.rs
index 808d3682f2..ac7f8f5a75 100644
--- a/crates/engine/schema/src/builder/sdl/directives.rs
+++ b/crates/engine/schema/src/builder/sdl/directives.rs
@@ -3,6 +3,19 @@ use cynic_parser_deser::{ConstDeserializer, DeserValue, ValueDeserialize, value:
 
 use crate::builder::error::Error;
 
+///```ignore,graphql
+/// directive @composite__is(
+///     graph: join__Graph!,
+///     field: String!
+/// ) repeatable on FIELD_DEFINITION | ARGUMENT_DEFINITION
+///```
+#[derive(Debug, ValueDeserialize)]
+pub(crate) struct IsDirective<'a> {
+    pub graph: GraphName<'a>,
+    #[deser(rename = "field")]
+    pub field_selection_map: &'a str,
+}
+
 #[derive(ValueDeserialize)]
 pub struct CostDirective {
     pub weight: i32,
diff --git a/crates/engine/schema/src/ty.rs b/crates/engine/schema/src/ty.rs
index 4ac8b087f8..2814ee44da 100644
--- a/crates/engine/schema/src/ty.rs
+++ b/crates/engine/schema/src/ty.rs
@@ -2,7 +2,7 @@ use crate::{Type, TypeRecord};
 
 impl std::fmt::Display for Type<'_> {
     fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
-        self.wrapping.write_type_string(self.definition().name(), f)
+        self.wrapping.type_display(self.definition().name()).fmt(f)
     }
 }
 
diff --git a/crates/graphql-wrapping-types/src/lib.rs b/crates/graphql-wrapping-types/src/lib.rs
index ae458612b6..f44cb2ab68 100644
--- a/crates/graphql-wrapping-types/src/lib.rs
+++ b/crates/graphql-wrapping-types/src/lib.rs
@@ -116,6 +116,41 @@ impl Wrapping {
         }
         self
     }
+
+    /// Whether a type wrapped with Self could receive a type wrapping with other.
+    pub fn is_equal_or_more_lenient_than(self, other: Wrapping) -> bool {
+        if self.inner_is_required() && !other.inner_is_required() {
+            return false;
+        }
+        if self.get_list_length() != other.get_list_length() {
+            return false;
+        }
+        for (s, o) in self.list_wrappings().zip(other.list_wrappings()) {
+            if s == ListWrapping::RequiredList && o == ListWrapping::NullableList {
+                return false;
+            }
+        }
+        true
+    }
+
+    pub fn without_list(self) -> Option<Wrapping> {
+        let mut wrapping = self.to_mutable();
+        wrapping.pop_outermost_list_wrapping().map(|_| wrapping.into())
+    }
+
+    pub fn without_non_null(mut self) -> Wrapping {
+        if self.is_nullable() {
+            self
+        } else if self.is_list() {
+            let mut wrapping = self.to_mutable();
+            wrapping.pop_outermost_list_wrapping();
+            wrapping.push_outermost_list_wrapping(ListWrapping::NullableList);
+            wrapping.into()
+        } else {
+            self.0 &= !INNER_IS_REQUIRED_FLAG;
+            self
+        }
+    }
 }
 
 #[derive(Clone, Copy, Debug, PartialEq, Eq)]
@@ -150,21 +185,32 @@ impl Wrapping {
         self.list_wrappings().next().is_some()
     }
 
-    pub fn write_type_string(self, name: &str, mut formatter: &mut dyn std::fmt::Write) -> Result<(), std::fmt::Error> {
-        for _ in 0..self.list_wrappings().len() {
-            write!(formatter, "[")?;
+    pub fn type_display(self, name: &str) -> impl std::fmt::Display {
+        WrappingDisplay { name, wrapping: self }
+    }
+}
+
+struct WrappingDisplay<'a> {
+    name: &'a str,
+    wrapping: Wrapping,
+}
+
+impl std::fmt::Display for WrappingDisplay<'_> {
+    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
+        for _ in 0..self.wrapping.get_list_length() {
+            write!(f, "[")?;
         }
 
-        write!(formatter, "{name}")?;
+        write!(f, "{}", self.name)?;
 
-        if self.inner_is_required() {
-            write!(formatter, "!")?;
+        if self.wrapping.inner_is_required() {
+            write!(f, "!")?;
         }
 
-        for wrapping in self.list_wrappings() {
+        for wrapping in self.wrapping.list_wrappings() {
             match wrapping {
-                ListWrapping::RequiredList => write!(&mut formatter, "]!")?,
-                ListWrapping::NullableList => write!(&mut formatter, "]")?,
+                ListWrapping::RequiredList => write!(f, "]!")?,
+                ListWrapping::NullableList => write!(f, "]")?,
             };
         }
 
@@ -316,4 +362,20 @@ mod tests {
         wrapping.push_outermost_list_wrapping(list_wrapping);
         assert_eq!(Wrapping::from(wrapping), original);
     }
+
+    #[test]
+    fn test_is_compatible_with() {
+        let non_null_list = Wrapping::required().list();
+        let non_null_list_non_null = Wrapping::required().list_non_null();
+        assert!(non_null_list.is_equal_or_more_lenient_than(non_null_list_non_null));
+        assert!(!non_null_list_non_null.is_equal_or_more_lenient_than(non_null_list));
+
+        let list = Wrapping::nullable().list();
+        assert!(list.is_equal_or_more_lenient_than(non_null_list));
+        assert!(!non_null_list.is_equal_or_more_lenient_than(list));
+
+        let list_non_null = Wrapping::nullable().list_non_null();
+        assert!(!non_null_list.is_equal_or_more_lenient_than(list_non_null));
+        assert!(!list_non_null.is_equal_or_more_lenient_than(non_null_list));
+    }
 }
diff --git a/crates/graphql-wrapping-types/src/mutable.rs b/crates/graphql-wrapping-types/src/mutable.rs
index 4d2057aaa9..eacbd8e73b 100644
--- a/crates/graphql-wrapping-types/src/mutable.rs
+++ b/crates/graphql-wrapping-types/src/mutable.rs
@@ -14,10 +14,6 @@ impl MutableWrapping {
         self.inner.is_required()
     }
 
-    pub fn write_type_string(&self, name: &str, formatter: &mut dyn std::fmt::Write) -> Result<(), std::fmt::Error> {
-        self.inner.write_type_string(name, formatter)
-    }
-
     pub fn pop_outermost_list_wrapping(&mut self) -> Option<ListWrapping> {
         let end = self.inner.get_list_length();
         if end == 0 {
diff --git a/crates/integration-tests/tests/gateway/composite/is/argument.rs b/crates/integration-tests/tests/gateway/composite/is/argument.rs
new file mode 100644
index 0000000000..79832c31c9
--- /dev/null
+++ b/crates/integration-tests/tests/gateway/composite/is/argument.rs
@@ -0,0 +1,35 @@
+use integration_tests::{gateway::Gateway, runtime};
+
+#[ignore] // doens't work yet with @lookup validation.
+#[test]
+fn basic() {
+    runtime().block_on(async {
+        let result = Gateway::builder()
+            .with_subgraph_sdl(
+                "ext",
+                r#"
+                extend schema
+                    @link(url: "https://specs.grafbase.com/composite-schemas/v1", import: ["@lookup", "@is", "@key"])
+
+                type Query {
+                    productBatch(input: ProductInput! @is(field: "{ id }")): Product! @lookup
+                }
+
+                input ProductInput {
+                    id: ID!
+                }
+
+                type Product @key(fields: "id") {
+                    id: ID!
+                    code: String!
+                }
+                "#,
+            )
+            .try_build()
+            .await;
+
+        if let Err(err) = result {
+            panic!("{err}");
+        }
+    })
+}
diff --git a/crates/integration-tests/tests/gateway/composite/is/field.rs b/crates/integration-tests/tests/gateway/composite/is/field.rs
new file mode 100644
index 0000000000..4f669136ef
--- /dev/null
+++ b/crates/integration-tests/tests/gateway/composite/is/field.rs
@@ -0,0 +1,324 @@
+use integration_tests::{gateway::Gateway, runtime};
+
+#[test]
+fn basic() {
+    runtime().block_on(async {
+        let result = Gateway::builder()
+            .with_subgraph_sdl(
+                "ext",
+                r#"
+                extend schema
+                    @link(url: "https://specs.grafbase.com/composite-schemas/v1", import: ["@is"])
+
+                type Query {
+                    productBatch(ids: [ID!]!): [Product!]!
+                }
+
+                type Product {
+                    id: ID!
+                    code: String!
+                    author_id: ID!
+                    user: User! @is(field: "{ id: author_id }")
+                }
+
+                type User {
+                    id: ID!
+                }
+                "#,
+            )
+            .try_build()
+            .await;
+
+        if let Err(err) = result {
+            panic!("{err}");
+        }
+    })
+}
+
+#[test]
+fn invalid_type() {
+    runtime().block_on(async {
+        let result = Gateway::builder()
+            .with_subgraph_sdl(
+                "ext",
+                r#"
+                extend schema
+                    @link(url: "https://specs.grafbase.com/composite-schemas/v1", import: ["@is"])
+
+                type Query {
+                    productBatch(ids: [ID!]!): [Product!]!
+                }
+
+                type Product {
+                    id: ID!
+                    code: String!
+                    author_id: Int!
+                    user: User! @is(field: "{ id: author_id }")
+                }
+
+                type User {
+                    id: ID!
+                }
+                "#,
+            )
+            .try_build()
+            .await;
+
+        insta::assert_debug_snapshot!(result.err(), @r#"
+        Some(
+            "At site Product.user, for directive @composite__is Cannot map Product.author_id (Int!) into User.id (ID!). See schema at 25:29:\n(graph: EXT, field: \"{ id: author_id }\")",
+        )
+        "#);
+    })
+}
+
+#[test]
+fn multiple_fields_mapping() {
+    runtime().block_on(async {
+        let result = Gateway::builder()
+            .with_subgraph_sdl(
+                "ext",
+                r#"
+                extend schema
+                    @link(url: "https://specs.grafbase.com/composite-schemas/v1", import: ["@is"])
+
+                type Query {
+                    productBatch(ids: [ID!]!): [Product!]!
+                }
+
+                type Product {
+                    id: ID!
+                    code: String!
+                    author_id: ID!
+                    category_id: ID!
+                    user: User! @is(field: "{ id: author_id category: category_id }")
+                }
+
+                type User {
+                    id: ID!
+                    category: ID!
+                }
+                "#,
+            )
+            .try_build()
+            .await;
+
+        if let Err(err) = result {
+            panic!("{err}");
+        }
+    })
+}
+
+#[test]
+fn direct_field_mapping() {
+    runtime().block_on(async {
+        let result = Gateway::builder()
+            .with_subgraph_sdl(
+                "ext",
+                r#"
+                extend schema
+                    @link(url: "https://specs.grafbase.com/composite-schemas/v1", import: ["@is"])
+
+                type Query {
+                    productBatch(ids: [ID!]!): [Product!]!
+                }
+
+                type Product {
+                    id: ID!
+                    code: String!
+                    author_id: ID!
+                    user_id: ID! @is(field: "author_id")
+                }
+                "#,
+            )
+            .try_build()
+            .await;
+
+        if let Err(err) = result {
+            panic!("{err}");
+        }
+    })
+}
+
+#[test]
+fn incompatible_wrapping() {
+    runtime().block_on(async {
+        let result = Gateway::builder()
+            .with_subgraph_sdl(
+                "ext",
+                r#"
+                extend schema
+                    @link(url: "https://specs.grafbase.com/composite-schemas/v1", import: ["@is"])
+
+                type Query {
+                    productBatch(ids: [ID!]!): [Product!]!
+                }
+
+                type Product {
+                    id: ID!
+                    code: String!
+                    author_ids: [ID!]!
+                    user: User! @is(field: "{ id: author_ids }")
+                }
+
+                type User {
+                    id: ID!
+                }
+                "#,
+            )
+            .try_build()
+            .await;
+
+        insta::assert_debug_snapshot!(result.err(), @r#"
+        Some(
+            "At site Product.user, for directive @composite__is Incompatible wrapping, cannot map Product.author_ids ([ID!]!) into User.id (ID!). See schema at 25:29:\n(graph: EXT, field: \"{ id: author_ids }\")",
+        )
+        "#);
+    })
+}
+
+#[test]
+fn inexistent_field() {
+    runtime().block_on(async {
+        let result = Gateway::builder()
+            .with_subgraph_sdl(
+                "ext",
+                r#"
+                extend schema
+                    @link(url: "https://specs.grafbase.com/composite-schemas/v1", import: ["@is"])
+
+                type Query {
+                    productBatch(ids: [ID!]!): [Product!]!
+                }
+
+                type Product {
+                    id: ID!
+                    code: String!
+                    user: User! @is(field: "{ id: non_existent_field }")
+                }
+
+                type User {
+                    id: ID!
+                }
+                "#,
+            )
+            .try_build()
+            .await;
+
+        insta::assert_debug_snapshot!(result.err(), @r#"
+        Some(
+            "At site Product.user, for directive @composite__is Type Product does not have a field named 'non_existent_field'. See schema at 24:29:\n(graph: EXT, field: \"{ id: non_existent_field }\")",
+        )
+        "#);
+    })
+}
+
+#[test]
+fn inexistent_target_field() {
+    runtime().block_on(async {
+        let result = Gateway::builder()
+            .with_subgraph_sdl(
+                "ext",
+                r#"
+                extend schema
+                    @link(url: "https://specs.grafbase.com/composite-schemas/v1", import: ["@is"])
+
+                type Query {
+                    productBatch(ids: [ID!]!): [Product!]!
+                }
+
+                type Product {
+                    id: ID!
+                    code: String!
+                    user: User! @is(field: "{ non_existent_field: id }")
+                }
+
+                type User {
+                    id: ID!
+                }
+                "#,
+            )
+            .try_build()
+            .await;
+
+        insta::assert_debug_snapshot!(result.err(), @r#"
+        Some(
+            "At site Product.user, for directive @composite__is Field 'non_existent_field' does not exist on Product.user. See schema at 24:29:\n(graph: EXT, field: \"{ non_existent_field: id }\")",
+        )
+        "#);
+    })
+}
+
+#[test]
+fn nullable_vs_required() {
+    runtime().block_on(async {
+        let result = Gateway::builder()
+            .with_subgraph_sdl(
+                "ext",
+                r#"
+                extend schema
+                    @link(url: "https://specs.grafbase.com/composite-schemas/v1", import: ["@is"])
+
+                type Query {
+                    productBatch(ids: [ID!]!): [Product!]!
+                }
+
+                type Product {
+                    id: ID!
+                    code: String!
+                    author_id: ID
+                    user: User! @is(field: "{ id: author_id }")
+                }
+
+                type User {
+                    id: ID!
+                }
+                "#,
+            )
+            .try_build()
+            .await;
+
+        insta::assert_debug_snapshot!(result.err(), @r#"
+        Some(
+            "At site Product.user, for directive @composite__is Incompatible wrapping, cannot map Product.author_id (ID) into User.id (ID!). See schema at 25:29:\n(graph: EXT, field: \"{ id: author_id }\")",
+        )
+        "#);
+    })
+}
+
+#[test]
+fn multiple_fields_without_rename() {
+    runtime().block_on(async {
+        let result = Gateway::builder()
+            .with_subgraph_sdl(
+                "ext",
+                r#"
+                extend schema
+                    @link(url: "https://specs.grafbase.com/composite-schemas/v1", import: ["@is"])
+
+                type Query {
+                    productBatch(ids: [ID!]!): [Product!]!
+                }
+
+                type Product {
+                    id: ID!
+                    code: String!
+                    author_id: ID!
+                    category: String!
+                    user: User! @is(field: "{ id: author_id category }")
+                }
+
+                type User {
+                    id: ID!
+                    category: String!
+                }
+                "#,
+            )
+            .try_build()
+            .await;
+
+        if let Err(err) = result {
+            panic!("{err}");
+        }
+    })
+}
diff --git a/crates/integration-tests/tests/gateway/composite/is/mod.rs b/crates/integration-tests/tests/gateway/composite/is/mod.rs
new file mode 100644
index 0000000000..8de14aa3c5
--- /dev/null
+++ b/crates/integration-tests/tests/gateway/composite/is/mod.rs
@@ -0,0 +1,2 @@
+mod argument;
+mod field;
diff --git a/crates/integration-tests/tests/gateway/composite/mod.rs b/crates/integration-tests/tests/gateway/composite/mod.rs
index b3ff679407..7feb2963e7 100644
--- a/crates/integration-tests/tests/gateway/composite/mod.rs
+++ b/crates/integration-tests/tests/gateway/composite/mod.rs
@@ -1 +1,2 @@
+mod is;
 mod lookup;


Reviews:
user: login: tomhoule
state: APPROVED
author_association: CONTRIBUTOR
submitted_at: 2025-05-07T08:42:10Z
user: login: Finistere
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-05-07T08:43:49Z
user: login: Finistere
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-05-07T08:43:59Z

Review comments:
diff_hunk: @@ -58,6 +60,76 @@ fn dispatch<'sdl>(
 
             // Nothing is ingested during this step, it's done when adding resolvers.
         }
+        "composite__is" => match def {
+            sdl::SdlDefinition::FieldDefinition(def) => {
+                let sdl::IsDirective {
+                    graph,
+                    field_selection_map,
+                } = dir.deserialize().map_err(|err| {
+                    (
+                        format!(
+                            "At {}, invalid composite__lookup directive: {}",
path: crates/engine/schema/src/builder/graph/directives/composite/mod.rs
user: login: tomhoule
body: Should that be "invalid composite__is directive"?
created_at: 2025-05-07T08:40:20Z
author_association: CONTRIBUTOR
diff_hunk: @@ -58,6 +60,76 @@ fn dispatch<'sdl>(
 
             // Nothing is ingested during this step, it's done when adding resolvers.
         }
+        "composite__is" => match def {
+            sdl::SdlDefinition::FieldDefinition(def) => {
+                let sdl::IsDirective {
+                    graph,
+                    field_selection_map,
+                } = dir.deserialize().map_err(|err| {
+                    (
+                        format!(
+                            "At {}, invalid composite__lookup directive: {}",
+                            def.to_site_string(ingester),
+                            err
+                        ),
+                        dir.arguments_span(),
+                    )
+                })?;
+                let _subgraph_id = ingester.subgraphs.try_get(graph, dir.arguments_span())?;
+                let output = ingester.graph[def.id].parent_entity_id;
+                ingester.parse_field_selection_map_for_field(output, def.id, field_selection_map)?;
+            }
+            sdl::SdlDefinition::ArgumentDefinition(def) => {
+                let sdl::IsDirective {
+                    graph,
+                    field_selection_map,
+                } = dir.deserialize().map_err(|err| {
+                    (
+                        format!(
+                            "At {}, invalid composite__lookup directive: {}",
path: crates/engine/schema/src/builder/graph/directives/composite/mod.rs
user: login: tomhoule
body: (same)
created_at: 2025-05-07T08:40:31Z
author_association: CONTRIBUTOR
diff_hunk: @@ -316,4 +362,20 @@ mod tests {
         wrapping.push_outermost_list_wrapping(list_wrapping);
         assert_eq!(Wrapping::from(wrapping), original);
     }
+
+    #[test]
+    fn test_is_compatible_with() {
path: crates/graphql-wrapping-types/src/lib.rs
user: login: tomhoule
body: nitpick. should that be test_is_equal_or_more_lenient_than()?
created_at: 2025-05-07T08:40:49Z
author_association: CONTRIBUTOR
diff_hunk: @@ -58,6 +60,76 @@ fn dispatch<'sdl>(
 
             // Nothing is ingested during this step, it's done when adding resolvers.
         }
+        "composite__is" => match def {
+            sdl::SdlDefinition::FieldDefinition(def) => {
+                let sdl::IsDirective {
+                    graph,
+                    field_selection_map,
+                } = dir.deserialize().map_err(|err| {
+                    (
+                        format!(
+                            "At {}, invalid composite__lookup directive: {}",
path: crates/engine/schema/src/builder/graph/directives/composite/mod.rs
user: login: Finistere
body: ah yes you're right, thx
created_at: 2025-05-07T08:43:49Z
author_association: CONTRIBUTOR
diff_hunk: @@ -316,4 +362,20 @@ mod tests {
         wrapping.push_outermost_list_wrapping(list_wrapping);
         assert_eq!(Wrapping::from(wrapping), original);
     }
+
+    #[test]
+    fn test_is_compatible_with() {
path: crates/graphql-wrapping-types/src/lib.rs
user: login: Finistere
body: touché
created_at: 2025-05-07T08:43:58Z
author_association: CONTRIBUTOR

PR comments:
user: login: linear[bot]
created_at: 2025-05-06T18:48:55Z
body: <p><a href="https://linear.app/grafbase/issue/GB-9015/bind-field-selection-map">GB-9015 bind field-selection-map</a></p>
author_association: NONE
user: login: github-actions[bot]
created_at: 2025-05-06T18:54:13Z
body: <table><tr><th><th>Tests</th><th>Passed ✅</th><th>Skipped ⏭️</th><th>Failed ❌</th></tr><tr><td>Federation Audit Report</td><td>179 ran</td><td>168 passed</td><td>0 skipped</td><td>11 failed</td></tr></table>

<!-- Summary comment for ["Federation Audit Report"] by mikepenz/action-junit-report -->
author_association: NONE
user: login: Finistere
created_at: 2025-05-07T09:45:21Z
body: Fixing all comments in next PR
author_association: CONTRIBUTOR