diff --git a/.github/workflows/lint.yml b/.github/workflows/lint.yml index 493cbba08..b1eec690b 100644 --- a/.github/workflows/lint.yml +++ b/.github/workflows/lint.yml @@ -59,9 +59,9 @@ jobs: ### BUILD CACHE ### - name: Cache Cargo registry, target, index uses: actions/cache@v2 - id: cache-cargo + id: cache-cargo-v2 env: - cache-name: cache-cargo + cache-name: cache-cargo-v2 with: path: | ~/.cargo/registry diff --git a/.github/workflows/rust-slow-test.yml b/.github/workflows/rust-slow-test.yml index f410d9cd6..0de89b854 100644 --- a/.github/workflows/rust-slow-test.yml +++ b/.github/workflows/rust-slow-test.yml @@ -68,9 +68,9 @@ jobs: ### BUILD CACHE ### - name: Cache cargo registry, target, index uses: actions/cache@v2 - id: cache-cargo + id: cache-cargo-v2 env: - cache-name: cache-cargo + cache-name: cache-cargo-v2 with: path: | ~/.cargo/registry diff --git a/.github/workflows/rust-test.yml b/.github/workflows/rust-test.yml index b69abcbe0..a4454944c 100644 --- a/.github/workflows/rust-test.yml +++ b/.github/workflows/rust-test.yml @@ -67,9 +67,9 @@ jobs: ### BUILD CACHE ### - name: Cache cargo registry, target, index uses: actions/cache@v2 - id: cache-cargo + id: cache-cargo-v2 env: - cache-name: cache-cargo + cache-name: cache-cargo-v2 with: path: | ~/.cargo/registry diff --git a/Cargo.lock b/Cargo.lock index f18a65883..2935a05c3 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1,7 +1,5 @@ # This file is automatically @generated by Cargo. # It is not intended for manual editing. -version = 3 - [[package]] name = "actix-codec" version = "0.3.0" @@ -13,7 +11,7 @@ dependencies = [ "futures-core", "futures-sink", "log", - "pin-project 0.4.27", + "pin-project 0.4.28", "tokio", "tokio-util", ] @@ -85,7 +83,7 @@ dependencies = [ "log", "mime", "percent-encoding", - "pin-project 1.0.5", + "pin-project 1.0.6", "rand 0.7.3", "regex", "serde", @@ -93,7 +91,7 @@ dependencies = [ "serde_urlencoded", "sha-1", "slab", - "time 0.2.25", + "time 0.2.26", ] [[package]] @@ -151,7 +149,7 @@ dependencies = [ "mio-uds", "num_cpus", "slab", - "socket2", + "socket2 0.3.19", ] [[package]] @@ -161,7 +159,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0052435d581b5be835d11f4eb3bce417c8af18d87ddf8ace99f8e67e595882bb" dependencies = [ "futures-util", - "pin-project 0.4.27", + "pin-project 0.4.28", ] [[package]] @@ -175,7 +173,7 @@ dependencies = [ "actix-server", "actix-service", "log", - "socket2", + "socket2 0.3.19", ] [[package]] @@ -221,7 +219,7 @@ dependencies = [ "futures-sink", "futures-util", "log", - "pin-project 0.4.27", + "pin-project 0.4.28", "slab", ] @@ -253,13 +251,13 @@ dependencies = [ "fxhash", "log", "mime", - "pin-project 1.0.5", + "pin-project 1.0.6", "regex", "serde", "serde_json", "serde_urlencoded", - "socket2", - "time 0.2.25", + "socket2 0.3.19", + "time 0.2.26", "tinyvec", "url", ] @@ -288,15 +286,6 @@ dependencies = [ "serde", ] -[[package]] -name = "addr2line" -version = "0.14.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a55f82cfe485775d02112886f4169bde0c5894d75e79ead7eafe7e40a25e45f7" -dependencies = [ - "gimli", -] - [[package]] name = "adler" version = "1.0.2" @@ -313,7 +302,7 @@ dependencies = [ "cfg_aliases", "lightproc", "once_cell", - "pin-project 1.0.5", + "pin-project 1.0.6", ] [[package]] @@ -345,9 +334,9 @@ dependencies = [ [[package]] name = "anyhow" -version = "1.0.39" +version = "1.0.40" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "81cddc5f91628367664cc7c69714ff08deee8a3efc54623011c772544d7b2767" +checksum = "28b2cd92db5cbd74e8e5028f7e27dd7aa3090e89e4f2a197cc7c8dfb69c7063b" [[package]] name = "apollo-query-planner" @@ -463,7 +452,7 @@ dependencies = [ "httparse", "lazy_static", "log", - "pin-project 1.0.5", + "pin-project 1.0.6", ] [[package]] @@ -525,7 +514,7 @@ dependencies = [ "memchr", "num_cpus", "once_cell", - "pin-project-lite 0.2.5", + "pin-project-lite 0.2.6", "pin-utils", "slab", "wasm-bindgen-futures", @@ -539,9 +528,9 @@ checksum = "e91831deabf0d6d7ec49552e489aed63b7456a7a3c46cff62adad428110b0af0" [[package]] name = "async-trait" -version = "0.1.42" +version = "0.1.48" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8d3a45e77e34375a7923b1e8febb049bb011f064714a8e17a1a616fef01da13d" +checksum = "36ea56748e10732c49404c153638a15ec3d6211ec5ff35d9bb20e13b93576adf" dependencies = [ "proc-macro2", "quote", @@ -595,20 +584,6 @@ dependencies = [ "serde_urlencoded", ] -[[package]] -name = "backtrace" -version = "0.3.56" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9d117600f438b1707d4e4ae15d3595657288f8235a0eb593e80ecc98ab34e1bc" -dependencies = [ - "addr2line", - "cfg-if 1.0.0", - "libc", - "miniz_oxide", - "object", - "rustc-demangle", -] - [[package]] name = "base-x" version = "0.2.8" @@ -630,7 +605,7 @@ dependencies = [ "anyhow", "async-mutex", "bastion-executor", - "crossbeam-queue 0.3.1", + "crossbeam-queue", "futures", "futures-timer", "fxhash", @@ -656,7 +631,7 @@ dependencies = [ "bastion-utils", "crossbeam-channel", "crossbeam-epoch 0.9.3", - "crossbeam-queue 0.3.1", + "crossbeam-queue", "crossbeam-utils 0.8.3", "futures-timer", "lazy_static", @@ -733,11 +708,11 @@ checksum = "63396b8a4b9de3f4fdfb320ab6080762242f66a8ef174c49d8e19b674db4cdbe" [[package]] name = "byte-pool" -version = "0.2.2" +version = "0.2.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1e38e98299d518ec351ca016363e0cbfc77059dcd08dfa9700d15e405536097a" +checksum = "f8c7230ddbb427b1094d477d821a99f3f54d36333178eeb806e279bcdcecf0ca" dependencies = [ - "crossbeam-queue 0.2.3", + "crossbeam-queue", "stable_deref_trait", ] @@ -749,9 +724,9 @@ checksum = "72feb31ffc86498dacdbd0fcebb56138e7177a8cc5cea4516031d15ae85a742e" [[package]] name = "byteorder" -version = "1.4.2" +version = "1.4.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ae44d1a3d5a19df61dd0c8beb138458ac2a53a7ac09eba97d55592540004306b" +checksum = "14c189c53d098945499cdfa7ecc63567cf3886b3332b312a5b4585d8d3a6a610" [[package]] name = "bytes" @@ -862,9 +837,9 @@ dependencies = [ [[package]] name = "console" -version = "0.14.0" +version = "0.14.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7cc80946b3480f421c2f17ed1cb841753a371c7c5104f51d507e13f532c856aa" +checksum = "3993e6445baa160675931ec041a5e03ca84b9c6e32a056150d3aa2bdda0a1f45" dependencies = [ "encode_unicode", "lazy_static", @@ -885,9 +860,15 @@ dependencies = [ [[package]] name = "const_fn" -version = "0.4.5" +version = "0.4.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "28b9d6de7f49e22cf97ad17fc4036ece69300032f45f78f30b4a4482cdc3f4a6" +checksum = "076a6803b0dacd6a88cfe64deba628b01533ff5ef265687e6938280c1afd0a28" + +[[package]] +name = "convert_case" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6245d59a3e82a7fc217c5828a6692dbc6dfb63a0c8c90495621f7b9d79704a0e" [[package]] name = "cookie" @@ -896,7 +877,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "03a5d7b21829bc7b4bf4754a978a241ae54ea55a40f92bb20216e54096f4b951" dependencies = [ "percent-encoding", - "time 0.2.25", + "time 0.2.26", "version_check", ] @@ -912,7 +893,7 @@ dependencies = [ "publicsuffix", "serde", "serde_json", - "time 0.2.25", + "time 0.2.26", "url", ] @@ -987,21 +968,10 @@ dependencies = [ "cfg-if 1.0.0", "crossbeam-utils 0.8.3", "lazy_static", - "memoffset 0.6.1", + "memoffset 0.6.3", "scopeguard", ] -[[package]] -name = "crossbeam-queue" -version = "0.2.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "774ba60a54c213d409d5353bda12d49cd68d14e45036a285234c8d6f91f92570" -dependencies = [ - "cfg-if 0.1.10", - "crossbeam-utils 0.7.2", - "maybe-uninit", -] - [[package]] name = "crossbeam-queue" version = "0.3.1" @@ -1036,9 +1006,9 @@ dependencies = [ [[package]] name = "ctor" -version = "0.1.19" +version = "0.1.20" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e8f45d9ad417bcef4817d614a501ab55cdd96a6fdb24f49aab89a54acfd66b19" +checksum = "5e98e2ad1a782e33928b96fc3948e7c355e5af34ba4de7670fe8bac2a3b2006d" dependencies = [ "quote", "syn", @@ -1097,7 +1067,7 @@ dependencies = [ "lazy_static", "libc", "log", - "pin-project 1.0.5", + "pin-project 1.0.6", "rusty_v8", "serde", "serde_json", @@ -1143,10 +1113,11 @@ dependencies = [ [[package]] name = "derive_more" -version = "0.99.11" +version = "0.99.13" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "41cb0e6161ad61ed084a36ba71fbba9e3ac5aee3606fb607fe08da6acbcf3d8c" +checksum = "f82b1b72f1263f214c0f823371768776c4f5841b942c9883aa8e5ec584fd0ba6" dependencies = [ + "convert_case", "proc-macro2", "quote", "syn", @@ -1175,9 +1146,9 @@ checksum = "212d0f5754cb6769937f4501cc0e67f4f4483c8d2c3e1e922ee9edbe4ab4c7c0" [[package]] name = "dtoa" -version = "0.4.7" +version = "0.4.8" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "88d7ed2934d741c6b37e33e3832298e8850b53fd2d2bea03873375596c7cea4e" +checksum = "56899898ce76aaf4a0f24d914c97ea6ed976d42fec6ad33fcbb0a1103e07b2b0" [[package]] name = "either" @@ -1381,7 +1352,7 @@ dependencies = [ "futures-io", "memchr", "parking", - "pin-project-lite 0.2.5", + "pin-project-lite 0.2.6", "waker-fn", ] @@ -1428,7 +1399,7 @@ dependencies = [ "futures-sink", "futures-task", "memchr", - "pin-project-lite 0.2.5", + "pin-project-lite 0.2.6", "pin-utils", "proc-macro-hack", "proc-macro-nested", @@ -1498,12 +1469,6 @@ dependencies = [ "typed-builder", ] -[[package]] -name = "gimli" -version = "0.23.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f6503fe142514ca4799d4c26297c4248239fe8838d827db6bd6065c6ed29a6ce" - [[package]] name = "gloo-timers" version = "0.2.1" @@ -1553,7 +1518,7 @@ dependencies = [ [[package]] name = "harmonizer" -version = "0.1.3" +version = "0.2.2" dependencies = [ "anyhow", "deno_core", @@ -1638,7 +1603,7 @@ dependencies = [ "futures-lite", "http", "infer", - "pin-project-lite 0.2.5", + "pin-project-lite 0.2.6", "rand 0.7.3", "serde", "serde_json", @@ -1690,8 +1655,8 @@ dependencies = [ "httparse", "httpdate", "itoa", - "pin-project 1.0.5", - "socket2", + "pin-project 1.0.6", + "socket2 0.3.19", "tokio", "tower-service", "tracing", @@ -1730,9 +1695,9 @@ dependencies = [ [[package]] name = "indexmap" -version = "1.6.1" +version = "1.6.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4fb1fa934250de4de8aef298d81c729a7d33d8c239daa3a7575e6b92bfc7313b" +checksum = "824845a0bf897a9042383849b02c1bc219c2383772efcd5c6f9766fa4b81aef3" dependencies = [ "autocfg", "hashbrown", @@ -1789,7 +1754,7 @@ version = "0.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f7e2f18aece9709094573a9f24f483c4f65caa4298e2f7ae1b71cc65d853fad7" dependencies = [ - "socket2", + "socket2 0.3.19", "widestring", "winapi 0.3.9", "winreg 0.6.2", @@ -1818,9 +1783,9 @@ checksum = "dd25036021b0de88a0aff6b850051563c6516d0bf53f8638938edbb9de732736" [[package]] name = "js-sys" -version = "0.3.48" +version = "0.3.50" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "dc9f84f9b115ce7843d60706df1422a916680bfdfcbdb0447c5614ff9d7e4d78" +checksum = "2d99f9e3e84b8f67f846ef5b4cbbc3b1c29f6c759fcbce6f01aa0e73d932a24c" dependencies = [ "wasm-bindgen", ] @@ -1874,9 +1839,9 @@ dependencies = [ [[package]] name = "libc" -version = "0.2.87" +version = "0.2.92" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "265d751d31d6780a3f956bb5b8022feba2d94eeee5a84ba64f4212eedca42213" +checksum = "56d855069fafbb9b344c0f962150cd2c1187975cb1c22c1522c240d8c4986714" [[package]] name = "lightproc" @@ -1966,9 +1931,9 @@ dependencies = [ [[package]] name = "memoffset" -version = "0.6.1" +version = "0.6.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "157b4208e3059a8f9e78d559edc658e13df41410cb3ae03979c83130067fdd87" +checksum = "f83fb6581e8ed1f85fd45c116db8405483899489e38406156c25eb743554361d" dependencies = [ "autocfg", ] @@ -2061,12 +2026,12 @@ dependencies = [ [[package]] name = "nb-connect" -version = "1.0.3" +version = "1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "670361df1bc2399ee1ff50406a0d422587dd3bb0da596e1978fe8e05dabddf4f" +checksum = "a19900e7eee95eb2b3c2e26d12a874cc80aaf750e31be6fcbe743ead369fa45d" dependencies = [ "libc", - "socket2", + "socket2 0.4.0", ] [[package]] @@ -2094,7 +2059,7 @@ dependencies = [ "libc", "once_cell", "pin-utils", - "socket2", + "socket2 0.3.19", ] [[package]] @@ -2126,12 +2091,6 @@ dependencies = [ "libc", ] -[[package]] -name = "object" -version = "0.23.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a9a7ab5d64814df0fe4a4b5ead45ed6c5f181ee3ff04ba344313a6c80446c5d4" - [[package]] name = "once_cell" version = "1.7.2" @@ -2146,15 +2105,15 @@ checksum = "624a8340c38c1b80fd549087862da4ba43e08858af025b236e509b6649fc13d5" [[package]] name = "openssl" -version = "0.10.32" +version = "0.10.33" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "038d43985d1ddca7a9900630d8cd031b56e4794eecc2e9ea39dd17aa04399a70" +checksum = "a61075b62a23fef5a29815de7536d940aa35ce96d18ce0cc5076272db678a577" dependencies = [ "bitflags", "cfg-if 1.0.0", "foreign-types", - "lazy_static", "libc", + "once_cell", "openssl-sys", ] @@ -2166,9 +2125,9 @@ checksum = "77af24da69f9d9341038eba93a073b1fdaaa1b788221b00a69bce9e762cb32de" [[package]] name = "openssl-sys" -version = "0.9.60" +version = "0.9.61" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "921fc71883267538946025deffb622905ecad223c28efbfdef9bb59a0175f3e6" +checksum = "313752393519e876837e09e1fa183ddef0be7735868dced3196f4472d536277f" dependencies = [ "autocfg", "cc", @@ -2186,7 +2145,7 @@ dependencies = [ "futures", "lazy_static", "percent-encoding", - "pin-project 0.4.27", + "pin-project 0.4.28", "prometheus", "rand 0.7.3", ] @@ -2301,27 +2260,27 @@ checksum = "d4fd5641d01c8f18a23da7b6fe29298ff4b55afcccdf78973b24cf3175fee32e" [[package]] name = "pin-project" -version = "0.4.27" +version = "0.4.28" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2ffbc8e94b38ea3d2d8ba92aea2983b503cd75d0888d75b86bb37970b5698e15" +checksum = "918192b5c59119d51e0cd221f4d49dde9112824ba717369e903c97d076083d0f" dependencies = [ - "pin-project-internal 0.4.27", + "pin-project-internal 0.4.28", ] [[package]] name = "pin-project" -version = "1.0.5" +version = "1.0.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "96fa8ebb90271c4477f144354485b8068bd8f6b78b428b01ba892ca26caf0b63" +checksum = "bc174859768806e91ae575187ada95c91a29e96a98dc5d2cd9a1fed039501ba6" dependencies = [ - "pin-project-internal 1.0.5", + "pin-project-internal 1.0.6", ] [[package]] name = "pin-project-internal" -version = "0.4.27" +version = "0.4.28" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "65ad2ae56b6abe3a1ee25f15ee605bacadb9a764edaba9c2bf4103800d4a1895" +checksum = "3be26700300be6d9d23264c73211d8190e755b6b5ca7a1b28230025511b52a5e" dependencies = [ "proc-macro2", "quote", @@ -2330,9 +2289,9 @@ dependencies = [ [[package]] name = "pin-project-internal" -version = "1.0.5" +version = "1.0.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "758669ae3558c6f74bd2a18b41f7ac0b5a195aea6639d6a9b5e5d1ad5ba24c0b" +checksum = "a490329918e856ed1b083f244e3bfe2d8c4f336407e4ea9e1a9f479ff09049e5" dependencies = [ "proc-macro2", "quote", @@ -2347,9 +2306,9 @@ checksum = "257b64915a082f7811703966789728173279bdebb956b143dbcd23f6f970a777" [[package]] name = "pin-project-lite" -version = "0.2.5" +version = "0.2.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0cf491442e4b033ed1c722cb9f0df5fcfcf4de682466c46469c36bc47dc5548a" +checksum = "dc0e1f259c92177c30a4c9d177246edd0a3568b25756a977d0632cf8fa37e905" [[package]] name = "pin-utils" @@ -2365,11 +2324,11 @@ checksum = "3831453b3449ceb48b6d9c7ad7c96d5ea673e9b470a1dc578c2ce6521230884c" [[package]] name = "polling" -version = "2.0.2" +version = "2.0.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a2a7bc6b2a29e632e45451c941832803a18cce6781db04de8a04696cdca8bde4" +checksum = "4fc12d774e799ee9ebae13f4076ca003b40d18a11ac0f3641e6f899618580b7b" dependencies = [ - "cfg-if 0.1.10", + "cfg-if 1.0.0", "libc", "log", "wepoll-sys", @@ -2455,9 +2414,9 @@ dependencies = [ [[package]] name = "protobuf" -version = "2.22.0" +version = "2.22.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "73f72884896d22e0da0e5b266cb9a780b791f6c3b2f5beab6368d6cd4f0dbb86" +checksum = "1b7f4a129bb3754c25a4e04032a90173c68f85168f77118ac4cb4936e7f06f92" [[package]] name = "publicsuffix" @@ -2597,14 +2556,13 @@ dependencies = [ [[package]] name = "regex" -version = "1.4.3" +version = "1.4.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d9251239e129e16308e70d853559389de218ac275b515068abc96829d05b948a" +checksum = "957056ecddbeba1b26965114e191d2e8589ce74db242b6ea25fc4062427a5c19" dependencies = [ "aho-corasick", "memchr", "regex-syntax", - "thread_local", ] [[package]] @@ -2619,9 +2577,9 @@ dependencies = [ [[package]] name = "regex-syntax" -version = "0.6.22" +version = "0.6.23" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b5eb417147ba9860a96cfe72a0b93bf88fee1744b5636ec99ab20c1aa9376581" +checksum = "24d5f089152e60f62d28b835fbff2cd2e8dc0baf1ac13343bef92ab7eed84548" [[package]] name = "remove_dir_all" @@ -2655,7 +2613,7 @@ dependencies = [ "mime_guess", "native-tls", "percent-encoding", - "pin-project-lite 0.2.5", + "pin-project-lite 0.2.6", "serde", "serde_json", "serde_urlencoded", @@ -2693,12 +2651,6 @@ dependencies = [ "winapi 0.3.9", ] -[[package]] -name = "rustc-demangle" -version = "0.1.18" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6e3bad0ee36814ca07d7968269dd4b7ec89ec2da10c4bb613928d3077083c232" - [[package]] name = "rustc_version" version = "0.2.3" @@ -2774,9 +2726,9 @@ dependencies = [ [[package]] name = "security-framework" -version = "2.1.1" +version = "2.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2dfd318104249865096c8da1dfabf09ddbb6d0330ea176812a62ec75e40c4166" +checksum = "3670b1d2fdf6084d192bc71ead7aabe6c06aa2ea3fbd9cc3ac111fa5c2b1bd84" dependencies = [ "bitflags", "core-foundation", @@ -2787,9 +2739,9 @@ dependencies = [ [[package]] name = "security-framework-sys" -version = "2.1.1" +version = "2.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "dee48cdde5ed250b0d3252818f646e174ab414036edb884dde62d80a3ac6082d" +checksum = "3676258fd3cfe2c9a0ec99ce3038798d847ce3e4bb17746373eb9f0f1ac16339" dependencies = [ "core-foundation-sys", "libc", @@ -2812,18 +2764,18 @@ checksum = "388a1df253eca08550bef6c72392cfe7c30914bf41df5269b68cbd6ff8f570a3" [[package]] name = "serde" -version = "1.0.124" +version = "1.0.125" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bd761ff957cb2a45fbb9ab3da6512de9de55872866160b23c25f1a841e99d29f" +checksum = "558dc50e1a5a5fa7112ca2ce4effcb321b0300c0d4ccf0776a9f60cd89031171" dependencies = [ "serde_derive", ] [[package]] name = "serde_derive" -version = "1.0.124" +version = "1.0.125" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1800f7693e94e186f5e25a28291ae1570da908aff7d97a095dec1e56ff99069b" +checksum = "b093b7a2bb58203b5da3056c05b4ec1fed827dcfdb37347a8841695263b3d06d" dependencies = [ "proc-macro2", "quote", @@ -2959,6 +2911,16 @@ dependencies = [ "winapi 0.3.9", ] +[[package]] +name = "socket2" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9e3dfc207c526015c632472a77be09cf1b6e46866581aecae5cc38fb4235dea2" +dependencies = [ + "libc", + "winapi 0.3.9", +] + [[package]] name = "spin" version = "0.5.2" @@ -2973,9 +2935,9 @@ checksum = "a8f112729512f8e442d81f95a8a7ddf2b7c6b8a1a6f509a95864142b30cab2d3" [[package]] name = "standback" -version = "0.2.15" +version = "0.2.17" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a2beb4d1860a61f571530b3f855a1b538d0200f7871c63331ecd6f17b1f014f8" +checksum = "e113fb6f3de07a243d434a56ec6f186dfd51cb08448239fe7bcae73f87ff28ff" dependencies = [ "version_check", ] @@ -3091,9 +3053,9 @@ dependencies = [ [[package]] name = "syn" -version = "1.0.60" +version = "1.0.67" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c700597eca8a5a762beb35753ef6b94df201c81cca676604f547495a0d7f0081" +checksum = "6498a9efc342871f91cc2d0d694c674368b4ceb40f62b65a7a08c3792935e702" dependencies = [ "proc-macro2", "quote", @@ -3214,9 +3176,9 @@ dependencies = [ [[package]] name = "time" -version = "0.2.25" +version = "0.2.26" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1195b046942c221454c2539395f85413b33383a067449d78aab2b7b052a142f7" +checksum = "08a8cbfbf47955132d0202d1662f49b2423ae35862aee471f3ba4b133358f372" dependencies = [ "const_fn", "libc", @@ -3336,7 +3298,7 @@ checksum = "01ebdc2bb4498ab1ab5f5b73c5803825e60199229ccba0698170e3be0e7f959f" dependencies = [ "cfg-if 1.0.0", "log", - "pin-project-lite 0.2.5", + "pin-project-lite 0.2.6", "tracing-attributes", "tracing-core", ] @@ -3356,9 +3318,9 @@ dependencies = [ [[package]] name = "tracing-attributes" -version = "0.1.13" +version = "0.1.15" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a8a9bd1db7706f2373a190b0d067146caa39350c486f3d455b0e33b431f94c07" +checksum = "c42e6fa53307c8a17e4ccd4dc81cf5ec38db9209f59b222210375b54ee40d1e2" dependencies = [ "proc-macro2", "quote", @@ -3397,7 +3359,7 @@ version = "0.2.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "97d095ae15e245a057c8e8451bab9b3ee1e1f68e9ba2b4fbc18d0ac5237835f2" dependencies = [ - "pin-project 1.0.5", + "pin-project 1.0.6", "tracing", ] @@ -3439,9 +3401,9 @@ dependencies = [ [[package]] name = "tracing-subscriber" -version = "0.2.16" +version = "0.2.17" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8ab8966ac3ca27126141f7999361cc97dd6fb4b71da04c02044fa9045d98bb96" +checksum = "705096c6f83bf68ea5d357a6aa01829ddbdac531b357b45abeca842938085baa" dependencies = [ "ansi_term 0.12.1", "chrono", @@ -3461,12 +3423,12 @@ dependencies = [ [[package]] name = "trust-dns-proto" -version = "0.19.6" +version = "0.19.7" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "53861fcb288a166aae4c508ae558ed18b53838db728d4d310aad08270a7d4c2b" +checksum = "1cad71a0c0d68ab9941d2fb6e82f8fb2e86d9945b94e1661dd0aaea2b88215a9" dependencies = [ "async-trait", - "backtrace", + "cfg-if 1.0.0", "enum-as-inner", "futures", "idna", @@ -3481,11 +3443,10 @@ dependencies = [ [[package]] name = "trust-dns-resolver" -version = "0.19.6" +version = "0.19.7" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6759e8efc40465547b0dfce9500d733c65f969a4cbbfbe3ccf68daaa46ef179e" +checksum = "710f593b371175db53a26d0b38ed2978fafb9e9e8d3868b1acd753ea18df0ceb" dependencies = [ - "backtrace", "cfg-if 0.1.10", "futures", "ipconfig", @@ -3518,9 +3479,9 @@ dependencies = [ [[package]] name = "typenum" -version = "1.12.0" +version = "1.13.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "373c8a200f9e67a0c95e62a4f52fbf80c23b4381c05a17845531982fa99e6b33" +checksum = "879f6906492a7cd215bfa4cf595b600146ccfac0c79bcbd1f3000162af5e8b06" [[package]] name = "unicase" @@ -3640,9 +3601,9 @@ checksum = "b00bca6106a5e23f3eee943593759b7fcddb00554332e856d990c893966879fb" [[package]] name = "vec-arena" -version = "1.0.0" +version = "1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "eafc1b9b2dfc6f5529177b62cf806484db55b32dc7c9658a118e11bbeb33061d" +checksum = "34b2f665b594b07095e3ac3f718e13c2197143416fae4c5706cffb7b1af8d7f1" [[package]] name = "vec_map" @@ -3652,9 +3613,9 @@ checksum = "f1bddf1187be692e79c5ffeab891132dfb0f236ed36a43c7ed39f1165ee20191" [[package]] name = "version_check" -version = "0.9.2" +version = "0.9.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b5a972e5669d67ba988ce3dc826706fb0a8b01471c088cb0b6110b805cc36aed" +checksum = "5fecdca9a5291cc2b8dcf7dc02453fee791a280f3743cb0905f8822ae463b3fe" [[package]] name = "void" @@ -3692,9 +3653,9 @@ checksum = "fd6fbd9a79829dd1ad0cc20627bf1ed606756a7f77edff7b66b7064f9cb327c6" [[package]] name = "wasm-bindgen" -version = "0.2.71" +version = "0.2.73" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7ee1280240b7c461d6a0071313e08f34a60b0365f14260362e5a2b17d1d31aa7" +checksum = "83240549659d187488f91f33c0f8547cbfef0b2088bc470c116d1d260ef623d9" dependencies = [ "cfg-if 1.0.0", "serde", @@ -3704,9 +3665,9 @@ dependencies = [ [[package]] name = "wasm-bindgen-backend" -version = "0.2.71" +version = "0.2.73" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5b7d8b6942b8bb3a9b0e73fc79b98095a27de6fa247615e59d096754a3bc2aa8" +checksum = "ae70622411ca953215ca6d06d3ebeb1e915f0f6613e3b495122878d7ebec7dae" dependencies = [ "bumpalo", "lazy_static", @@ -3719,9 +3680,9 @@ dependencies = [ [[package]] name = "wasm-bindgen-futures" -version = "0.4.21" +version = "0.4.23" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8e67a5806118af01f0d9045915676b22aaebecf4178ae7021bc171dab0b897ab" +checksum = "81b8b767af23de6ac18bf2168b690bed2902743ddf0fb39252e36f9e2bfc63ea" dependencies = [ "cfg-if 1.0.0", "js-sys", @@ -3731,9 +3692,9 @@ dependencies = [ [[package]] name = "wasm-bindgen-macro" -version = "0.2.71" +version = "0.2.73" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e5ac38da8ef716661f0f36c0d8320b89028efe10c7c0afde65baffb496ce0d3b" +checksum = "3e734d91443f177bfdb41969de821e15c516931c3c3db3d318fa1b68975d0f6f" dependencies = [ "quote", "wasm-bindgen-macro-support", @@ -3741,9 +3702,9 @@ dependencies = [ [[package]] name = "wasm-bindgen-macro-support" -version = "0.2.71" +version = "0.2.73" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cc053ec74d454df287b9374ee8abb36ffd5acb95ba87da3ba5b7d3fe20eb401e" +checksum = "d53739ff08c8a68b0fdbcd54c372b8ab800b1449ab3c9d706503bc7dd1621b2c" dependencies = [ "proc-macro2", "quote", @@ -3754,15 +3715,15 @@ dependencies = [ [[package]] name = "wasm-bindgen-shared" -version = "0.2.71" +version = "0.2.73" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7d6f8ec44822dd71f5f221a5847fb34acd9060535c1211b70a05844c0f6383b1" +checksum = "d9a543ae66aa233d14bb765ed9af4a33e81b8b58d1584cf1b47ff8cd0b9e4489" [[package]] name = "wasm-bindgen-test" -version = "0.3.21" +version = "0.3.23" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2ea9e4f0050d5498a160e6b9d278a9699598e445b51dacd05598da55114c801a" +checksum = "e972e914de63aa53bd84865e54f5c761bd274d48e5be3a6329a662c0386aa67a" dependencies = [ "console_error_panic_hook", "js-sys", @@ -3774,9 +3735,9 @@ dependencies = [ [[package]] name = "wasm-bindgen-test-macro" -version = "0.3.21" +version = "0.3.23" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "43f40402f495d92df6cdd0d329e7cc2580c8f99bcd74faff0e468923a764b7d4" +checksum = "ea6153a8f9bf24588e9f25c87223414fff124049f68d3a442a0f0eab4768a8b6" dependencies = [ "proc-macro2", "quote", @@ -3784,9 +3745,9 @@ dependencies = [ [[package]] name = "web-sys" -version = "0.3.48" +version = "0.3.50" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ec600b26223b2948cedfde2a0aa6756dcf1fef616f43d7b3097aaf53a6c4d92b" +checksum = "a905d57e488fec8861446d3393670fb50d27a262344013181c2cdf9fff5481be" dependencies = [ "js-sys", "wasm-bindgen", @@ -3822,12 +3783,12 @@ dependencies = [ [[package]] name = "which" -version = "4.0.2" +version = "4.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "87c14ef7e1b8b8ecfc75d5eca37949410046e66f15d185c01d70824f1f8111ef" +checksum = "b55551e42cbdf2ce2bedd2203d0cc08dba002c27510f86dab6d0ce304cba3dfe" dependencies = [ + "either", "libc", - "thiserror", ] [[package]] diff --git a/codegen.yml b/codegen.yml index c4ddeb3bc..04016faf7 100644 --- a/codegen.yml +++ b/codegen.yml @@ -1,7 +1,7 @@ overwrite: true schema: "https://us-central1-mdg-services.cloudfunctions.net:443/cloudconfig-staging/" documents: - - gateway-js/src/loadCsdlFromStorage.ts + - gateway-js/src/loadSupergraphSdlFromStorage.ts generates: gateway-js/src/__generated__/graphqlTypes.ts: plugins: diff --git a/federation-integration-testsuite-js/src/matchers/index.ts b/federation-integration-testsuite-js/src/matchers/index.ts index 4442028ed..6559e2360 100644 --- a/federation-integration-testsuite-js/src/matchers/index.ts +++ b/federation-integration-testsuite-js/src/matchers/index.ts @@ -2,3 +2,4 @@ import './toCallService'; import './toHaveBeenCalledBefore'; import './toHaveFetched'; import './toMatchAST'; +import './toMatchQueryPlan'; diff --git a/federation-js/CHANGELOG.md b/federation-js/CHANGELOG.md index 466b8f804..3460bbdcb 100644 --- a/federation-js/CHANGELOG.md +++ b/federation-js/CHANGELOG.md @@ -4,8 +4,7 @@ > The changes noted within this `vNEXT` section have not been released yet. New PRs and commits which introduce changes should include an entry in this `vNEXT` section as part of their development. When a release is being prepared, a new header will be (manually) created below and the appropriate changes within that release will be moved into the new section. -- _Nothing yet! Stay tuned!_ - +- __BREAKING__ - Update CSDL to the new core schema format, implementing the currently-being-introduced core and join specs. `composeAndValidate` now returns `supergraphSdl` in the new format instead of `composedSdl` in the previous CSDL format. [PR #622](https://github.com/apollographql/federation/pull/622) ## v0.22.0 - No changes to the package itself, though there are some small changes to the way this package is compiled and the tests within this package due to the changes in [PR #453](https://github.com/apollographql/federation/pull/453) diff --git a/federation-js/src/__tests__/joinSpec.test.ts b/federation-js/src/__tests__/joinSpec.test.ts new file mode 100644 index 000000000..381fffa4b --- /dev/null +++ b/federation-js/src/__tests__/joinSpec.test.ts @@ -0,0 +1,45 @@ +import { fixtures } from 'apollo-federation-integration-testsuite'; +import { getJoins } from "../joinSpec"; + +const questionableNamesRemap = { + accounts: 'ServiceA', + books: 'serviceA', + documents: 'servicea_2', + inventory: 'servicea_2_', + product: '9product*!', + reviews: 'reviews_9', +}; + +const fixturesWithQuestionableServiceNames = fixtures.map((service) => ({ + ...service, + name: questionableNamesRemap[service.name], +})); + +describe('join__Graph enum', () => { + it('correctly uniquifies and sanitizes service names', () => { + const { sanitizedServiceNames } = getJoins( + fixturesWithQuestionableServiceNames, + ); + + /** + * Expectations + * 1. Non-Alphanumeric characters are replaced with _ (9product*!) + * 2. Numeric first characters are prefixed with _ (9product*!) + * 3. Names ending in an underscore followed by numbers `_\d+` are suffixed with _ (reviews_9, servicea_2) + * 4. Names are uppercased (all) + * 5. After transformations 1-4, duplicates are suffixed with _{n} where {n} is number of times we've seen the dupe (ServiceA + serviceA, servicea_2 + servicea_2_) + * + * Miscellany + * (serviceA) tests the edge case of colliding with a name we generated + * (servicea_2_) tests a collision against (documents) post-transformation + */ + expect(sanitizedServiceNames).toMatchObject({ + '9product*!': '_9PRODUCT__', + ServiceA: 'SERVICEA', + reviews_9: 'REVIEWS_9_', + serviceA: 'SERVICEA_2', + servicea_2: 'SERVICEA_2_', + servicea_2_: 'SERVICEA_2__2', + }); + }) +}) diff --git a/federation-js/src/composition/__tests__/composeAndValidate.test.ts b/federation-js/src/composition/__tests__/composeAndValidate.test.ts index b4b2a68cd..a2e7f6120 100644 --- a/federation-js/src/composition/__tests__/composeAndValidate.test.ts +++ b/federation-js/src/composition/__tests__/composeAndValidate.test.ts @@ -689,7 +689,7 @@ describe('composition of value types', () => { ]); assertCompositionSuccess(compositionResult); - const { schema, composedSdl } = compositionResult; + const { schema, supergraphSdl } = compositionResult; expect( (schema.getType('Product') as GraphQLObjectType).getInterfaces(), ).toHaveLength(2); @@ -697,7 +697,7 @@ describe('composition of value types', () => { expect(printSchema(schema)).toContain( 'type Product implements Named & Node', ); - expect(composedSdl).toContain('type Product implements Named & Node'); + expect(supergraphSdl).toContain('type Product implements Named & Node'); }); }); diff --git a/federation-js/src/composition/compose.ts b/federation-js/src/composition/compose.ts index ad97ba58c..37f595e06 100644 --- a/federation-js/src/composition/compose.ts +++ b/federation-js/src/composition/compose.ts @@ -45,7 +45,7 @@ import { } from './types'; import { validateSDL } from 'graphql/validation/validate'; import { compositionRules } from './rules'; -import { printComposedSdl } from '../service/printComposedSdl'; +import { printSupergraphSdl } from '../service/printSupergraphSdl'; const EmptyQueryDefinition = { kind: Kind.OBJECT_TYPE_DEFINITION, @@ -156,6 +156,7 @@ export function buildMapsFromServiceList(serviceList: ServiceDefinition[]) { if ( definition.kind === Kind.OBJECT_TYPE_DEFINITION || definition.kind === Kind.OBJECT_TYPE_EXTENSION + // || definition.kind === Kind.INTERFACE_TYPE_DEFINITION ) { const typeName = definition.name.value; @@ -660,7 +661,7 @@ export function composeServices(services: ServiceDefinition[]): CompositionResul } else { return { schema, - composedSdl: printComposedSdl(schema, services), + supergraphSdl: printSupergraphSdl(schema, services), }; } } diff --git a/federation-js/src/composition/utils.ts b/federation-js/src/composition/utils.ts index a5f2100f6..9f7909d94 100644 --- a/federation-js/src/composition/utils.ts +++ b/federation-js/src/composition/utils.ts @@ -607,16 +607,16 @@ export type CompositionResult = CompositionFailure | CompositionSuccess; // Yes, it's a bit awkward that we still return a schema when errors occur. // This is old behavior that I'm choosing not to modify for now. export interface CompositionFailure { - /** @deprecated Use composedSdl instead */ + /** @deprecated Use supergraphSdl instead */ schema: GraphQLSchema; errors: GraphQLError[]; - composedSdl?: undefined; + supergraphSdl?: undefined; } export interface CompositionSuccess { - /** @deprecated Use composedSdl instead */ + /** @deprecated Use supergraphSdl instead */ schema: GraphQLSchema; - composedSdl: string; + supergraphSdl: string; errors?: undefined; } diff --git a/federation-js/src/coreSpec.ts b/federation-js/src/coreSpec.ts new file mode 100644 index 000000000..cb512c740 --- /dev/null +++ b/federation-js/src/coreSpec.ts @@ -0,0 +1,17 @@ +import { + GraphQLDirective, + DirectiveLocation, + GraphQLNonNull, + GraphQLString, +} from 'graphql'; + +export const CoreDirective = new GraphQLDirective({ + name: 'core', + locations: [DirectiveLocation.SCHEMA], + args: { + feature: { + type: new GraphQLNonNull(GraphQLString), + }, + }, + isRepeatable: true, +}); diff --git a/federation-js/src/csdlDirectives.ts b/federation-js/src/csdlDirectives.ts deleted file mode 100644 index f8cee3917..000000000 --- a/federation-js/src/csdlDirectives.ts +++ /dev/null @@ -1,97 +0,0 @@ -import { - GraphQLDirective, - DirectiveLocation, - GraphQLNonNull, - GraphQLString, - GraphQLInt, -} from 'graphql'; - -export const ComposedGraphDirective = new GraphQLDirective({ - name: 'composedGraph', - locations: [DirectiveLocation.SCHEMA], - args: { - version: { - type: GraphQLNonNull(GraphQLInt), - }, - }, -}); - -export const GraphDirective = new GraphQLDirective({ - name: 'graph', - locations: [DirectiveLocation.SCHEMA], - args: { - name: { - type: GraphQLNonNull(GraphQLString), - }, - url: { - type: GraphQLNonNull(GraphQLString), - }, - }, - isRepeatable: true, -}); - -export const OwnerDirective = new GraphQLDirective({ - name: 'owner', - locations: [DirectiveLocation.OBJECT], - args: { - graph: { - type: GraphQLNonNull(GraphQLString), - }, - }, -}); - -export const KeyDirective = new GraphQLDirective({ - name: 'key', - locations: [DirectiveLocation.OBJECT], - args: { - fields: { - type: GraphQLNonNull(GraphQLString), - }, - graph: { - type: GraphQLNonNull(GraphQLString), - }, - }, - isRepeatable: true, -}); - -export const ResolveDirective = new GraphQLDirective({ - name: 'resolve', - locations: [DirectiveLocation.FIELD_DEFINITION], - args: { - graph: { - type: GraphQLNonNull(GraphQLString), - }, - }, -}); - -export const ProvidesDirective = new GraphQLDirective({ - name: 'provides', - locations: [DirectiveLocation.FIELD_DEFINITION], - args: { - fields: { - type: GraphQLNonNull(GraphQLString), - }, - }, -}); - -export const RequiresDirective = new GraphQLDirective({ - name: 'requires', - locations: [DirectiveLocation.FIELD_DEFINITION], - args: { - fields: { - type: GraphQLNonNull(GraphQLString), - }, - }, -}); - -export const csdlDirectives = [ - ComposedGraphDirective, - GraphDirective, - OwnerDirective, - KeyDirective, - ResolveDirective, - ProvidesDirective, - RequiresDirective, -]; - -export default csdlDirectives; diff --git a/federation-js/src/directives.ts b/federation-js/src/directives.ts index dfd723c00..60e2ed3e2 100644 --- a/federation-js/src/directives.ts +++ b/federation-js/src/directives.ts @@ -26,7 +26,7 @@ export const KeyDirective = new GraphQLDirective({ locations: [DirectiveLocation.OBJECT, DirectiveLocation.INTERFACE], args: { fields: { - type: GraphQLNonNull(GraphQLString), + type: new GraphQLNonNull(GraphQLString), }, }, }); @@ -46,7 +46,7 @@ export const RequiresDirective = new GraphQLDirective({ locations: [DirectiveLocation.FIELD_DEFINITION], args: { fields: { - type: GraphQLNonNull(GraphQLString), + type: new GraphQLNonNull(GraphQLString), }, }, }); @@ -56,7 +56,7 @@ export const ProvidesDirective = new GraphQLDirective({ locations: [DirectiveLocation.FIELD_DEFINITION], args: { fields: { - type: GraphQLNonNull(GraphQLString), + type: new GraphQLNonNull(GraphQLString), }, }, }); diff --git a/federation-js/src/joinSpec.ts b/federation-js/src/joinSpec.ts new file mode 100644 index 000000000..6d5422e4f --- /dev/null +++ b/federation-js/src/joinSpec.ts @@ -0,0 +1,146 @@ +import { + GraphQLDirective, + DirectiveLocation, + GraphQLEnumType, + GraphQLScalarType, + GraphQLString, + GraphQLNonNull, +} from 'graphql'; +import { ServiceDefinition } from './composition'; + +const FieldSetScalar = new GraphQLScalarType({ + name: 'join__FieldSet', +}); + +const JoinGraphDirective = new GraphQLDirective({ + name: "join__graph", + locations: [DirectiveLocation.ENUM_VALUE], + args: { + name: { + type: new GraphQLNonNull(GraphQLString), + }, + url: { + type: new GraphQLNonNull(GraphQLString), + }, + } +}); + +/** + * Expectations + * 1. Non-Alphanumeric characters are replaced with _ (alphaNumericUnderscoreOnly) + * 2. Numeric first characters are prefixed with _ (noNumericFirstChar) + * 3. Names ending in an underscore followed by numbers `_\d+` are suffixed with _ (noUnderscoreNumericEnding) + * 4. Names are uppercased (toUpper) + * 5. After transformations 1-4, duplicates are suffixed with _{n} where {n} is number of times we've seen the dupe + * + * Note: Collisions with name's we've generated are also accounted for + */ +function getJoinGraphEnum(serviceList: ServiceDefinition[]) { + // Track whether we've seen a name and how many times + const nameMap: Map = new Map(); + // Build a map of original service name to generated name + const sanitizedServiceNames: Record = Object.create(null); + + function uniquifyAndSanitizeGraphQLName(name: string) { + // Transforms to ensure valid graphql `Name` + const alphaNumericUnderscoreOnly = name.replace(/[^_a-zA-Z0-9]/g, '_'); + const noNumericFirstChar = alphaNumericUnderscoreOnly.match(/^[0-9]/) + ? '_' + alphaNumericUnderscoreOnly + : alphaNumericUnderscoreOnly; + const noUnderscoreNumericEnding = noNumericFirstChar.match(/_[0-9]+$/) + ? noNumericFirstChar + '_' + : noNumericFirstChar; + + // toUpper not really necessary but follows convention of enum values + const toUpper = noUnderscoreNumericEnding.toLocaleUpperCase(); + + // Uniquifying post-transform + const nameCount = nameMap.get(toUpper); + if (nameCount) { + // Collision - bump counter by one + nameMap.set(toUpper, nameCount + 1); + const uniquified = `${toUpper}_${nameCount + 1}`; + // We also now need another entry for the name we just generated + nameMap.set(uniquified, 1); + sanitizedServiceNames[name] = uniquified; + return uniquified; + } else { + nameMap.set(toUpper, 1); + sanitizedServiceNames[name] = toUpper; + return toUpper; + } + } + + return { + sanitizedServiceNames, + JoinGraphEnum: new GraphQLEnumType({ + name: 'join__Graph', + values: Object.fromEntries( + serviceList.map((service) => [ + uniquifyAndSanitizeGraphQLName(service.name), + { value: service }, + ]), + ), + }), + }; +} + +function getJoinFieldDirective(JoinGraphEnum: GraphQLEnumType) { + return new GraphQLDirective({ + name: 'join__field', + locations: [DirectiveLocation.FIELD_DEFINITION], + args: { + graph: { + type: JoinGraphEnum, + }, + requires: { + type: FieldSetScalar, + }, + provides: { + type: FieldSetScalar, + }, + }, + }); +} + +function getJoinOwnerDirective(JoinGraphEnum: GraphQLEnumType) { + return new GraphQLDirective({ + name: 'join__owner', + locations: [DirectiveLocation.OBJECT, DirectiveLocation.INTERFACE], + args: { + graph: { + type: new GraphQLNonNull(JoinGraphEnum), + }, + }, + }); +} + +export function getJoins(serviceList: ServiceDefinition[]) { + const { sanitizedServiceNames, JoinGraphEnum } = getJoinGraphEnum(serviceList); + const JoinFieldDirective = getJoinFieldDirective(JoinGraphEnum); + const JoinOwnerDirective = getJoinOwnerDirective(JoinGraphEnum); + + const JoinTypeDirective = new GraphQLDirective({ + name: 'join__type', + locations: [DirectiveLocation.OBJECT, DirectiveLocation.INTERFACE], + isRepeatable: true, + args: { + graph: { + type: new GraphQLNonNull(JoinGraphEnum), + }, + key: { + type: FieldSetScalar, + }, + }, + }); + + return { + sanitizedServiceNames, + FieldSetScalar, + JoinTypeDirective, + JoinFieldDirective, + JoinOwnerDirective, + JoinGraphEnum, + JoinGraphDirective, + }; +} diff --git a/federation-js/src/service/__tests__/printComposedSdl.test.ts b/federation-js/src/service/__tests__/printComposedSdl.test.ts deleted file mode 100644 index 400a6eca9..000000000 --- a/federation-js/src/service/__tests__/printComposedSdl.test.ts +++ /dev/null @@ -1,316 +0,0 @@ -import { fixtures } from 'apollo-federation-integration-testsuite'; -import { parse, GraphQLError, visit, StringValueNode } from 'graphql'; -import { composeAndValidate, compositionHasErrors } from '../../composition'; - -describe('printComposedSdl', () => { - let composedSdl: string, errors: GraphQLError[]; - - beforeAll(() => { - // composeAndValidate calls `printComposedSdl` to return `composedSdl` - const compositionResult = composeAndValidate(fixtures); - if (compositionHasErrors(compositionResult)) { - errors = compositionResult.errors; - } else { - composedSdl = compositionResult.composedSdl; - } - }); - - it('composes without errors', () => { - expect(errors).toBeUndefined(); - }); - - it('produces a parseable output', () => { - expect(() => parse(composedSdl!)).not.toThrow(); - }); - - it('prints a fully composed schema correctly', () => { - expect(composedSdl).toMatchInlineSnapshot(` - "schema - @graph(name: \\"accounts\\", url: \\"https://accounts.api.com\\") - @graph(name: \\"books\\", url: \\"https://books.api.com\\") - @graph(name: \\"documents\\", url: \\"https://documents.api.com\\") - @graph(name: \\"inventory\\", url: \\"https://inventory.api.com\\") - @graph(name: \\"product\\", url: \\"https://product.api.com\\") - @graph(name: \\"reviews\\", url: \\"https://reviews.api.com\\") - @composedGraph(version: 1) - { - query: Query - mutation: Mutation - } - - directive @composedGraph(version: Int!) on SCHEMA - - directive @graph(name: String!, url: String!) repeatable on SCHEMA - - directive @owner(graph: String!) on OBJECT - - directive @key(fields: String!, graph: String!) repeatable on OBJECT - - directive @resolve(graph: String!) on FIELD_DEFINITION - - directive @provides(fields: String!) on FIELD_DEFINITION - - directive @requires(fields: String!) on FIELD_DEFINITION - - directive @stream on FIELD - - directive @transform(from: String!) on FIELD - - union AccountType = PasswordAccount | SMSAccount - - type Amazon { - referrer: String - } - - union Body = Image | Text - - type Book implements Product - @owner(graph: \\"books\\") - @key(fields: \\"{ isbn }\\", graph: \\"books\\") - @key(fields: \\"{ isbn }\\", graph: \\"inventory\\") - @key(fields: \\"{ isbn }\\", graph: \\"product\\") - @key(fields: \\"{ isbn }\\", graph: \\"reviews\\") - { - isbn: String! - title: String - year: Int - similarBooks: [Book]! - metadata: [MetadataOrError] - inStock: Boolean @resolve(graph: \\"inventory\\") - isCheckedOut: Boolean @resolve(graph: \\"inventory\\") - upc: String! @resolve(graph: \\"product\\") - sku: String! @resolve(graph: \\"product\\") - name(delimeter: String = \\" \\"): String @resolve(graph: \\"product\\") @requires(fields: \\"{ title year }\\") - price: String @resolve(graph: \\"product\\") - details: ProductDetailsBook @resolve(graph: \\"product\\") - reviews: [Review] @resolve(graph: \\"reviews\\") - relatedReviews: [Review!]! @resolve(graph: \\"reviews\\") @requires(fields: \\"{ similarBooks { isbn } }\\") - } - - union Brand = Ikea | Amazon - - type Car implements Vehicle - @owner(graph: \\"product\\") - @key(fields: \\"{ id }\\", graph: \\"product\\") - @key(fields: \\"{ id }\\", graph: \\"reviews\\") - { - id: String! - description: String - price: String - retailPrice: String @resolve(graph: \\"reviews\\") @requires(fields: \\"{ price }\\") - } - - type Error { - code: Int - message: String - } - - type Furniture implements Product - @owner(graph: \\"product\\") - @key(fields: \\"{ upc }\\", graph: \\"product\\") - @key(fields: \\"{ sku }\\", graph: \\"product\\") - @key(fields: \\"{ sku }\\", graph: \\"inventory\\") - @key(fields: \\"{ upc }\\", graph: \\"reviews\\") - { - upc: String! - sku: String! - name: String - price: String - brand: Brand - metadata: [MetadataOrError] - details: ProductDetailsFurniture - inStock: Boolean @resolve(graph: \\"inventory\\") - isHeavy: Boolean @resolve(graph: \\"inventory\\") - reviews: [Review] @resolve(graph: \\"reviews\\") - } - - type Ikea { - asile: Int - } - - type Image { - name: String! - attributes: ImageAttributes! - } - - type ImageAttributes { - url: String! - } - - type KeyValue { - key: String! - value: String! - } - - type Library - @owner(graph: \\"books\\") - @key(fields: \\"{ id }\\", graph: \\"books\\") - @key(fields: \\"{ id }\\", graph: \\"accounts\\") - { - id: ID! - name: String - userAccount(id: ID! = 1): User @resolve(graph: \\"accounts\\") @requires(fields: \\"{ name }\\") - } - - union MetadataOrError = KeyValue | Error - - type Mutation { - login(username: String!, password: String!): User @resolve(graph: \\"accounts\\") - reviewProduct(upc: String!, body: String!): Product @resolve(graph: \\"reviews\\") - updateReview(review: UpdateReviewInput!): Review @resolve(graph: \\"reviews\\") - deleteReview(id: ID!): Boolean @resolve(graph: \\"reviews\\") - } - - type Name { - first: String - last: String - } - - type PasswordAccount - @owner(graph: \\"accounts\\") - @key(fields: \\"{ email }\\", graph: \\"accounts\\") - { - email: String! - } - - interface Product { - upc: String! - sku: String! - name: String - price: String - details: ProductDetails - inStock: Boolean - reviews: [Review] - } - - interface ProductDetails { - country: String - } - - type ProductDetailsBook implements ProductDetails { - country: String - pages: Int - } - - type ProductDetailsFurniture implements ProductDetails { - country: String - color: String - } - - type Query { - user(id: ID!): User @resolve(graph: \\"accounts\\") - me: User @resolve(graph: \\"accounts\\") - book(isbn: String!): Book @resolve(graph: \\"books\\") - books: [Book] @resolve(graph: \\"books\\") - library(id: ID!): Library @resolve(graph: \\"books\\") - body: Body! @resolve(graph: \\"documents\\") - product(upc: String!): Product @resolve(graph: \\"product\\") - vehicle(id: String!): Vehicle @resolve(graph: \\"product\\") - topProducts(first: Int = 5): [Product] @resolve(graph: \\"product\\") - topCars(first: Int = 5): [Car] @resolve(graph: \\"product\\") - topReviews(first: Int = 5): [Review] @resolve(graph: \\"reviews\\") - } - - type Review - @owner(graph: \\"reviews\\") - @key(fields: \\"{ id }\\", graph: \\"reviews\\") - { - id: ID! - body(format: Boolean = false): String - author: User @provides(fields: \\"{ username }\\") - product: Product - metadata: [MetadataOrError] - } - - type SMSAccount - @owner(graph: \\"accounts\\") - @key(fields: \\"{ number }\\", graph: \\"accounts\\") - { - number: String - } - - type Text { - name: String! - attributes: TextAttributes! - } - - type TextAttributes { - bold: Boolean - text: String - } - - union Thing = Car | Ikea - - input UpdateReviewInput { - id: ID! - body: String - } - - type User - @owner(graph: \\"accounts\\") - @key(fields: \\"{ id }\\", graph: \\"accounts\\") - @key(fields: \\"{ username name { first last } }\\", graph: \\"accounts\\") - @key(fields: \\"{ id }\\", graph: \\"inventory\\") - @key(fields: \\"{ id }\\", graph: \\"product\\") - @key(fields: \\"{ id }\\", graph: \\"reviews\\") - { - id: ID! - name: Name - username: String - birthDate(locale: String): String - account: AccountType - metadata: [UserMetadata] - goodDescription: Boolean @resolve(graph: \\"inventory\\") @requires(fields: \\"{ metadata { description } }\\") - vehicle: Vehicle @resolve(graph: \\"product\\") - thing: Thing @resolve(graph: \\"product\\") - reviews: [Review] @resolve(graph: \\"reviews\\") - numberOfReviews: Int! @resolve(graph: \\"reviews\\") - goodAddress: Boolean @resolve(graph: \\"reviews\\") @requires(fields: \\"{ metadata { address } }\\") - } - - type UserMetadata { - name: String - address: String - description: String - } - - type Van implements Vehicle - @owner(graph: \\"product\\") - @key(fields: \\"{ id }\\", graph: \\"product\\") - @key(fields: \\"{ id }\\", graph: \\"reviews\\") - { - id: String! - description: String - price: String - retailPrice: String @resolve(graph: \\"reviews\\") @requires(fields: \\"{ price }\\") - } - - interface Vehicle { - id: String! - description: String - price: String - retailPrice: String - } - " - `); - }); - - it('fieldsets are parseable', () => { - const parsedCsdl = parse(composedSdl!); - const fieldSets: string[] = []; - - // Collect all args with the 'fields' name (from @key, @provides, @requires directives) - visit(parsedCsdl, { - Argument(node) { - if (node.name.value === 'fields') { - fieldSets.push((node.value as StringValueNode).value); - } - }, - }); - - // Ensure each found 'fields' arg is graphql parseable - fieldSets.forEach((unparsed) => { - expect(() => parse(unparsed)).not.toThrow(); - }); - }); -}); diff --git a/federation-js/src/service/__tests__/printSupergraphSdl.test.ts b/federation-js/src/service/__tests__/printSupergraphSdl.test.ts new file mode 100644 index 000000000..009c95bc1 --- /dev/null +++ b/federation-js/src/service/__tests__/printSupergraphSdl.test.ts @@ -0,0 +1,325 @@ +import { fixtures } from 'apollo-federation-integration-testsuite'; +import { parse, GraphQLError, visit, StringValueNode } from 'graphql'; +import { composeAndValidate, compositionHasErrors } from '../../composition'; + +describe('printSupergraphSdl', () => { + let supergraphSdl: string, errors: GraphQLError[]; + + beforeAll(() => { + // composeAndValidate calls `printSupergraphSdl` to return `supergraphSdl` + const compositionResult = composeAndValidate(fixtures); + if (compositionHasErrors(compositionResult)) { + errors = compositionResult.errors; + } else { + supergraphSdl = compositionResult.supergraphSdl; + } + }); + + it('composes without errors', () => { + expect(errors).toBeUndefined(); + }); + + it('produces a parseable output', () => { + expect(() => parse(supergraphSdl!)).not.toThrow(); + }); + + it('prints a fully composed schema correctly', () => { + expect(supergraphSdl).toMatchInlineSnapshot(` + "schema + @core(feature: \\"https://specs.apollo.dev/core/v0.1\\"), + @core(feature: \\"https://specs.apollo.dev/join/v0.1\\") + { + query: Query + mutation: Mutation + } + + directive @core(feature: String!) repeatable on SCHEMA + + directive @join__field(graph: join__Graph, requires: join__FieldSet, provides: join__FieldSet) on FIELD_DEFINITION + + directive @join__type(graph: join__Graph!, key: join__FieldSet) repeatable on OBJECT | INTERFACE + + directive @join__owner(graph: join__Graph!) on OBJECT | INTERFACE + + directive @join__graph(name: String!, url: String!) on ENUM_VALUE + + directive @stream on FIELD + + directive @transform(from: String!) on FIELD + + union AccountType = PasswordAccount | SMSAccount + + type Amazon { + referrer: String + } + + union Body = Image | Text + + type Book implements Product + @join__owner(graph: BOOKS) + @join__type(graph: BOOKS, key: \\"isbn\\") + @join__type(graph: INVENTORY, key: \\"isbn\\") + @join__type(graph: PRODUCT, key: \\"isbn\\") + @join__type(graph: REVIEWS, key: \\"isbn\\") + { + isbn: String! @join__field(graph: BOOKS) + title: String @join__field(graph: BOOKS) + year: Int @join__field(graph: BOOKS) + similarBooks: [Book]! @join__field(graph: BOOKS) + metadata: [MetadataOrError] @join__field(graph: BOOKS) + inStock: Boolean @join__field(graph: INVENTORY) + isCheckedOut: Boolean @join__field(graph: INVENTORY) + upc: String! @join__field(graph: PRODUCT) + sku: String! @join__field(graph: PRODUCT) + name(delimeter: String = \\" \\"): String @join__field(graph: PRODUCT, requires: \\"title year\\") + price: String @join__field(graph: PRODUCT) + details: ProductDetailsBook @join__field(graph: PRODUCT) + reviews: [Review] @join__field(graph: REVIEWS) + relatedReviews: [Review!]! @join__field(graph: REVIEWS, requires: \\"similarBooks { isbn }\\") + } + + union Brand = Ikea | Amazon + + type Car implements Vehicle + @join__owner(graph: PRODUCT) + @join__type(graph: PRODUCT, key: \\"id\\") + @join__type(graph: REVIEWS, key: \\"id\\") + { + id: String! @join__field(graph: PRODUCT) + description: String @join__field(graph: PRODUCT) + price: String @join__field(graph: PRODUCT) + retailPrice: String @join__field(graph: REVIEWS, requires: \\"price\\") + } + + type Error { + code: Int + message: String + } + + type Furniture implements Product + @join__owner(graph: PRODUCT) + @join__type(graph: PRODUCT, key: \\"upc\\") + @join__type(graph: PRODUCT, key: \\"sku\\") + @join__type(graph: INVENTORY, key: \\"sku\\") + @join__type(graph: REVIEWS, key: \\"upc\\") + { + upc: String! @join__field(graph: PRODUCT) + sku: String! @join__field(graph: PRODUCT) + name: String @join__field(graph: PRODUCT) + price: String @join__field(graph: PRODUCT) + brand: Brand @join__field(graph: PRODUCT) + metadata: [MetadataOrError] @join__field(graph: PRODUCT) + details: ProductDetailsFurniture @join__field(graph: PRODUCT) + inStock: Boolean @join__field(graph: INVENTORY) + isHeavy: Boolean @join__field(graph: INVENTORY) + reviews: [Review] @join__field(graph: REVIEWS) + } + + type Ikea { + asile: Int + } + + type Image { + name: String! + attributes: ImageAttributes! + } + + type ImageAttributes { + url: String! + } + + scalar join__FieldSet + + enum join__Graph { + ACCOUNTS @join__graph(name: \\"accounts\\" url: \\"https://accounts.api.com\\") + BOOKS @join__graph(name: \\"books\\" url: \\"https://books.api.com\\") + DOCUMENTS @join__graph(name: \\"documents\\" url: \\"https://documents.api.com\\") + INVENTORY @join__graph(name: \\"inventory\\" url: \\"https://inventory.api.com\\") + PRODUCT @join__graph(name: \\"product\\" url: \\"https://product.api.com\\") + REVIEWS @join__graph(name: \\"reviews\\" url: \\"https://reviews.api.com\\") + } + + type KeyValue { + key: String! + value: String! + } + + type Library + @join__owner(graph: BOOKS) + @join__type(graph: BOOKS, key: \\"id\\") + @join__type(graph: ACCOUNTS, key: \\"id\\") + { + id: ID! @join__field(graph: BOOKS) + name: String @join__field(graph: BOOKS) + userAccount(id: ID! = 1): User @join__field(graph: ACCOUNTS, requires: \\"name\\") + } + + union MetadataOrError = KeyValue | Error + + type Mutation { + login(username: String!, password: String!): User @join__field(graph: ACCOUNTS) + reviewProduct(upc: String!, body: String!): Product @join__field(graph: REVIEWS) + updateReview(review: UpdateReviewInput!): Review @join__field(graph: REVIEWS) + deleteReview(id: ID!): Boolean @join__field(graph: REVIEWS) + } + + type Name { + first: String + last: String + } + + type PasswordAccount + @join__owner(graph: ACCOUNTS) + @join__type(graph: ACCOUNTS, key: \\"email\\") + { + email: String! @join__field(graph: ACCOUNTS) + } + + interface Product { + upc: String! + sku: String! + name: String + price: String + details: ProductDetails + inStock: Boolean + reviews: [Review] + } + + interface ProductDetails { + country: String + } + + type ProductDetailsBook implements ProductDetails { + country: String + pages: Int + } + + type ProductDetailsFurniture implements ProductDetails { + country: String + color: String + } + + type Query { + user(id: ID!): User @join__field(graph: ACCOUNTS) + me: User @join__field(graph: ACCOUNTS) + book(isbn: String!): Book @join__field(graph: BOOKS) + books: [Book] @join__field(graph: BOOKS) + library(id: ID!): Library @join__field(graph: BOOKS) + body: Body! @join__field(graph: DOCUMENTS) + product(upc: String!): Product @join__field(graph: PRODUCT) + vehicle(id: String!): Vehicle @join__field(graph: PRODUCT) + topProducts(first: Int = 5): [Product] @join__field(graph: PRODUCT) + topCars(first: Int = 5): [Car] @join__field(graph: PRODUCT) + topReviews(first: Int = 5): [Review] @join__field(graph: REVIEWS) + } + + type Review + @join__owner(graph: REVIEWS) + @join__type(graph: REVIEWS, key: \\"id\\") + { + id: ID! @join__field(graph: REVIEWS) + body(format: Boolean = false): String @join__field(graph: REVIEWS) + author: User @join__field(graph: REVIEWS, provides: \\"username\\") + product: Product @join__field(graph: REVIEWS) + metadata: [MetadataOrError] @join__field(graph: REVIEWS) + } + + type SMSAccount + @join__owner(graph: ACCOUNTS) + @join__type(graph: ACCOUNTS, key: \\"number\\") + { + number: String @join__field(graph: ACCOUNTS) + } + + type Text { + name: String! + attributes: TextAttributes! + } + + type TextAttributes { + bold: Boolean + text: String + } + + union Thing = Car | Ikea + + input UpdateReviewInput { + id: ID! + body: String + } + + type User + @join__owner(graph: ACCOUNTS) + @join__type(graph: ACCOUNTS, key: \\"id\\") + @join__type(graph: ACCOUNTS, key: \\"username name { first last }\\") + @join__type(graph: INVENTORY, key: \\"id\\") + @join__type(graph: PRODUCT, key: \\"id\\") + @join__type(graph: REVIEWS, key: \\"id\\") + { + id: ID! @join__field(graph: ACCOUNTS) + name: Name @join__field(graph: ACCOUNTS) + username: String @join__field(graph: ACCOUNTS) + birthDate(locale: String): String @join__field(graph: ACCOUNTS) + account: AccountType @join__field(graph: ACCOUNTS) + metadata: [UserMetadata] @join__field(graph: ACCOUNTS) + goodDescription: Boolean @join__field(graph: INVENTORY, requires: \\"metadata { description }\\") + vehicle: Vehicle @join__field(graph: PRODUCT) + thing: Thing @join__field(graph: PRODUCT) + reviews: [Review] @join__field(graph: REVIEWS) + numberOfReviews: Int! @join__field(graph: REVIEWS) + goodAddress: Boolean @join__field(graph: REVIEWS, requires: \\"metadata { address }\\") + } + + type UserMetadata { + name: String + address: String + description: String + } + + type Van implements Vehicle + @join__owner(graph: PRODUCT) + @join__type(graph: PRODUCT, key: \\"id\\") + @join__type(graph: REVIEWS, key: \\"id\\") + { + id: String! @join__field(graph: PRODUCT) + description: String @join__field(graph: PRODUCT) + price: String @join__field(graph: PRODUCT) + retailPrice: String @join__field(graph: REVIEWS, requires: \\"price\\") + } + + interface Vehicle { + id: String! + description: String + price: String + retailPrice: String + } + " + `); + }); + + it('fieldsets are parseable', () => { + const parsedSupergraphSdl = parse(supergraphSdl!); + const fieldSets: string[] = []; + + // Collect all args with the `key`, `provides`, and `requires` fields + // Note: if our testing schema ever begins to include a directive with args + // that use any of these names, this test will likely fail and will need to + // be a bit less heavy-handed by searching for the specific directives + // instead of by argument name. + const argNames = ['key', 'requires', 'provides']; + visit(parsedSupergraphSdl, { + Argument(node) { + if (argNames.includes(node.name.value)) { + fieldSets.push((node.value as StringValueNode).value); + } + }, + }); + + // Ensure we're actually finding fieldSets, else this will fail quietly + expect(fieldSets).not.toHaveLength(0); + // Ensure each fieldSet arg is graphql parseable (when wrapped in curlies, as we do elsewhere) + fieldSets.forEach((unparsed) => { + expect(() => parse('{' + unparsed + '}')).not.toThrow(); + }); + }); +}); diff --git a/federation-js/src/service/printComposedSdl.ts b/federation-js/src/service/printSupergraphSdl.ts similarity index 59% rename from federation-js/src/service/printComposedSdl.ts rename to federation-js/src/service/printSupergraphSdl.ts index 14b010088..9957907b3 100644 --- a/federation-js/src/service/printComposedSdl.ts +++ b/federation-js/src/service/printSupergraphSdl.ts @@ -28,10 +28,9 @@ import { ASTNode, SelectionNode, } from 'graphql'; -import { Maybe, ServiceDefinition, FederationType, FederationField } from '../composition'; -import { isFederationType } from '../types'; -import { isFederationDirective } from '../composition/utils'; -import csdlDirectives from '../csdlDirectives'; +import { Maybe, FederationType, FederationField, ServiceDefinition } from '../composition'; +import { CoreDirective } from '../coreSpec'; +import { getJoins } from '../joinSpec'; type Options = { /** @@ -45,27 +44,59 @@ type Options = { commentDescriptions?: boolean; }; +interface PrintingContext { + // Core addition: we need access to a map from serviceName to its corresponding + // sanitized / uniquified enum value `Name` from the `join__Graph` enum + sanitizedServiceNames?: Record; +} + /** - * Accepts options as a second argument: + * Accepts options as an optional third argument: * * - commentDescriptions: * Provide true to use preceding comments as the description. * */ -export function printComposedSdl( +// Core change: we need service and url information for the join__Graph enum +export function printSupergraphSdl( schema: GraphQLSchema, serviceList: ServiceDefinition[], options?: Options, ): string { + const config = schema.toConfig(); + + const { + FieldSetScalar, + JoinFieldDirective, + JoinTypeDirective, + JoinOwnerDirective, + JoinGraphEnum, + JoinGraphDirective, + sanitizedServiceNames, + } = getJoins(serviceList); + + schema = new GraphQLSchema({ + ...config, + directives: [ + CoreDirective, + JoinFieldDirective, + JoinTypeDirective, + JoinOwnerDirective, + JoinGraphDirective, + ...config.directives, + ], + types: [FieldSetScalar, JoinGraphEnum, ...config.types], + }); + + const context: PrintingContext = { + sanitizedServiceNames, + } + return printFilteredSchema( schema, - // Federation change: we need service and url information for the @graph directives - serviceList, - // Federation change: treat the directives defined by the federation spec - // similarly to the directives defined by the GraphQL spec (ie, don't print - // their definitions). - (n) => !isSpecifiedDirective(n) && !isFederationDirective(n), + (n) => !isSpecifiedDirective(n), isDefinedType, + context, options, ); } @@ -76,56 +107,42 @@ export function printIntrospectionSchema( ): string { return printFilteredSchema( schema, - [], isSpecifiedDirective, isIntrospectionType, + {}, options, ); } -// Federation change: treat the types defined by the federation spec -// similarly to the directives defined by the GraphQL spec (ie, don't print -// their definitions). function isDefinedType(type: GraphQLNamedType): boolean { - return ( - !isSpecifiedScalarType(type) && - !isIntrospectionType(type) && - !isFederationType(type) - ); + return !isSpecifiedScalarType(type) && !isIntrospectionType(type); } function printFilteredSchema( schema: GraphQLSchema, - // Federation change: we need service and url information for the @graph directives - serviceList: ServiceDefinition[], directiveFilter: (type: GraphQLDirective) => boolean, typeFilter: (type: GraphQLNamedType) => boolean, + // Core addition - see `PrintingContext` type for details + context: PrintingContext, options?: Options, ): string { - // Federation change: include directive definitions for CSDL - const directives = [ - ...csdlDirectives, - ...schema.getDirectives().filter(directiveFilter), - ]; + const directives = schema.getDirectives().filter(directiveFilter); const types = Object.values(schema.getTypeMap()) .sort((type1, type2) => type1.name.localeCompare(type2.name)) .filter(typeFilter); return ( - [printSchemaDefinition(schema, serviceList)] + [printSchemaDefinition(schema)] .concat( - directives.map(directive => printDirective(directive, options)), - types.map(type => printType(type, options)), + directives.map((directive) => printDirective(directive, options)), + types.map((type) => printType(type, context, options)), ) .filter(Boolean) .join('\n\n') + '\n' ); } -function printSchemaDefinition( - schema: GraphQLSchema, - serviceList: ServiceDefinition[], -): string | undefined { +function printSchemaDefinition(schema: GraphQLSchema): string { const operationTypes = []; const queryType = schema.getQueryType(); @@ -145,26 +162,31 @@ function printSchemaDefinition( return ( 'schema' + - // Federation change: print @graph and @composedGraph schema directives - printFederationSchemaDirectives(serviceList) + + // Core change: print @core directive usages on schema node + printCoreDirectives() + `\n{\n${operationTypes.join('\n')}\n}` ); } -function printFederationSchemaDirectives(serviceList: ServiceDefinition[]) { - return ( - serviceList.map(service => `\n @graph(name: "${service.name}", url: "${service.url}")`).join('') + - `\n @composedGraph(version: 1)` - ); +function printCoreDirectives() { + return [ + 'https://specs.apollo.dev/core/v0.1', + 'https://specs.apollo.dev/join/v0.1', + ].map((feature) => `\n @core(feature: ${printStringLiteral(feature)})`); } -export function printType(type: GraphQLNamedType, options?: Options): string { +export function printType( + type: GraphQLNamedType, + // Core addition - see `PrintingContext` type for details + context: PrintingContext, + options?: Options, +): string { if (isScalarType(type)) { return printScalar(type, options); } else if (isObjectType(type)) { - return printObject(type, options); + return printObject(type, context, options); } else if (isInterfaceType(type)) { - return printInterface(type, options); + return printInterface(type, context, options); } else if (isUnionType(type)) { return printUnion(type, options); } else if (isEnumType(type)) { @@ -180,36 +202,33 @@ function printScalar(type: GraphQLScalarType, options?: Options): string { return printDescription(options, type) + `scalar ${type.name}`; } -function printObject(type: GraphQLObjectType, options?: Options): string { +function printObject( + type: GraphQLObjectType, + // Core addition - see `PrintingContext` type for details + context: PrintingContext, + options?: Options, +): string { const interfaces = type.getInterfaces(); const implementedInterfaces = interfaces.length - ? ' implements ' + interfaces.map(i => i.name).join(' & ') + ? ' implements ' + interfaces.map((i) => i.name).join(' & ') : ''; - // Federation change: print `extend` keyword on type extensions. - // - // The implementation assumes that an owned type will have fields defined - // since that is required for a valid schema. Types that are *only* - // extensions will not have fields on the astNode since that ast doesn't - // exist. - // - // XXX revist extension checking - const isExtension = - type.extensionASTNodes && type.astNode && !type.astNode.fields; - return ( printDescription(options, type) + - (isExtension ? 'extend ' : '') + `type ${type.name}` + implementedInterfaces + - // Federation addition for printing @owner and @key usages - printFederationTypeDirectives(type) + - printFields(options, type) + // Core addition for printing @join__owner and @join__type usages + printTypeJoinDirectives(type, context) + + printFields(options, type, context) ); } -// Federation change: print usages of the @owner and @key directives. -function printFederationTypeDirectives(type: GraphQLObjectType): string { +// Core change: print @join__owner and @join__type usages +function printTypeJoinDirectives( + type: GraphQLObjectType | GraphQLInterfaceType, + // Core addition - see `PrintingContext` type for details + context: PrintingContext, +): string { const metadata: FederationType = type.extensions?.federation; if (!metadata) return ''; @@ -218,20 +237,31 @@ function printFederationTypeDirectives(type: GraphQLObjectType): string { // Separate owner @keys from the rest of the @keys so we can print them // adjacent to the @owner directive. - const { [ownerService]: ownerKeys = [], ...restKeys } = keys - const ownerEntry: [string, (readonly SelectionNode[])[]] = [ownerService, ownerKeys]; + const { [ownerService]: ownerKeys = [], ...restKeys } = keys; + const ownerEntry: [string, (readonly SelectionNode[])[]] = [ + ownerService, + ownerKeys, + ]; const restEntries = Object.entries(restKeys); + // We don't want to print an owner for interface types + const shouldPrintOwner = isObjectType(type); + const joinOwnerString = shouldPrintOwner + ? `\n @join__owner(graph: ${ + context.sanitizedServiceNames?.[ownerService] ?? ownerService + })` + : ''; + return ( - `\n @owner(graph: "${ownerService}")` + + joinOwnerString + [ownerEntry, ...restEntries] .map(([service, keys = []]) => keys .map( (selections) => - `\n @key(fields: "${printFieldSet( - selections, - )}", graph: "${service}")`, + `\n @join__type(graph: ${ + context.sanitizedServiceNames?.[service] ?? service + }, key: ${printStringLiteral(printFieldSet(selections))})`, ) .join(''), ) @@ -239,19 +269,18 @@ function printFederationTypeDirectives(type: GraphQLObjectType): string { ); } -function printInterface(type: GraphQLInterfaceType, options?: Options): string { - // Federation change: print `extend` keyword on type extensions. - // See printObject for assumptions made. - // - // XXX revist extension checking - const isExtension = - type.extensionASTNodes && type.astNode && !type.astNode.fields; - +function printInterface( + type: GraphQLInterfaceType, + // Core addition - see `PrintingContext` type for details + context: PrintingContext, + options?: Options, +): string { return ( printDescription(options, type) + - (isExtension ? 'extend ' : '') + `interface ${type.name}` + - printFields(options, type) + // Core addition for printing @join__owner and @join__type usages + printTypeJoinDirectives(type, context) + + printFields(options, type, context) ); } @@ -269,7 +298,8 @@ function printEnum(type: GraphQLEnumType, options?: Options): string { printDescription(options, value, ' ', !i) + ' ' + value.name + - printDeprecated(value), + printDeprecated(value) + + printDirectivesOnEnumValue(type, value), ); return ( @@ -277,6 +307,13 @@ function printEnum(type: GraphQLEnumType, options?: Options): string { ); } +function printDirectivesOnEnumValue(type: GraphQLEnumType, value: GraphQLEnumValue) { + if (type.name === "join__Graph") { + return ` @join__graph(name: ${printStringLiteral((value.value.name))} url: ${printStringLiteral(value.value.url ?? '')})` + } + return ''; +} + function printInputObject( type: GraphQLInputObjectType, options?: Options, @@ -293,8 +330,9 @@ function printInputObject( function printFields( options: Options | undefined, type: GraphQLObjectType | GraphQLInterfaceType, + // Core addition - see `PrintingContext` type for details + context: PrintingContext, ) { - const fields = Object.values(type.getFields()).map( (f, i) => printDescription(options, f, ' ', !i) + @@ -304,10 +342,11 @@ function printFields( ': ' + String(f.type) + printDeprecated(f) + - printFederationFieldDirectives(f, type), + // We don't want to print field owner directives on fields belonging to an interface type + (isObjectType(type) ? printJoinFieldDirectives(f, type, context) : ''), ); - // Federation change: for entities, we want to print the block on a new line. + // Core change: for entities, we want to print the block on a new line. // This is just a formatting nice-to-have. const isEntity = Boolean(type.extensions?.federation?.keys); @@ -321,25 +360,52 @@ export function printWithReducedWhitespace(ast: ASTNode): string { } /** - * Federation change: print fieldsets for @key, @requires, and @provides directives + * Core change: print fieldsets for @join__field's @key, @requires, and @provides args * * @param selections */ function printFieldSet(selections: readonly SelectionNode[]): string { - return `{ ${selections.map(printWithReducedWhitespace).join(' ')} }`; + return `${selections.map(printWithReducedWhitespace).join(' ')}`; } /** - * Federation change: print @resolve, @requires, and @provides directives + * Core change: print @join__field directives * * @param field * @param parentType */ -function printFederationFieldDirectives( +function printJoinFieldDirectives( field: GraphQLField, parentType: GraphQLObjectType | GraphQLInterfaceType, + // Core addition - see `PrintingContext` type for details + context: PrintingContext, ): string { - if (!field.extensions?.federation) return ''; + let printed = ' @join__field('; + // Fields on the owning service do not have any federation metadata applied + // TODO: maybe make this metadata available? Though I think this is intended and we may depend on that implicity. + + if (!field.extensions?.federation) { + // FIXME: We should change the way we detect value types. If a type is + // defined in only one service, we currently don't consider it a value type + // even if it doesn't specify any keys. + // Because we print `@join__type` directives based on the keys, but only used to + // look at the owning service here, that meant we would print `@join__field` + // without a corresponding `@join__type`, which is invalid according to the spec. + if ( + parentType.extensions?.federation?.serviceName && + parentType.extensions?.federation?.keys + ) { + return ( + printed + + `graph: ${ + context.sanitizedServiceNames?.[ + parentType.extensions?.federation.serviceName + ] ?? parentType.extensions?.federation.serviceName + })` + ); + } + return ''; + } const { serviceName, @@ -347,28 +413,28 @@ function printFederationFieldDirectives( provides = [], }: FederationField = field.extensions.federation; - let printed = ''; - // If a `serviceName` exists, we only want to print a `@resolve` directive - // if the `serviceName` differs from the `parentType`'s `serviceName` - if ( - serviceName && - serviceName !== parentType.extensions?.federation?.serviceName - ) { - printed += ` @resolve(graph: "${serviceName}")`; + let directiveArgs: string[] = []; + + if (serviceName && serviceName.length > 0) { + directiveArgs.push( + `graph: ${context.sanitizedServiceNames?.[serviceName] ?? serviceName}`, + ); } if (requires.length > 0) { - printed += ` @requires(fields: "${printFieldSet(requires)}")`; + directiveArgs.push(`requires: ${printStringLiteral(printFieldSet(requires))}`); } if (provides.length > 0) { - printed += ` @provides(fields: "${printFieldSet(provides)}")`; + directiveArgs.push(`provides: ${printStringLiteral(printFieldSet(provides))}`); } - return printed; + printed += directiveArgs.join(', '); + + return (printed += ')'); } -// Federation change: `onNewLine` is a formatting nice-to-have for printing +// Core change: `onNewLine` is a formatting nice-to-have for printing // types that have a list of directives attached, i.e. an entity. function printBlock(items: string[], onNewLine?: boolean) { return items.length !== 0 @@ -481,6 +547,14 @@ function printDescriptionWithComments( return prefix + comment + '\n'; } +// Using JSON.stringify ensures that we will generate a valid string literal, +// escaping quote marks, backslashes, etc. when needed. +// The `graphql-js` printer also does this when printing out a `StringValue`: +// https://github.com/graphql/graphql-js/blob/d4bcde8d3e7a7cb8462044ff21122a3996af8655/src/language/printer.js#L109-L112 +function printStringLiteral(value: string) { + return JSON.stringify(value); +} + /** * Print a block string in the indented block form by adding a leading and * trailing blank line. However, if a block string starts with whitespace and is diff --git a/gateway-js/CHANGELOG.md b/gateway-js/CHANGELOG.md index 8831ee124..938aecd36 100644 --- a/gateway-js/CHANGELOG.md +++ b/gateway-js/CHANGELOG.md @@ -4,7 +4,8 @@ > The changes noted within this `vNEXT` section have not been released yet. New PRs and commits which introduce changes should include an entry in this `vNEXT` section as part of their development. When a release is being prepared, a new header will be (manually) created below and the appropriate changes within that release will be moved into the new section. -- _Nothing yet! Stay tuned!_ +- Re-introduce TypeScript query planner to the gateway. This change should effectively be an implementation detail - it's undergone extensive testing to ensure compatibility with current query plans. [PR #622](https://github.com/apollographql/federation/pull/622) +- __BREAKING__ - All references to CSDL within the gateway have been updated to its latest iteration `Supergraph SDL` which is very similar in spirit, but implements the currently-being-introduced core and join specs. This includes changes to recent external API additions like the `csdl` and `experimental_updateCsdl` gateway constructor options. [PR #622](https://github.com/apollographql/federation/pull/622) ## v0.25.1 diff --git a/gateway-js/src/__generated__/graphqlTypes.ts b/gateway-js/src/__generated__/graphqlTypes.ts index 15d2835fd..425c8f472 100644 --- a/gateway-js/src/__generated__/graphqlTypes.ts +++ b/gateway-js/src/__generated__/graphqlTypes.ts @@ -68,17 +68,18 @@ export enum CacheControlScope { Private = 'PRIVATE' } -export type CsdlQueryVariables = Exact<{ +export type SupergraphSdlQueryVariables = Exact<{ apiKey: Scalars['String']; ref: Scalars['String']; }>; -export type CsdlQuery = ( +export type SupergraphSdlQuery = ( { __typename?: 'Query' } & { routerConfig: ( { __typename: 'RouterConfigResult' } - & Pick + & Pick + & { supergraphSdl: RouterConfigResult['csdl'] } ) | ( { __typename: 'FetchError' } & Pick diff --git a/gateway-js/src/__tests__/buildQueryPlan.test.ts b/gateway-js/src/__tests__/buildQueryPlan.test.ts index 8c061bf3b..120700a66 100644 --- a/gateway-js/src/__tests__/buildQueryPlan.test.ts +++ b/gateway-js/src/__tests__/buildQueryPlan.test.ts @@ -1080,6 +1080,14 @@ describe('buildQueryPlan', () => { } } + fragment __QueryPlanFragment_1__ on Review { + body + author + product { + ...__QueryPlanFragment_0__ + } + } + fragment __QueryPlanFragment_0__ on Product { __typename ... on Book { @@ -1091,14 +1099,6 @@ describe('buildQueryPlan', () => { upc } } - - fragment __QueryPlanFragment_1__ on Review { - body - author - product { - ...__QueryPlanFragment_0__ - } - } }, Parallel { Sequence { @@ -1290,6 +1290,14 @@ describe('buildQueryPlan', () => { } } + fragment __QueryPlanFragment_1__ on Review { + content: body + author + product { + ...__QueryPlanFragment_0__ + } + } + fragment __QueryPlanFragment_0__ on Product { __typename ... on Book { @@ -1301,14 +1309,6 @@ describe('buildQueryPlan', () => { upc } } - - fragment __QueryPlanFragment_1__ on Review { - content: body - author - product { - ...__QueryPlanFragment_0__ - } - } }, Parallel { Sequence { diff --git a/gateway-js/src/__tests__/execution-utils.ts b/gateway-js/src/__tests__/execution-utils.ts index a5c176310..bac172d4d 100644 --- a/gateway-js/src/__tests__/execution-utils.ts +++ b/gateway-js/src/__tests__/execution-utils.ts @@ -23,7 +23,7 @@ import { mergeDeep } from 'apollo-utilities'; import { queryPlanSerializer, astSerializer } from 'apollo-federation-integration-testsuite'; import gql from 'graphql-tag'; import { fixtures } from 'apollo-federation-integration-testsuite'; -import { getQueryPlanner } from '@apollo/query-planner-wasm'; +import { getQueryPlanner } from '@apollo/query-planner'; const prettyFormat = require('pretty-format'); @@ -107,15 +107,15 @@ export function getFederatedTestingSchema(services: ServiceDefinitionModule[] = throw new GraphQLSchemaValidationError(compositionResult.errors); } - const queryPlannerPointer = getQueryPlanner(compositionResult.composedSdl); + const queryPlannerPointer = getQueryPlanner(compositionResult.supergraphSdl); return { serviceMap, schema: compositionResult.schema, queryPlannerPointer }; } -export function getTestingCsdl(services: typeof fixtures = fixtures) { +export function getTestingSupergraphSdl(services: typeof fixtures = fixtures) { const compositionResult = composeAndValidate(services); if (!compositionHasErrors(compositionResult)) { - return compositionResult.composedSdl; + return compositionResult.supergraphSdl; } throw new Error("Testing fixtures don't compose properly!"); } diff --git a/gateway-js/src/__tests__/gateway/composedSdl.test.ts b/gateway-js/src/__tests__/gateway/composedSdl.test.ts index c60ba1cf0..6211d8079 100644 --- a/gateway-js/src/__tests__/gateway/composedSdl.test.ts +++ b/gateway-js/src/__tests__/gateway/composedSdl.test.ts @@ -1,12 +1,12 @@ import { ApolloGateway } from '@apollo/gateway'; import { ApolloServer } from 'apollo-server'; import { fetch } from '../../__mocks__/apollo-server-env'; -import { getTestingCsdl } from '../execution-utils'; +import { getTestingSupergraphSdl } from '../execution-utils'; -async function getCsdlGatewayServer() { +async function getSupergraphSdlGatewayServer() { const server = new ApolloServer({ gateway: new ApolloGateway({ - csdl: getTestingCsdl(), + supergraphSdl: getTestingSupergraphSdl(), }), subscriptions: false, engine: false, @@ -16,9 +16,9 @@ async function getCsdlGatewayServer() { return server; } -describe('Using csdl configuration', () => { +describe('Using supergraphSdl configuration', () => { it('successfully starts and serves requests to the proper services', async () => { - const server = await getCsdlGatewayServer(); + const server = await getSupergraphSdlGatewayServer(); fetch.mockJSONResponseOnce({ data: { me: { id: 1, username: '@jbaxleyiii' } }, diff --git a/gateway-js/src/__tests__/integration/configuration.test.ts b/gateway-js/src/__tests__/integration/configuration.test.ts index 083a0efcd..7eeeac7d6 100644 --- a/gateway-js/src/__tests__/integration/configuration.test.ts +++ b/gateway-js/src/__tests__/integration/configuration.test.ts @@ -3,12 +3,12 @@ import mockedEnv from 'mocked-env'; import { Logger } from 'apollo-server-types'; import { ApolloGateway } from '../..'; import { - mockSDLQuerySuccess, - mockCsdlRequestSuccess, + mockSdlQuerySuccess, + mockSupergraphSdlRequestSuccess, mockApolloConfig, mockCloudConfigUrl, } from './nockMocks'; -import { getTestingCsdl } from '../execution-utils'; +import { getTestingSupergraphSdl } from '../execution-utils'; import { MockService } from './networkRequests.test'; let logger: Logger; @@ -53,9 +53,9 @@ describe('gateway configuration warnings', () => { gateway = null; } }); - it('warns when both csdl and studio configuration are provided', async () => { + it('warns when both supergraphSdl and studio configuration are provided', async () => { gateway = new ApolloGateway({ - csdl: getTestingCsdl(), + supergraphSdl: getTestingSupergraphSdl(), logger, }); @@ -63,22 +63,22 @@ describe('gateway configuration warnings', () => { expect(logger.warn).toHaveBeenCalledWith( 'A local gateway configuration is overriding a managed federation configuration.' + - ' To use the managed configuration, do not specify a service list or csdl locally.', + ' To use the managed configuration, do not specify a service list or supergraphSdl locally.', ); }); it('warns when both manual update configurations are provided', async () => { gateway = new ApolloGateway({ // @ts-ignore - async experimental_updateCsdl() {}, + async experimental_updateSupergraphSdl() {}, async experimental_updateServiceDefinitions() {}, logger, }); expect(logger.warn).toHaveBeenCalledWith( 'Gateway found two manual update configurations when only one should be ' + - 'provided. Gateway will default to using the provided `experimental_updateCsdl` ' + - 'function when both `experimental_updateCsdl` and experimental_updateServiceDefinitions` ' + + 'provided. Gateway will default to using the provided `experimental_updateSupergraphSdl` ' + + 'function when both `experimental_updateSupergraphSdl` and experimental_updateServiceDefinitions` ' + 'are provided.', ); @@ -88,7 +88,7 @@ describe('gateway configuration warnings', () => { }); it('conflicting configurations are warned about when present', async () => { - mockSDLQuerySuccess(service); + mockSdlQuerySuccess(service); gateway = new ApolloGateway({ serviceList: [{ name: 'accounts', url: service.url }], @@ -105,7 +105,7 @@ describe('gateway configuration warnings', () => { }); it('conflicting configurations are not warned about when absent', async () => { - mockCsdlRequestSuccess(); + mockSupergraphSdlRequestSuccess(); gateway = new ApolloGateway({ logger, @@ -130,7 +130,7 @@ describe('gateway configuration warnings', () => { }); expect(gateway.load()).rejects.toThrowErrorMatchingInlineSnapshot( - `"When a manual configuration is not provided, gateway requires an Apollo configuration. See https://www.apollographql.com/docs/apollo-server/federation/managed-federation/ for more information. Manual configuration options include: \`serviceList\`, \`csdl\`, and \`experimental_updateServiceDefinitions\`."`, + `"When a manual configuration is not provided, gateway requires an Apollo configuration. See https://www.apollographql.com/docs/apollo-server/federation/managed-federation/ for more information. Manual configuration options include: \`serviceList\`, \`supergraphSdl\`, and \`experimental_updateServiceDefinitions\`."`, ); // Set to `null` so we don't try to call `stop` on it in the `afterEach`, diff --git a/gateway-js/src/__tests__/integration/legacyNockMocks.ts b/gateway-js/src/__tests__/integration/legacyNockMocks.ts index a35b9282b..396435d3b 100644 --- a/gateway-js/src/__tests__/integration/legacyNockMocks.ts +++ b/gateway-js/src/__tests__/integration/legacyNockMocks.ts @@ -8,14 +8,14 @@ const storageSecret = 'my-storage-secret'; const accountsService = 'accounts'; // Service mocks -function mockSDLQuery({ url }: MockService) { +function mockSdlQuery({ url }: MockService) { return nock(url).post('/', { query: SERVICE_DEFINITION_QUERY, }); } -export function mockSDLQuerySuccess(service: MockService) { - mockSDLQuery(service).reply(200, { +export function mockSdlQuerySuccess(service: MockService) { + mockSdlQuery(service).reply(200, { data: { _service: { sdl: service.sdl } }, }); } diff --git a/gateway-js/src/__tests__/integration/networkRequests.test.ts b/gateway-js/src/__tests__/integration/networkRequests.test.ts index ff7993f17..280b8b70d 100644 --- a/gateway-js/src/__tests__/integration/networkRequests.test.ts +++ b/gateway-js/src/__tests__/integration/networkRequests.test.ts @@ -5,12 +5,12 @@ import mockedEnv from 'mocked-env'; import { Logger } from 'apollo-server-types'; import { ApolloGateway } from '../..'; import { - mockSDLQuerySuccess, + mockSdlQuerySuccess, mockServiceHealthCheckSuccess, mockAllServicesHealthCheckSuccess, mockServiceHealthCheck, - mockCsdlRequestSuccess, - mockCsdlRequest, + mockSupergraphSdlRequestSuccess, + mockSupergraphSdlRequest, mockApolloConfig, mockCloudConfigUrl, } from './nockMocks'; @@ -23,7 +23,7 @@ import { product, reviews, } from 'apollo-federation-integration-testsuite'; -import { getTestingCsdl } from '../execution-utils'; +import { getTestingSupergraphSdl } from '../execution-utils'; export interface MockService { name: string; @@ -91,7 +91,7 @@ afterEach(async () => { }); it('Queries remote endpoints for their SDLs', async () => { - mockSDLQuerySuccess(simpleService); + mockSdlQuerySuccess(simpleService); gateway = new ApolloGateway({ serviceList: [simpleService] }); await gateway.load(); @@ -99,8 +99,8 @@ it('Queries remote endpoints for their SDLs', async () => { }); // TODO(trevor:cloudconfig): Remove all usages of the experimental config option -it('Fetches CSDL from remote storage', async () => { - mockCsdlRequestSuccess(); +it('Fetches Supergraph SDL from remote storage', async () => { + mockSupergraphSdlRequestSuccess(); gateway = new ApolloGateway({ logger, @@ -113,11 +113,11 @@ it('Fetches CSDL from remote storage', async () => { }); // TODO(trevor:cloudconfig): This test should evolve to demonstrate overriding the default in the future -it('Fetches CSDL from remote storage using a configured env variable', async () => { +it('Fetches Supergraph SDL from remote storage using a configured env variable', async () => { cleanUp = mockedEnv({ APOLLO_SCHEMA_CONFIG_DELIVERY_ENDPOINT: mockCloudConfigUrl, }); - mockCsdlRequestSuccess(); + mockSupergraphSdlRequestSuccess(); gateway = new ApolloGateway({ logger, @@ -128,9 +128,9 @@ it('Fetches CSDL from remote storage using a configured env variable', async () expect(gateway.schema?.getType('User')).toBeTruthy(); }); -it('Updates CSDL from remote storage', async () => { - mockCsdlRequestSuccess(); - mockCsdlRequestSuccess(getTestingCsdl(fixturesWithUpdate), 'updatedId-5678'); +it('Updates Supergraph SDL from remote storage', async () => { + mockSupergraphSdlRequestSuccess(); + mockSupergraphSdlRequestSuccess(getTestingSupergraphSdl(fixturesWithUpdate), 'updatedId-5678'); // This test is only interested in the second time the gateway notifies of an // update, since the first happens on load. @@ -158,9 +158,9 @@ it('Updates CSDL from remote storage', async () => { expect(gateway['compositionId']).toMatchInlineSnapshot(`"updatedId-5678"`); }); -describe('CSDL update failures', () => { +describe('Supergraph SDL update failures', () => { it('Gateway throws on initial load failure', async () => { - mockCsdlRequest().reply(401); + mockSupergraphSdlRequest().reply(401); gateway = new ApolloGateway({ logger, @@ -182,8 +182,8 @@ describe('CSDL update failures', () => { }); it('Handles arbitrary fetch failures (non 200 response)', async () => { - mockCsdlRequestSuccess(); - mockCsdlRequest().reply(500); + mockSupergraphSdlRequestSuccess(); + mockSupergraphSdlRequest().reply(500); // Spy on logger.error so we can just await once it's been called let errorLogged: Function; @@ -207,8 +207,8 @@ describe('CSDL update failures', () => { }); it('Handles GraphQL errors', async () => { - mockCsdlRequestSuccess(); - mockCsdlRequest().reply(200, { + mockSupergraphSdlRequestSuccess(); + mockSupergraphSdlRequest().reply(200, { errors: [ { message: 'Cannot query field "fail" on type "Query".', @@ -240,16 +240,16 @@ describe('CSDL update failures', () => { ); }); - it("Doesn't update and logs on receiving unparseable CSDL", async () => { - mockCsdlRequestSuccess(); - mockCsdlRequest().reply( + it("Doesn't update and logs on receiving unparseable Supergraph SDL", async () => { + mockSupergraphSdlRequestSuccess(); + mockSupergraphSdlRequest().reply( 200, JSON.stringify({ data: { routerConfig: { __typename: 'RouterConfigResult', id: 'failure', - csdl: 'Syntax Error - invalid SDL', + supergraphSdl: 'Syntax Error - invalid SDL', }, }, }), @@ -276,15 +276,15 @@ describe('CSDL update failures', () => { expect(gateway.schema).toBeTruthy(); }); - it('Throws on initial load when receiving unparseable CSDL', async () => { - mockCsdlRequest().reply( + it('Throws on initial load when receiving unparseable Supergraph SDL', async () => { + mockSupergraphSdlRequest().reply( 200, JSON.stringify({ data: { routerConfig: { __typename: 'RouterConfigResult', id: 'failure', - csdl: 'Syntax Error - invalid SDL', + supergraphSdl: 'Syntax Error - invalid SDL', }, }, }), @@ -313,9 +313,9 @@ describe('CSDL update failures', () => { it('Rollsback to a previous schema when triggered', async () => { // Init - mockCsdlRequestSuccess(); - mockCsdlRequestSuccess(getTestingCsdl(fixturesWithUpdate), 'updatedId-5678'); - mockCsdlRequestSuccess(); + mockSupergraphSdlRequestSuccess(); + mockSupergraphSdlRequestSuccess(getTestingSupergraphSdl(fixturesWithUpdate), 'updatedId-5678'); + mockSupergraphSdlRequestSuccess(); let firstResolve: Function; let secondResolve: Function; @@ -353,7 +353,7 @@ it('Rollsback to a previous schema when triggered', async () => { describe('Downstream service health checks', () => { describe('Unmanaged mode', () => { it(`Performs health checks to downstream services on load`, async () => { - mockSDLQuerySuccess(simpleService); + mockSdlQuerySuccess(simpleService); mockServiceHealthCheckSuccess(simpleService); gateway = new ApolloGateway({ @@ -369,7 +369,7 @@ describe('Downstream service health checks', () => { }); it(`Rejects on initial load when health check fails`, async () => { - mockSDLQuerySuccess(simpleService); + mockSdlQuerySuccess(simpleService); mockServiceHealthCheck(simpleService).reply(500); gateway = new ApolloGateway({ @@ -410,7 +410,7 @@ describe('Downstream service health checks', () => { describe('Managed mode', () => { it('Performs health checks to downstream services on load', async () => { - mockCsdlRequestSuccess(); + mockSupergraphSdlRequestSuccess(); mockAllServicesHealthCheckSuccess(); gateway = new ApolloGateway({ @@ -428,7 +428,7 @@ describe('Downstream service health checks', () => { }); it('Rejects on initial load when health check fails', async () => { - mockCsdlRequestSuccess(); + mockSupergraphSdlRequestSuccess(); mockServiceHealthCheck(accounts).reply(500); mockServiceHealthCheckSuccess(books); mockServiceHealthCheckSuccess(inventory); @@ -476,12 +476,12 @@ describe('Downstream service health checks', () => { // I've decided to skip this test for now with hopes that we can one day // determine the root cause and test this behavior in a reliable manner. it('Rolls over to new schema when health check succeeds', async () => { - mockCsdlRequestSuccess(); + mockSupergraphSdlRequestSuccess(); mockAllServicesHealthCheckSuccess(); // Update - mockCsdlRequestSuccess( - getTestingCsdl(fixturesWithUpdate), + mockSupergraphSdlRequestSuccess( + getTestingSupergraphSdl(fixturesWithUpdate), 'updatedId-5678', ); mockAllServicesHealthCheckSuccess(); @@ -519,12 +519,12 @@ describe('Downstream service health checks', () => { }); it('Preserves original schema when health check fails', async () => { - mockCsdlRequestSuccess(); + mockSupergraphSdlRequestSuccess(); mockAllServicesHealthCheckSuccess(); // Update (with one health check failure) - mockCsdlRequestSuccess( - getTestingCsdl(fixturesWithUpdate), + mockSupergraphSdlRequestSuccess( + getTestingSupergraphSdl(fixturesWithUpdate), 'updatedId-5678', ); mockServiceHealthCheck(accounts).reply(500); diff --git a/gateway-js/src/__tests__/integration/nockMocks.ts b/gateway-js/src/__tests__/integration/nockMocks.ts index e10041b9a..7e5580a91 100644 --- a/gateway-js/src/__tests__/integration/nockMocks.ts +++ b/gateway-js/src/__tests__/integration/nockMocks.ts @@ -1,8 +1,8 @@ import nock from 'nock'; import { MockService } from './networkRequests.test'; import { HEALTH_CHECK_QUERY, SERVICE_DEFINITION_QUERY } from '../..'; -import { CSDL_QUERY } from '../../loadCsdlFromStorage'; -import { getTestingCsdl } from '../../__tests__/execution-utils'; +import { SUPERGRAPH_SDL_QUERY } from '../../loadSupergraphSdlFromStorage'; +import { getTestingSupergraphSdl } from '../../__tests__/execution-utils'; import { print } from 'graphql'; import { fixtures } from 'apollo-federation-integration-testsuite'; @@ -21,14 +21,14 @@ export const mockApolloConfig = { }; // Service mocks -function mockSDLQuery({ url }: MockService) { +function mockSdlQuery({ url }: MockService) { return nock(url).post('/', { query: SERVICE_DEFINITION_QUERY, }); } -export function mockSDLQuerySuccess(service: MockService) { - mockSDLQuery(service).reply(200, { +export function mockSdlQuerySuccess(service: MockService) { + mockSdlQuery(service).reply(200, { data: { _service: { sdl: print(service.typeDefs) } }, }); } @@ -53,7 +53,7 @@ export function mockAllServicesHealthCheckSuccess() { ); } -// CSDL fetching mocks +// Supergraph SDL fetching mocks function gatewayNock(url: Parameters[0]): nock.Scope { const { name, version } = require('../../../package.json'); return nock(url, { @@ -69,9 +69,9 @@ function gatewayNock(url: Parameters[0]): nock.Scope { export const mockCloudConfigUrl = 'https://example.cloud-config-url.com/cloudconfig/'; -export function mockCsdlRequest() { +export function mockSupergraphSdlRequest() { return gatewayNock(mockCloudConfigUrl).post('/', { - query: CSDL_QUERY, + query: SUPERGRAPH_SDL_QUERY, variables: { ref: `${graphId}@${graphVariant}`, apiKey: apiKey, @@ -79,18 +79,18 @@ export function mockCsdlRequest() { }); } -export function mockCsdlRequestSuccess( - csdl = getTestingCsdl(), +export function mockSupergraphSdlRequestSuccess( + supergraphSdl = getTestingSupergraphSdl(), id = 'originalId-1234', ) { - return mockCsdlRequest().reply( + return mockSupergraphSdlRequest().reply( 200, JSON.stringify({ data: { routerConfig: { __typename: 'RouterConfigResult', id, - csdl, + supergraphSdl, }, }, }), diff --git a/gateway-js/src/__tests__/loadCsdlFromStorage.test.ts b/gateway-js/src/__tests__/loadCsdlFromStorage.test.ts deleted file mode 100644 index 29a151c20..000000000 --- a/gateway-js/src/__tests__/loadCsdlFromStorage.test.ts +++ /dev/null @@ -1,354 +0,0 @@ -import { loadCsdlFromStorage } from '../loadCsdlFromStorage'; -import { getDefaultFetcher } from '../..'; -import { - mockCsdlRequestSuccess, - graphId, - graphVariant, - apiKey, - mockCloudConfigUrl, - mockCsdlRequest, -} from './integration/nockMocks'; - -describe('loadCsdlFromStorage', () => { - it('fetches CSDL as expected', async () => { - mockCsdlRequestSuccess(); - const fetcher = getDefaultFetcher(); - const result = await loadCsdlFromStorage({ - graphId, - graphVariant, - apiKey, - endpoint: mockCloudConfigUrl, - fetcher, - }); - - expect(result).toMatchInlineSnapshot(` - Object { - "csdl": "schema - @graph(name: \\"accounts\\", url: \\"https://accounts.api.com\\") - @graph(name: \\"books\\", url: \\"https://books.api.com\\") - @graph(name: \\"documents\\", url: \\"https://documents.api.com\\") - @graph(name: \\"inventory\\", url: \\"https://inventory.api.com\\") - @graph(name: \\"product\\", url: \\"https://product.api.com\\") - @graph(name: \\"reviews\\", url: \\"https://reviews.api.com\\") - @composedGraph(version: 1) - { - query: Query - mutation: Mutation - } - - directive @composedGraph(version: Int!) on SCHEMA - - directive @graph(name: String!, url: String!) repeatable on SCHEMA - - directive @owner(graph: String!) on OBJECT - - directive @key(fields: String!, graph: String!) repeatable on OBJECT - - directive @resolve(graph: String!) on FIELD_DEFINITION - - directive @provides(fields: String!) on FIELD_DEFINITION - - directive @requires(fields: String!) on FIELD_DEFINITION - - directive @stream on FIELD - - directive @transform(from: String!) on FIELD - - union AccountType = PasswordAccount | SMSAccount - - type Amazon { - referrer: String - } - - union Body = Image | Text - - type Book implements Product - @owner(graph: \\"books\\") - @key(fields: \\"{ isbn }\\", graph: \\"books\\") - @key(fields: \\"{ isbn }\\", graph: \\"inventory\\") - @key(fields: \\"{ isbn }\\", graph: \\"product\\") - @key(fields: \\"{ isbn }\\", graph: \\"reviews\\") - { - isbn: String! - title: String - year: Int - similarBooks: [Book]! - metadata: [MetadataOrError] - inStock: Boolean @resolve(graph: \\"inventory\\") - isCheckedOut: Boolean @resolve(graph: \\"inventory\\") - upc: String! @resolve(graph: \\"product\\") - sku: String! @resolve(graph: \\"product\\") - name(delimeter: String = \\" \\"): String @resolve(graph: \\"product\\") @requires(fields: \\"{ title year }\\") - price: String @resolve(graph: \\"product\\") - details: ProductDetailsBook @resolve(graph: \\"product\\") - reviews: [Review] @resolve(graph: \\"reviews\\") - relatedReviews: [Review!]! @resolve(graph: \\"reviews\\") @requires(fields: \\"{ similarBooks { isbn } }\\") - } - - union Brand = Ikea | Amazon - - type Car implements Vehicle - @owner(graph: \\"product\\") - @key(fields: \\"{ id }\\", graph: \\"product\\") - @key(fields: \\"{ id }\\", graph: \\"reviews\\") - { - id: String! - description: String - price: String - retailPrice: String @resolve(graph: \\"reviews\\") @requires(fields: \\"{ price }\\") - } - - type Error { - code: Int - message: String - } - - type Furniture implements Product - @owner(graph: \\"product\\") - @key(fields: \\"{ upc }\\", graph: \\"product\\") - @key(fields: \\"{ sku }\\", graph: \\"product\\") - @key(fields: \\"{ sku }\\", graph: \\"inventory\\") - @key(fields: \\"{ upc }\\", graph: \\"reviews\\") - { - upc: String! - sku: String! - name: String - price: String - brand: Brand - metadata: [MetadataOrError] - details: ProductDetailsFurniture - inStock: Boolean @resolve(graph: \\"inventory\\") - isHeavy: Boolean @resolve(graph: \\"inventory\\") - reviews: [Review] @resolve(graph: \\"reviews\\") - } - - type Ikea { - asile: Int - } - - type Image { - name: String! - attributes: ImageAttributes! - } - - type ImageAttributes { - url: String! - } - - type KeyValue { - key: String! - value: String! - } - - type Library - @owner(graph: \\"books\\") - @key(fields: \\"{ id }\\", graph: \\"books\\") - @key(fields: \\"{ id }\\", graph: \\"accounts\\") - { - id: ID! - name: String - userAccount(id: ID! = 1): User @resolve(graph: \\"accounts\\") @requires(fields: \\"{ name }\\") - } - - union MetadataOrError = KeyValue | Error - - type Mutation { - login(username: String!, password: String!): User @resolve(graph: \\"accounts\\") - reviewProduct(upc: String!, body: String!): Product @resolve(graph: \\"reviews\\") - updateReview(review: UpdateReviewInput!): Review @resolve(graph: \\"reviews\\") - deleteReview(id: ID!): Boolean @resolve(graph: \\"reviews\\") - } - - type Name { - first: String - last: String - } - - type PasswordAccount - @owner(graph: \\"accounts\\") - @key(fields: \\"{ email }\\", graph: \\"accounts\\") - { - email: String! - } - - interface Product { - upc: String! - sku: String! - name: String - price: String - details: ProductDetails - inStock: Boolean - reviews: [Review] - } - - interface ProductDetails { - country: String - } - - type ProductDetailsBook implements ProductDetails { - country: String - pages: Int - } - - type ProductDetailsFurniture implements ProductDetails { - country: String - color: String - } - - type Query { - user(id: ID!): User @resolve(graph: \\"accounts\\") - me: User @resolve(graph: \\"accounts\\") - book(isbn: String!): Book @resolve(graph: \\"books\\") - books: [Book] @resolve(graph: \\"books\\") - library(id: ID!): Library @resolve(graph: \\"books\\") - body: Body! @resolve(graph: \\"documents\\") - product(upc: String!): Product @resolve(graph: \\"product\\") - vehicle(id: String!): Vehicle @resolve(graph: \\"product\\") - topProducts(first: Int = 5): [Product] @resolve(graph: \\"product\\") - topCars(first: Int = 5): [Car] @resolve(graph: \\"product\\") - topReviews(first: Int = 5): [Review] @resolve(graph: \\"reviews\\") - } - - type Review - @owner(graph: \\"reviews\\") - @key(fields: \\"{ id }\\", graph: \\"reviews\\") - { - id: ID! - body(format: Boolean = false): String - author: User @provides(fields: \\"{ username }\\") - product: Product - metadata: [MetadataOrError] - } - - type SMSAccount - @owner(graph: \\"accounts\\") - @key(fields: \\"{ number }\\", graph: \\"accounts\\") - { - number: String - } - - type Text { - name: String! - attributes: TextAttributes! - } - - type TextAttributes { - bold: Boolean - text: String - } - - union Thing = Car | Ikea - - input UpdateReviewInput { - id: ID! - body: String - } - - type User - @owner(graph: \\"accounts\\") - @key(fields: \\"{ id }\\", graph: \\"accounts\\") - @key(fields: \\"{ username name { first last } }\\", graph: \\"accounts\\") - @key(fields: \\"{ id }\\", graph: \\"inventory\\") - @key(fields: \\"{ id }\\", graph: \\"product\\") - @key(fields: \\"{ id }\\", graph: \\"reviews\\") - { - id: ID! - name: Name - username: String - birthDate(locale: String): String - account: AccountType - metadata: [UserMetadata] - goodDescription: Boolean @resolve(graph: \\"inventory\\") @requires(fields: \\"{ metadata { description } }\\") - vehicle: Vehicle @resolve(graph: \\"product\\") - thing: Thing @resolve(graph: \\"product\\") - reviews: [Review] @resolve(graph: \\"reviews\\") - numberOfReviews: Int! @resolve(graph: \\"reviews\\") - goodAddress: Boolean @resolve(graph: \\"reviews\\") @requires(fields: \\"{ metadata { address } }\\") - } - - type UserMetadata { - name: String - address: String - description: String - } - - type Van implements Vehicle - @owner(graph: \\"product\\") - @key(fields: \\"{ id }\\", graph: \\"product\\") - @key(fields: \\"{ id }\\", graph: \\"reviews\\") - { - id: String! - description: String - price: String - retailPrice: String @resolve(graph: \\"reviews\\") @requires(fields: \\"{ price }\\") - } - - interface Vehicle { - id: String! - description: String - price: String - retailPrice: String - } - ", - "id": "originalId-1234", - } - `); - }); - - describe('errors', () => { - it('throws on a malformed response', async () => { - mockCsdlRequest().reply(200, 'Invalid JSON'); - - const fetcher = getDefaultFetcher(); - await expect( - loadCsdlFromStorage({ - graphId, - graphVariant, - apiKey, - endpoint: mockCloudConfigUrl, - fetcher, - }), - ).rejects.toThrowErrorMatchingInlineSnapshot( - `"An error occurred while fetching your schema from Apollo: 200 invalid json response body at https://example.cloud-config-url.com/cloudconfig/ reason: Unexpected token I in JSON at position 0"`, - ); - }); - - it('throws errors from JSON on 400', async () => { - const message = 'Query syntax error'; - mockCsdlRequest().reply( - 400, - JSON.stringify({ - errors: [{ message }], - }), - ); - - const fetcher = getDefaultFetcher(); - await expect( - loadCsdlFromStorage({ - graphId, - graphVariant, - apiKey, - endpoint: mockCloudConfigUrl, - fetcher, - }), - ).rejects.toThrowError(message); - }); - - it("throws on non-OK status codes when `errors` isn't present in a JSON response", async () => { - mockCsdlRequest().reply(500); - - const fetcher = getDefaultFetcher(); - await expect( - loadCsdlFromStorage({ - graphId, - graphVariant, - apiKey, - endpoint: mockCloudConfigUrl, - fetcher, - }), - ).rejects.toThrowErrorMatchingInlineSnapshot( - `"An error occurred while fetching your schema from Apollo: 500 Internal Server Error"`, - ); - }); - }); -}); diff --git a/gateway-js/src/__tests__/loadSupergraphSdlFromStorage.test.ts b/gateway-js/src/__tests__/loadSupergraphSdlFromStorage.test.ts new file mode 100644 index 000000000..a524a7a59 --- /dev/null +++ b/gateway-js/src/__tests__/loadSupergraphSdlFromStorage.test.ts @@ -0,0 +1,356 @@ +import { loadSupergraphSdlFromStorage } from '../loadSupergraphSdlFromStorage'; +import { getDefaultFetcher } from '../..'; +import { + mockSupergraphSdlRequestSuccess, + graphId, + graphVariant, + apiKey, + mockCloudConfigUrl, + mockSupergraphSdlRequest, +} from './integration/nockMocks'; + +describe('loadSupergraphSdlFromStorage', () => { + it('fetches Supergraph SDL as expected', async () => { + mockSupergraphSdlRequestSuccess(); + const fetcher = getDefaultFetcher(); + const result = await loadSupergraphSdlFromStorage({ + graphId, + graphVariant, + apiKey, + endpoint: mockCloudConfigUrl, + fetcher, + }); + + expect(result).toMatchInlineSnapshot(` + Object { + "id": "originalId-1234", + "supergraphSdl": "schema + @core(feature: \\"https://specs.apollo.dev/core/v0.1\\"), + @core(feature: \\"https://specs.apollo.dev/join/v0.1\\") + { + query: Query + mutation: Mutation + } + + directive @core(feature: String!) repeatable on SCHEMA + + directive @join__field(graph: join__Graph, requires: join__FieldSet, provides: join__FieldSet) on FIELD_DEFINITION + + directive @join__type(graph: join__Graph!, key: join__FieldSet) repeatable on OBJECT | INTERFACE + + directive @join__owner(graph: join__Graph!) on OBJECT | INTERFACE + + directive @join__graph(name: String!, url: String!) on ENUM_VALUE + + directive @stream on FIELD + + directive @transform(from: String!) on FIELD + + union AccountType = PasswordAccount | SMSAccount + + type Amazon { + referrer: String + } + + union Body = Image | Text + + type Book implements Product + @join__owner(graph: BOOKS) + @join__type(graph: BOOKS, key: \\"isbn\\") + @join__type(graph: INVENTORY, key: \\"isbn\\") + @join__type(graph: PRODUCT, key: \\"isbn\\") + @join__type(graph: REVIEWS, key: \\"isbn\\") + { + isbn: String! @join__field(graph: BOOKS) + title: String @join__field(graph: BOOKS) + year: Int @join__field(graph: BOOKS) + similarBooks: [Book]! @join__field(graph: BOOKS) + metadata: [MetadataOrError] @join__field(graph: BOOKS) + inStock: Boolean @join__field(graph: INVENTORY) + isCheckedOut: Boolean @join__field(graph: INVENTORY) + upc: String! @join__field(graph: PRODUCT) + sku: String! @join__field(graph: PRODUCT) + name(delimeter: String = \\" \\"): String @join__field(graph: PRODUCT, requires: \\"title year\\") + price: String @join__field(graph: PRODUCT) + details: ProductDetailsBook @join__field(graph: PRODUCT) + reviews: [Review] @join__field(graph: REVIEWS) + relatedReviews: [Review!]! @join__field(graph: REVIEWS, requires: \\"similarBooks { isbn }\\") + } + + union Brand = Ikea | Amazon + + type Car implements Vehicle + @join__owner(graph: PRODUCT) + @join__type(graph: PRODUCT, key: \\"id\\") + @join__type(graph: REVIEWS, key: \\"id\\") + { + id: String! @join__field(graph: PRODUCT) + description: String @join__field(graph: PRODUCT) + price: String @join__field(graph: PRODUCT) + retailPrice: String @join__field(graph: REVIEWS, requires: \\"price\\") + } + + type Error { + code: Int + message: String + } + + type Furniture implements Product + @join__owner(graph: PRODUCT) + @join__type(graph: PRODUCT, key: \\"upc\\") + @join__type(graph: PRODUCT, key: \\"sku\\") + @join__type(graph: INVENTORY, key: \\"sku\\") + @join__type(graph: REVIEWS, key: \\"upc\\") + { + upc: String! @join__field(graph: PRODUCT) + sku: String! @join__field(graph: PRODUCT) + name: String @join__field(graph: PRODUCT) + price: String @join__field(graph: PRODUCT) + brand: Brand @join__field(graph: PRODUCT) + metadata: [MetadataOrError] @join__field(graph: PRODUCT) + details: ProductDetailsFurniture @join__field(graph: PRODUCT) + inStock: Boolean @join__field(graph: INVENTORY) + isHeavy: Boolean @join__field(graph: INVENTORY) + reviews: [Review] @join__field(graph: REVIEWS) + } + + type Ikea { + asile: Int + } + + type Image { + name: String! + attributes: ImageAttributes! + } + + type ImageAttributes { + url: String! + } + + scalar join__FieldSet + + enum join__Graph { + ACCOUNTS @join__graph(name: \\"accounts\\" url: \\"https://accounts.api.com\\") + BOOKS @join__graph(name: \\"books\\" url: \\"https://books.api.com\\") + DOCUMENTS @join__graph(name: \\"documents\\" url: \\"https://documents.api.com\\") + INVENTORY @join__graph(name: \\"inventory\\" url: \\"https://inventory.api.com\\") + PRODUCT @join__graph(name: \\"product\\" url: \\"https://product.api.com\\") + REVIEWS @join__graph(name: \\"reviews\\" url: \\"https://reviews.api.com\\") + } + + type KeyValue { + key: String! + value: String! + } + + type Library + @join__owner(graph: BOOKS) + @join__type(graph: BOOKS, key: \\"id\\") + @join__type(graph: ACCOUNTS, key: \\"id\\") + { + id: ID! @join__field(graph: BOOKS) + name: String @join__field(graph: BOOKS) + userAccount(id: ID! = 1): User @join__field(graph: ACCOUNTS, requires: \\"name\\") + } + + union MetadataOrError = KeyValue | Error + + type Mutation { + login(username: String!, password: String!): User @join__field(graph: ACCOUNTS) + reviewProduct(upc: String!, body: String!): Product @join__field(graph: REVIEWS) + updateReview(review: UpdateReviewInput!): Review @join__field(graph: REVIEWS) + deleteReview(id: ID!): Boolean @join__field(graph: REVIEWS) + } + + type Name { + first: String + last: String + } + + type PasswordAccount + @join__owner(graph: ACCOUNTS) + @join__type(graph: ACCOUNTS, key: \\"email\\") + { + email: String! @join__field(graph: ACCOUNTS) + } + + interface Product { + upc: String! + sku: String! + name: String + price: String + details: ProductDetails + inStock: Boolean + reviews: [Review] + } + + interface ProductDetails { + country: String + } + + type ProductDetailsBook implements ProductDetails { + country: String + pages: Int + } + + type ProductDetailsFurniture implements ProductDetails { + country: String + color: String + } + + type Query { + user(id: ID!): User @join__field(graph: ACCOUNTS) + me: User @join__field(graph: ACCOUNTS) + book(isbn: String!): Book @join__field(graph: BOOKS) + books: [Book] @join__field(graph: BOOKS) + library(id: ID!): Library @join__field(graph: BOOKS) + body: Body! @join__field(graph: DOCUMENTS) + product(upc: String!): Product @join__field(graph: PRODUCT) + vehicle(id: String!): Vehicle @join__field(graph: PRODUCT) + topProducts(first: Int = 5): [Product] @join__field(graph: PRODUCT) + topCars(first: Int = 5): [Car] @join__field(graph: PRODUCT) + topReviews(first: Int = 5): [Review] @join__field(graph: REVIEWS) + } + + type Review + @join__owner(graph: REVIEWS) + @join__type(graph: REVIEWS, key: \\"id\\") + { + id: ID! @join__field(graph: REVIEWS) + body(format: Boolean = false): String @join__field(graph: REVIEWS) + author: User @join__field(graph: REVIEWS, provides: \\"username\\") + product: Product @join__field(graph: REVIEWS) + metadata: [MetadataOrError] @join__field(graph: REVIEWS) + } + + type SMSAccount + @join__owner(graph: ACCOUNTS) + @join__type(graph: ACCOUNTS, key: \\"number\\") + { + number: String @join__field(graph: ACCOUNTS) + } + + type Text { + name: String! + attributes: TextAttributes! + } + + type TextAttributes { + bold: Boolean + text: String + } + + union Thing = Car | Ikea + + input UpdateReviewInput { + id: ID! + body: String + } + + type User + @join__owner(graph: ACCOUNTS) + @join__type(graph: ACCOUNTS, key: \\"id\\") + @join__type(graph: ACCOUNTS, key: \\"username name { first last }\\") + @join__type(graph: INVENTORY, key: \\"id\\") + @join__type(graph: PRODUCT, key: \\"id\\") + @join__type(graph: REVIEWS, key: \\"id\\") + { + id: ID! @join__field(graph: ACCOUNTS) + name: Name @join__field(graph: ACCOUNTS) + username: String @join__field(graph: ACCOUNTS) + birthDate(locale: String): String @join__field(graph: ACCOUNTS) + account: AccountType @join__field(graph: ACCOUNTS) + metadata: [UserMetadata] @join__field(graph: ACCOUNTS) + goodDescription: Boolean @join__field(graph: INVENTORY, requires: \\"metadata { description }\\") + vehicle: Vehicle @join__field(graph: PRODUCT) + thing: Thing @join__field(graph: PRODUCT) + reviews: [Review] @join__field(graph: REVIEWS) + numberOfReviews: Int! @join__field(graph: REVIEWS) + goodAddress: Boolean @join__field(graph: REVIEWS, requires: \\"metadata { address }\\") + } + + type UserMetadata { + name: String + address: String + description: String + } + + type Van implements Vehicle + @join__owner(graph: PRODUCT) + @join__type(graph: PRODUCT, key: \\"id\\") + @join__type(graph: REVIEWS, key: \\"id\\") + { + id: String! @join__field(graph: PRODUCT) + description: String @join__field(graph: PRODUCT) + price: String @join__field(graph: PRODUCT) + retailPrice: String @join__field(graph: REVIEWS, requires: \\"price\\") + } + + interface Vehicle { + id: String! + description: String + price: String + retailPrice: String + } + ", + } + `); + }); + + describe('errors', () => { + it('throws on a malformed response', async () => { + mockSupergraphSdlRequest().reply(200, 'Invalid JSON'); + + const fetcher = getDefaultFetcher(); + await expect( + loadSupergraphSdlFromStorage({ + graphId, + graphVariant, + apiKey, + endpoint: mockCloudConfigUrl, + fetcher, + }), + ).rejects.toThrowErrorMatchingInlineSnapshot( + `"An error occurred while fetching your schema from Apollo: 200 invalid json response body at https://example.cloud-config-url.com/cloudconfig/ reason: Unexpected token I in JSON at position 0"`, + ); + }); + + it('throws errors from JSON on 400', async () => { + const message = 'Query syntax error'; + mockSupergraphSdlRequest().reply( + 400, + JSON.stringify({ + errors: [{ message }], + }), + ); + + const fetcher = getDefaultFetcher(); + await expect( + loadSupergraphSdlFromStorage({ + graphId, + graphVariant, + apiKey, + endpoint: mockCloudConfigUrl, + fetcher, + }), + ).rejects.toThrowError(message); + }); + + it("throws on non-OK status codes when `errors` isn't present in a JSON response", async () => { + mockSupergraphSdlRequest().reply(500); + + const fetcher = getDefaultFetcher(); + await expect( + loadSupergraphSdlFromStorage({ + graphId, + graphVariant, + apiKey, + endpoint: mockCloudConfigUrl, + fetcher, + }), + ).rejects.toThrowErrorMatchingInlineSnapshot( + `"An error occurred while fetching your schema from Apollo: 500 Internal Server Error"`, + ); + }); + }); +}); diff --git a/gateway-js/src/config.ts b/gateway-js/src/config.ts index 0d2da61b0..d536246ee 100644 --- a/gateway-js/src/config.ts +++ b/gateway-js/src/config.ts @@ -52,22 +52,22 @@ export interface ServiceDefinitionCompositionInfo { compositionMetadata?: CompositionMetadata; } -export interface CsdlCompositionInfo { +export interface SupergraphSdlCompositionInfo { schema: GraphQLSchema; compositionId: string; - csdl: string; + supergraphSdl: string; } export type CompositionInfo = | ServiceDefinitionCompositionInfo - | CsdlCompositionInfo; + | SupergraphSdlCompositionInfo; export type Experimental_DidUpdateCompositionCallback = ( currentConfig: CompositionInfo, previousConfig?: CompositionInfo, ) => void; -export type CompositionUpdate = ServiceDefinitionUpdate | CsdlUpdate; +export type CompositionUpdate = ServiceDefinitionUpdate | SupergraphSdlUpdate; export interface ServiceDefinitionUpdate { serviceDefinitions?: ServiceDefinition[]; @@ -75,13 +75,13 @@ export interface ServiceDefinitionUpdate { isNewSchema: boolean; } -export interface CsdlUpdate { +export interface SupergraphSdlUpdate { id: string; - csdl: string; + supergraphSdl: string; } -export function isCsdlUpdate(update: CompositionUpdate): update is CsdlUpdate { - return 'csdl' in update; +export function isSupergraphSdlUpdate(update: CompositionUpdate): update is SupergraphSdlUpdate { + return 'supergraphSdl' in update; } export function isServiceDefinitionUpdate( @@ -100,9 +100,9 @@ export type Experimental_UpdateServiceDefinitions = ( config: DynamicGatewayConfig, ) => Promise; -export type Experimental_UpdateCsdl = ( +export type Experimental_UpdateSupergraphSdl = ( config: DynamicGatewayConfig, -) => Promise; +) => Promise; export type Experimental_UpdateComposition = ( config: DynamicGatewayConfig, @@ -165,23 +165,23 @@ interface ManuallyManagedServiceDefsGatewayConfig extends GatewayConfigBase { experimental_updateServiceDefinitions: Experimental_UpdateServiceDefinitions; } -interface ManuallyManagedCsdlGatewayConfig extends GatewayConfigBase { - experimental_updateCsdl: Experimental_UpdateCsdl +interface ManuallyManagedSupergraphSdlGatewayConfig extends GatewayConfigBase { + experimental_updateSupergraphSdl: Experimental_UpdateSupergraphSdl } type ManuallyManagedGatewayConfig = | ManuallyManagedServiceDefsGatewayConfig - | ManuallyManagedCsdlGatewayConfig; + | ManuallyManagedSupergraphSdlGatewayConfig; interface LocalGatewayConfig extends GatewayConfigBase { localServiceList: ServiceDefinition[]; } -interface CsdlGatewayConfig extends GatewayConfigBase { - csdl: string; +interface SupergraphSdlGatewayConfig extends GatewayConfigBase { + supergraphSdl: string; } -export type StaticGatewayConfig = LocalGatewayConfig | CsdlGatewayConfig; +export type StaticGatewayConfig = LocalGatewayConfig | SupergraphSdlGatewayConfig; type DynamicGatewayConfig = | ManagedGatewayConfig @@ -198,8 +198,8 @@ export function isRemoteConfig(config: GatewayConfig): config is RemoteGatewayCo return 'serviceList' in config; } -export function isCsdlConfig(config: GatewayConfig): config is CsdlGatewayConfig { - return 'csdl' in config; +export function isSupergraphSdlConfig(config: GatewayConfig): config is SupergraphSdlGatewayConfig { + return 'supergraphSdl' in config; } // A manually managed config means the user has provided a function which @@ -209,7 +209,7 @@ export function isManuallyManagedConfig( ): config is ManuallyManagedGatewayConfig { return ( 'experimental_updateServiceDefinitions' in config || - 'experimental_updateCsdl' in config + 'experimental_updateSupergraphSdl' in config ); } @@ -221,7 +221,7 @@ export function isManagedConfig( isPrecomposedManagedConfig(config) || (!isRemoteConfig(config) && !isLocalConfig(config) && - !isCsdlConfig(config) && + !isSupergraphSdlConfig(config) && !isManuallyManagedConfig(config)) ); } @@ -238,7 +238,7 @@ export function isPrecomposedManagedConfig( // A static config is one which loads synchronously on start and never updates export function isStaticConfig(config: GatewayConfig): config is StaticGatewayConfig { - return isLocalConfig(config) || isCsdlConfig(config); + return isLocalConfig(config) || isSupergraphSdlConfig(config); } // A dynamic config is one which loads asynchronously and (can) update via polling diff --git a/gateway-js/src/csdlToSchema.ts b/gateway-js/src/csdlToSchema.ts deleted file mode 100644 index 0fe0ab01b..000000000 --- a/gateway-js/src/csdlToSchema.ts +++ /dev/null @@ -1,10 +0,0 @@ -import { extendSchema, GraphQLSchema, parse } from 'graphql'; - -export function csdlToSchema(csdl: string) { - let schema = new GraphQLSchema({ - query: undefined, - }); - - const parsed = parse(csdl); - return extendSchema(schema, parsed, { assumeValidSDL: true }); -} diff --git a/gateway-js/src/index.ts b/gateway-js/src/index.ts index de2da313d..6aaee350e 100644 --- a/gateway-js/src/index.ts +++ b/gateway-js/src/index.ts @@ -16,7 +16,6 @@ import { isIntrospectionType, GraphQLSchema, VariableDefinitionNode, - visit, DocumentNode, print, FragmentDefinitionNode, @@ -27,8 +26,6 @@ import { composeAndValidate, compositionHasErrors, ServiceDefinition, - findDirectivesOnNode, - isStringValueNode, } from '@apollo/federation'; import loglevel from 'loglevel'; @@ -47,7 +44,6 @@ import fetcher from 'make-fetch-happen'; import { HttpRequestCache } from './cache'; import { fetch } from 'apollo-server-env'; import { getQueryPlanner, QueryPlannerPointer, QueryPlan, prettyFormatQueryPlan } from '@apollo/query-planner'; -import { csdlToSchema } from './csdlToSchema'; import { ServiceEndpointDefinition, Experimental_DidFailCompositionCallback, @@ -66,15 +62,16 @@ import { isDynamicConfig, isStaticConfig, CompositionMetadata, - isCsdlUpdate, + isSupergraphSdlUpdate, isServiceDefinitionUpdate, ServiceDefinitionUpdate, - CsdlUpdate, + SupergraphSdlUpdate, CompositionUpdate, isPrecomposedManagedConfig, } from './config'; -import { loadCsdlFromStorage } from './loadCsdlFromStorage'; +import { loadSupergraphSdlFromStorage } from './loadSupergraphSdlFromStorage'; import { getServiceDefinitionsFromStorage } from './legacyLoadServicesFromStorage'; +import { buildComposedSchema } from '@apollo/query-planner'; type FragmentMap = { [fragmentName: string]: FragmentDefinitionNode }; @@ -164,7 +161,7 @@ export class ApolloGateway implements GraphQLService { private serviceSdlCache = new Map(); private warnedStates: WarnedStates = Object.create(null); private queryPlannerPointer?: QueryPlannerPointer; - private parsedCsdl?: DocumentNode; + private parsedSupergraphSdl?: DocumentNode; private fetcher: typeof fetch; private compositionId?: string; private state: GatewayState; @@ -229,8 +226,8 @@ export class ApolloGateway implements GraphQLService { if (isManuallyManagedConfig(this.config)) { // Use the provided updater function if provided by the user, else default - if ('experimental_updateCsdl' in this.config) { - this.updateServiceDefinitions = this.config.experimental_updateCsdl; + if ('experimental_updateSupergraphSdl' in this.config) { + this.updateServiceDefinitions = this.config.experimental_updateSupergraphSdl; } else if ('experimental_updateServiceDefinitions' in this.config) { this.updateServiceDefinitions = this.config.experimental_updateServiceDefinitions; } else { @@ -308,13 +305,13 @@ export class ApolloGateway implements GraphQLService { if ( isManuallyManagedConfig(this.config) && - 'experimental_updateCsdl' in this.config && + 'experimental_updateSupergraphSdl' in this.config && 'experimental_updateServiceDefinitions' in this.config ) { this.logger.warn( 'Gateway found two manual update configurations when only one should be ' + - 'provided. Gateway will default to using the provided `experimental_updateCsdl` ' + - 'function when both `experimental_updateCsdl` and experimental_updateServiceDefinitions` ' + + 'provided. Gateway will default to using the provided `experimental_updateSupergraphSdl` ' + + 'function when both `experimental_updateSupergraphSdl` and experimental_updateServiceDefinitions` ' + 'are provided.', ); } @@ -394,11 +391,11 @@ export class ApolloGateway implements GraphQLService { // schema and query planner. private loadStatic(config: StaticGatewayConfig) { let schema: GraphQLSchema; - let composedSdl: string; + let supergraphSdl: string; try { - ({ schema, composedSdl } = isLocalConfig(config) + ({ schema, supergraphSdl } = isLocalConfig(config) ? this.createSchemaFromServiceList(config.localServiceList) - : this.createSchemaFromCsdl(config.csdl)); + : this.createSchemaFromSupergraphSdl(config.supergraphSdl)); } catch (e) { this.state = { phase: 'failed to load' }; throw e; @@ -406,8 +403,8 @@ export class ApolloGateway implements GraphQLService { this.schema = schema; // TODO(trevor): #580 redundant parse - this.parsedCsdl = parse(composedSdl); - this.queryPlannerPointer = getQueryPlanner(composedSdl); + this.parsedSupergraphSdl = parse(supergraphSdl); + this.queryPlannerPointer = getQueryPlanner(supergraphSdl); this.state = { phase: 'loaded' }; } @@ -437,8 +434,8 @@ export class ApolloGateway implements GraphQLService { // This may throw, but an error here is caught and logged upstream const result = await this.updateServiceDefinitions(this.config); - if (isCsdlUpdate(result)) { - await this.updateWithCsdl(result); + if (isSupergraphSdlUpdate(result)) { + await this.updateWithSupergraphSdl(result); } else if (isServiceDefinitionUpdate(result)) { await this.updateByComposition(result); } else { @@ -475,17 +472,17 @@ export class ApolloGateway implements GraphQLService { if (this.queryPlanStore) this.queryPlanStore.flush(); - const { schema, composedSdl } = this.createSchemaFromServiceList( + const { schema, supergraphSdl } = this.createSchemaFromServiceList( result.serviceDefinitions, ); - if (!composedSdl) { + if (!supergraphSdl) { this.logger.error( "A valid schema couldn't be composed. Falling back to previous schema.", ); } else { this.schema = schema; - this.queryPlannerPointer = getQueryPlanner(composedSdl); + this.queryPlannerPointer = getQueryPlanner(supergraphSdl); // Notify the schema listeners of the updated schema try { @@ -522,7 +519,7 @@ export class ApolloGateway implements GraphQLService { } } - private async updateWithCsdl(result: CsdlUpdate): Promise { + private async updateWithSupergraphSdl(result: SupergraphSdlUpdate): Promise { if (result.id === this.compositionId) { this.logger.debug('No change in composition since last check.'); return; @@ -533,32 +530,32 @@ export class ApolloGateway implements GraphQLService { // In the case that it throws, the gateway will: // * on initial load, throw the error // * on update, log the error and don't update - const parsedCsdl = parse(result.csdl); + const parsedSupergraphSdl = parse(result.supergraphSdl); const previousSchema = this.schema; - const previousCsdl = this.parsedCsdl; + const previousSupergraphSdl = this.parsedSupergraphSdl; const previousCompositionId = this.compositionId; if (previousSchema) { - this.logger.info('Updated CSDL was found.'); + this.logger.info('Updated Supergraph SDL was found.'); } await this.maybePerformServiceHealthCheck(result); this.compositionId = result.id; - this.parsedCsdl = parsedCsdl; + this.parsedSupergraphSdl = parsedSupergraphSdl; if (this.queryPlanStore) this.queryPlanStore.flush(); - const { schema, composedSdl } = this.createSchemaFromCsdl(result.csdl); + const { schema, supergraphSdl } = this.createSchemaFromSupergraphSdl(result.supergraphSdl); - if (!composedSdl) { + if (!supergraphSdl) { this.logger.error( "A valid schema couldn't be composed. Falling back to previous schema.", ); } else { this.schema = schema; - this.queryPlannerPointer = getQueryPlanner(composedSdl); + this.queryPlannerPointer = getQueryPlanner(supergraphSdl); // Notify the schema listeners of the updated schema try { @@ -577,13 +574,13 @@ export class ApolloGateway implements GraphQLService { this.experimental_didUpdateComposition( { compositionId: result.id, - csdl: result.csdl, + supergraphSdl: result.supergraphSdl, schema: this.schema, }, - previousCompositionId && previousCsdl && previousSchema + previousCompositionId && previousSupergraphSdl && previousSchema ? { compositionId: previousCompositionId, - csdl: print(previousCsdl), + supergraphSdl: print(previousSupergraphSdl), schema: previousSchema, } : undefined, @@ -596,11 +593,11 @@ export class ApolloGateway implements GraphQLService { // Run service health checks before we commit and update the new schema. // This is the last chance to bail out of a schema update. if (this.config.serviceHealthCheck) { - const serviceList = isCsdlUpdate(update) + const serviceList = isSupergraphSdlUpdate(update) ? // TODO(trevor): #580 redundant parse // Parsing could technically fail and throw here, but parseability has // already been confirmed slightly earlier in the code path - this.serviceListFromCsdl(parse(update.csdl)) + this.serviceListFromSupergraphSdl(parse(update.supergraphSdl)) : // Existence of this is determined in advance with an early return otherwise update.serviceDefinitions!; // Here we need to construct new datasources based on the new schema info @@ -679,9 +676,11 @@ export class ApolloGateway implements GraphQLService { errors.map((e) => '\t' + e.message).join('\n'), ); } else { - const { composedSdl } = compositionResult; + const { supergraphSdl } = compositionResult; this.createServices(serviceList); + const schema = buildComposedSchema(parse(supergraphSdl)); + this.logger.debug('Schema loaded and ready for execution'); // This is a workaround for automatic wrapping of all fields, which Apollo @@ -691,53 +690,44 @@ export class ApolloGateway implements GraphQLService { // the shape of the root value already contains the aliased fields as // responseNames return { - schema: wrapSchemaWithAliasResolver(csdlToSchema(composedSdl)), - composedSdl, + schema: wrapSchemaWithAliasResolver(schema), + supergraphSdl, }; } } - private serviceListFromCsdl(csdl: DocumentNode) { - const serviceList: Omit[] = []; + private serviceListFromSupergraphSdl(supergraphSdl: DocumentNode): Omit[] { + const schema = buildComposedSchema(supergraphSdl); + return this.serviceListFromComposedSchema(schema); + } - visit(csdl, { - SchemaDefinition(node) { - findDirectivesOnNode(node, 'graph').forEach((directive) => { - const name = directive.arguments?.find( - (arg) => arg.name.value === 'name', - ); - const url = directive.arguments?.find( - (arg) => arg.name.value === 'url', - ); + private serviceListFromComposedSchema(schema: GraphQLSchema) { + const graphMap = schema.extensions?.federation?.graphs; + if (!graphMap) { + throw Error(`Couldn't find graph map in composed schema`); + } - if ( - name && - isStringValueNode(name.value) && - url && - isStringValueNode(url.value) - ) { - serviceList.push({ - name: name.value.value, - url: url.value.value, - }); - } - }); - }, - }); + const serviceList = Object.values(graphMap).map(graph => ({ + name: graph.name, + url: graph.url + })) return serviceList; } - private createSchemaFromCsdl(csdl: string) { + private createSchemaFromSupergraphSdl(supergraphSdl: string) { // TODO(trevor): #580 redundant parse - this.parsedCsdl = parse(csdl); - const serviceList = this.serviceListFromCsdl(this.parsedCsdl); + this.parsedSupergraphSdl = parse(supergraphSdl); + + const schema = buildComposedSchema(this.parsedSupergraphSdl); + + const serviceList = this.serviceListFromComposedSchema(schema); this.createServices(serviceList); return { - schema: wrapSchemaWithAliasResolver(csdlToSchema(csdl)), - composedSdl: csdl, + schema: wrapSchemaWithAliasResolver(schema), + supergraphSdl, }; } @@ -892,7 +882,7 @@ export class ApolloGateway implements GraphQLService { 'When a manual configuration is not provided, gateway requires an Apollo ' + 'configuration. See https://www.apollographql.com/docs/apollo-server/federation/managed-federation/ ' + 'for more information. Manual configuration options include: ' + - '`serviceList`, `csdl`, and `experimental_updateServiceDefinitions`.', + '`serviceList`, `supergraphSdl`, and `experimental_updateServiceDefinitions`.', ); } @@ -910,7 +900,7 @@ export class ApolloGateway implements GraphQLService { }); } - return loadCsdlFromStorage({ + return loadSupergraphSdlFromStorage({ graphId: this.apolloConfig!.graphId!, apiKey: this.apolloConfig!.key!, graphVariant: this.apolloConfig!.graphVariant, @@ -940,7 +930,7 @@ export class ApolloGateway implements GraphQLService { this.logger.warn( 'A local gateway configuration is overriding a managed federation ' + 'configuration. To use the managed ' + - 'configuration, do not specify a service list or csdl locally.', + 'configuration, do not specify a service list or supergraphSdl locally.', ); } } diff --git a/gateway-js/src/loadCsdlFromStorage.ts b/gateway-js/src/loadSupergraphSdlFromStorage.ts similarity index 74% rename from gateway-js/src/loadCsdlFromStorage.ts rename to gateway-js/src/loadSupergraphSdlFromStorage.ts index d645589b8..8f0a0d2aa 100644 --- a/gateway-js/src/loadCsdlFromStorage.ts +++ b/gateway-js/src/loadSupergraphSdlFromStorage.ts @@ -1,14 +1,14 @@ import { fetch, Response } from 'apollo-server-env'; import { GraphQLError } from 'graphql'; -import { CsdlQuery } from './__generated__/graphqlTypes'; +import { SupergraphSdlQuery } from './__generated__/graphqlTypes'; -export const CSDL_QUERY = /* GraphQL */`#graphql - query Csdl($apiKey: String!, $ref: String!) { +export const SUPERGRAPH_SDL_QUERY = /* GraphQL */`#graphql + query SupergraphSdl($apiKey: String!, $ref: String!) { routerConfig(ref: $ref, apiKey: $apiKey) { __typename ... on RouterConfigResult { id - csdl + supergraphSdl: csdl } ... on FetchError { code @@ -18,14 +18,16 @@ export const CSDL_QUERY = /* GraphQL */`#graphql } `; -type CsdlQueryResult = CsdlQuerySuccess | CsdlQueryFailure; +type SupergraphSdlQueryResult = + | SupergraphSdlQuerySuccess + | SupergraphSdlQueryFailure; -interface CsdlQuerySuccess { - data: CsdlQuery; +interface SupergraphSdlQuerySuccess { + data: SupergraphSdlQuery; } -interface CsdlQueryFailure { - data?: CsdlQuery; +interface SupergraphSdlQueryFailure { + data?: SupergraphSdlQuery; errors: GraphQLError[]; } @@ -33,7 +35,7 @@ const { name, version } = require('../package.json'); const fetchErrorMsg = "An error occurred while fetching your schema from Apollo: "; -export async function loadCsdlFromStorage({ +export async function loadSupergraphSdlFromStorage({ graphId, graphVariant, apiKey, @@ -51,7 +53,7 @@ export async function loadCsdlFromStorage({ result = await fetcher(endpoint, { method: 'POST', body: JSON.stringify({ - query: CSDL_QUERY, + query: SUPERGRAPH_SDL_QUERY, variables: { ref: `${graphId}@${graphVariant}`, apiKey, @@ -68,7 +70,7 @@ export async function loadCsdlFromStorage({ throw new Error(fetchErrorMsg + (e.message ?? e)); } - let response: CsdlQueryResult; + let response: SupergraphSdlQueryResult; if (result.ok || result.status === 400) { try { @@ -93,12 +95,12 @@ export async function loadCsdlFromStorage({ if (routerConfig.__typename === 'RouterConfigResult') { const { id, - csdl, + supergraphSdl, // messages, } = routerConfig; - // `csdl` should not be nullable in the schema, but it currently is - return { id, csdl: csdl! }; + // `supergraphSdl` should not be nullable in the schema, but it currently is + return { id, supergraphSdl: supergraphSdl! }; } else if (routerConfig.__typename === 'FetchError') { // FetchError case const { code, message } = routerConfig; diff --git a/harmonizer/Cargo.toml b/harmonizer/Cargo.toml index b5b9a90e3..cf2abe3a7 100644 --- a/harmonizer/Cargo.toml +++ b/harmonizer/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "harmonizer" -version = "0.1.3" +version = "0.2.2" authors = ["Apollo Graph, Inc. "] edition = "2018" description = "Apollo Federation utility to compose a supergraph from subgraphs" diff --git a/harmonizer/js/do_compose.js b/harmonizer/js/do_compose.js index 134958a5a..eb46782fc 100644 --- a/harmonizer/js/do_compose.js +++ b/harmonizer/js/do_compose.js @@ -45,10 +45,12 @@ function parseTypedefs(source) { try { /** - * @type {{ errors: Error[], composedSdl?: undefined } | { errors?: undefined, composedSdl: string; }} + * @type {{ errors: Error[], supergraphSdl?: undefined } | { errors?: undefined, supergraphSdl: string; }} */ const composed = composition.composeAndValidate(serviceList); - done(composed.errors ? { Err: composed.errors } : { Ok: composed.composedSdl }) -} catch(err) { - done({ Err: [err] }) + done( + composed.errors ? { Err: composed.errors } : { Ok: composed.supergraphSdl }, + ); +} catch (err) { + done({ Err: [err] }); } diff --git a/harmonizer/src/snapshots/harmonizer__tests__it_works.snap b/harmonizer/src/snapshots/harmonizer__tests__it_works.snap index dcc619add..294ddcb37 100644 --- a/harmonizer/src/snapshots/harmonizer__tests__it_works.snap +++ b/harmonizer/src/snapshots/harmonizer__tests__it_works.snap @@ -1,28 +1,31 @@ --- source: harmonizer/src/lib.rs expression: "harmonize(vec![ServiceDefinition ::\n new(\"users\", \"undefined\",\n \"\n type User {\n id: ID\n name: String\n }\n\n type Query {\n users: [User!]\n }\n \"),\n ServiceDefinition ::\n new(\"movies\", \"undefined\",\n \"\n type Movie {\n title: String\n name: String\n }\n\n extend type User {\n favorites: [Movie!]\n }\n\n type Query {\n movies: [Movie!]\n }\n \")]).unwrap()" + --- schema - @graph(name: "users", url: "undefined") - @graph(name: "movies", url: "undefined") - @composedGraph(version: 1) + @core(feature: "https://specs.apollo.dev/core/v0.1"), + @core(feature: "https://specs.apollo.dev/join/v0.1") { query: Query } -directive @composedGraph(version: Int!) on SCHEMA +directive @core(feature: String!) repeatable on SCHEMA -directive @graph(name: String!, url: String!) repeatable on SCHEMA +directive @join__field(graph: join__Graph, requires: join__FieldSet, provides: join__FieldSet) on FIELD_DEFINITION -directive @owner(graph: String!) on OBJECT +directive @join__type(graph: join__Graph!, key: join__FieldSet) repeatable on OBJECT | INTERFACE -directive @key(fields: String!, graph: String!) repeatable on OBJECT +directive @join__owner(graph: join__Graph!) on OBJECT | INTERFACE -directive @resolve(graph: String!) on FIELD_DEFINITION +directive @join__graph(name: String!, url: String!) on ENUM_VALUE -directive @provides(fields: String!) on FIELD_DEFINITION +scalar join__FieldSet -directive @requires(fields: String!) on FIELD_DEFINITION +enum join__Graph { + USERS @join__graph(name: "users" url: "undefined") + MOVIES @join__graph(name: "movies" url: "undefined") +} type Movie { title: String @@ -30,13 +33,13 @@ type Movie { } type Query { - users: [User!] @resolve(graph: "users") - movies: [Movie!] @resolve(graph: "movies") + users: [User!] @join__field(graph: USERS) + movies: [Movie!] @join__field(graph: MOVIES) } type User { id: ID name: String - favorites: [Movie!] @resolve(graph: "movies") + favorites: [Movie!] @join__field(graph: MOVIES) } diff --git a/package.json b/package.json index c9a9e9595..bfa344c62 100644 --- a/package.json +++ b/package.json @@ -83,7 +83,8 @@ "projects": [ "/federation-js", "/federation-integration-testsuite-js", - "/gateway-js" + "/gateway-js", + "/query-planner-js" ] } } diff --git a/query-planner-js/src/FieldSet.ts b/query-planner-js/src/FieldSet.ts new file mode 100644 index 000000000..9c1c95070 --- /dev/null +++ b/query-planner-js/src/FieldSet.ts @@ -0,0 +1,174 @@ +import { + FieldNode, + getNamedType, + GraphQLCompositeType, + GraphQLField, + isCompositeType, + Kind, + SelectionNode, + SelectionSetNode, + GraphQLObjectType, + DirectiveNode, +} from 'graphql'; +import { getResponseName } from './utilities/graphql'; +import { partition, groupBy } from './utilities/array'; + +export interface Field< + TParent extends GraphQLCompositeType = GraphQLCompositeType +> { + scope: Scope; + fieldNode: FieldNode; + fieldDef: GraphQLField; +} + +export interface Scope { + parentType: TParent; + possibleTypes: ReadonlyArray; + directives?: ReadonlyArray + enclosingScope?: Scope; +} + +export type FieldSet = Field[]; + +export function printFields(fields?: FieldSet) { + if (!fields) return '[]'; + return ( + '[' + + fields + .map(field => `"${field.scope.parentType.name}.${field.fieldDef.name}"`) + .join(', ') + + ']' + ); +} + +export function matchesField(field: Field) { + // TODO: Compare parent type and arguments + return (otherField: Field) => { + return field.fieldDef.name === otherField.fieldDef.name; + }; +} + +export const groupByResponseName = groupBy(field => + getResponseName(field.fieldNode) +); + +export const groupByParentType = groupBy( + field => field.scope.parentType, +); + +export function selectionSetFromFieldSet( + fields: FieldSet, + parentType?: GraphQLCompositeType, +): SelectionSetNode { + return { + kind: Kind.SELECTION_SET, + selections: Array.from(groupByParentType(fields)).flatMap( + ([typeCondition, fieldsByParentType]: [ + GraphQLCompositeType, + FieldSet, + ]) => { + const directives = fieldsByParentType[0].scope.directives; + + return wrapInInlineFragmentIfNeeded( + Array.from(groupByResponseName(fieldsByParentType).values()).map( + (fieldsByResponseName) => { + return combineFields(fieldsByResponseName).fieldNode; + }, + ), + typeCondition, + parentType, + directives, + ); + }, + ), + }; +} + +function wrapInInlineFragmentIfNeeded( + selections: SelectionNode[], + typeCondition: GraphQLCompositeType, + parentType?: GraphQLCompositeType, + directives?: ReadonlyArray +): SelectionNode[] { + return typeCondition === parentType + ? selections + : [ + { + kind: Kind.INLINE_FRAGMENT, + typeCondition: { + kind: Kind.NAMED_TYPE, + name: { + kind: Kind.NAME, + value: typeCondition.name, + }, + }, + selectionSet: { kind: Kind.SELECTION_SET, selections }, + directives + }, + ]; +} + +function combineFields( + fields: FieldSet, +): Field { + const { scope, fieldNode, fieldDef } = fields[0]; + const returnType = getNamedType(fieldDef.type); + + if (isCompositeType(returnType)) { + return { + scope, + fieldNode: { + ...fieldNode, + selectionSet: mergeSelectionSets(fields.map(field => field.fieldNode)), + }, + fieldDef, + }; + } else { + return { scope, fieldNode, fieldDef }; + } +} + +function mergeSelectionSets(fieldNodes: FieldNode[]): SelectionSetNode { + const selections: SelectionNode[] = []; + + for (const fieldNode of fieldNodes) { + if (!fieldNode.selectionSet) continue; + + selections.push(...fieldNode.selectionSet.selections); + } + + return { + kind: 'SelectionSet', + selections: mergeFieldNodeSelectionSets(selections), + }; +} + +function mergeFieldNodeSelectionSets( + selectionNodes: SelectionNode[], +): SelectionNode[] { + const [fieldNodes, fragmentNodes] = partition( + selectionNodes, + (node): node is FieldNode => node.kind === Kind.FIELD, + ); + + const mergedFieldNodes = Array.from( + groupBy((node: FieldNode) => node.alias?.value ?? node.name.value)( + fieldNodes, + ).values(), + ).map((nodesWithSameResponseName) => { + const node = { ...nodesWithSameResponseName[0] }; + if (node.selectionSet) { + node.selectionSet = { + ...node.selectionSet, + selections: mergeFieldNodeSelectionSets( + nodesWithSameResponseName.flatMap( + (node) => node.selectionSet?.selections || [], + ), + ), + }; + } + return node; + }); + + return [...mergedFieldNodes, ...fragmentNodes]; +} diff --git a/query-planner-js/src/QueryPlan.ts b/query-planner-js/src/QueryPlan.ts new file mode 100644 index 000000000..26219e75a --- /dev/null +++ b/query-planner-js/src/QueryPlan.ts @@ -0,0 +1,123 @@ +import { + FragmentDefinitionNode, + GraphQLSchema, + OperationDefinitionNode, + Kind, + SelectionNode as GraphQLJSSelectionNode, +} from 'graphql'; +import prettyFormat from 'pretty-format'; +import { queryPlanSerializer, astSerializer } from './snapshotSerializers'; + +export type ResponsePath = (string | number)[]; + +export type FragmentMap = { [fragmentName: string]: FragmentDefinitionNode }; + +export type OperationContext = { + schema: GraphQLSchema; + operation: OperationDefinitionNode; + fragments: FragmentMap; +}; + +export interface QueryPlan { + kind: 'QueryPlan'; + node?: PlanNode; +} + +export type PlanNode = SequenceNode | ParallelNode | FetchNode | FlattenNode; + +export interface SequenceNode { + kind: 'Sequence'; + nodes: PlanNode[]; +} + +export interface ParallelNode { + kind: 'Parallel'; + nodes: PlanNode[]; +} + +export interface FetchNode { + kind: 'Fetch'; + serviceName: string; + variableUsages?: string[]; + requires?: QueryPlanSelectionNode[]; + operation: string; +} + +export interface FlattenNode { + kind: 'Flatten'; + path: ResponsePath; + node: PlanNode; +} + +/** + * SelectionNodes from GraphQL-js _can_ have a FragmentSpreadNode + * but this SelectionNode is specifically typing the `requires` key + * in a built query plan, where there can't be FragmentSpreadNodes + * since that info is contained in the `FetchNode.operation` + */ +export type QueryPlanSelectionNode = QueryPlanFieldNode | QueryPlanInlineFragmentNode; + +export interface QueryPlanFieldNode { + readonly kind: 'Field'; + readonly alias?: string; + readonly name: string; + readonly selections?: QueryPlanSelectionNode[]; +} + +export interface QueryPlanInlineFragmentNode { + readonly kind: 'InlineFragment'; + readonly typeCondition?: string; + readonly selections: QueryPlanSelectionNode[]; +} + +export function serializeQueryPlan(queryPlan: QueryPlan) { + return prettyFormat(queryPlan, { + plugins: [queryPlanSerializer, astSerializer], + }); +} + +export function getResponseName(node: QueryPlanFieldNode): string { + return node.alias ? node.alias : node.name; +} + +/** + * Converts a GraphQL-js SelectionNode to our newly defined SelectionNode + * + * This function is used to remove the unneeded pieces of a SelectionSet's + * `.selections`. It is only ever called on a query plan's `requires` field, + * so we can guarantee there won't be any FragmentSpreads passed in. That's why + * we can ignore the case where `selection.kind === Kind.FRAGMENT_SPREAD` + */ +export const trimSelectionNodes = ( + selections: readonly GraphQLJSSelectionNode[], +): QueryPlanSelectionNode[] => { + /** + * Using an array to push to instead of returning value from `selections.map` + * because TypeScript thinks we can encounter a `Kind.FRAGMENT_SPREAD` here, + * so if we mapped the array directly to the return, we'd have to `return undefined` + * from one branch of the map and then `.filter(Boolean)` on that returned + * array + */ + const remapped: QueryPlanSelectionNode[] = []; + + selections.forEach((selection) => { + if (selection.kind === Kind.FIELD) { + remapped.push({ + kind: Kind.FIELD, + name: selection.name.value, + selections: + selection.selectionSet && + trimSelectionNodes(selection.selectionSet.selections), + }); + } + if (selection.kind === Kind.INLINE_FRAGMENT) { + remapped.push({ + kind: Kind.INLINE_FRAGMENT, + typeCondition: selection.typeCondition?.name.value, + selections: trimSelectionNodes(selection.selectionSet.selections), + }); + } + }); + + return remapped; +}; diff --git a/query-planner-js/src/__tests__/allFeatures.test.ts b/query-planner-js/src/__tests__/allFeatures.test.ts new file mode 100644 index 000000000..70b4e2678 --- /dev/null +++ b/query-planner-js/src/__tests__/allFeatures.test.ts @@ -0,0 +1,97 @@ +import fs from 'fs'; +import { DocumentNode, GraphQLSchema, parse, validate } from 'graphql'; +import { defineFeature, loadFeatures } from 'jest-cucumber'; +import path from 'path'; +import { + QueryPlan +} from '..'; +import { + buildComposedSchema +} from '../composedSchema'; +import { + buildOperationContext, + buildQueryPlan, +} from '../buildQueryPlan'; + +// This test looks over all directories under tests/features and finds "supergraphSdl.graphql" in +// each of those directories. It runs all of the .feature cases in that directory against that schema. +// To add test cases against new schemas, create a sub directory under "features" with the new schema +// and new .feature files. + +const featuresPath = path.join(__dirname, 'features'); + +const directories = fs + .readdirSync(featuresPath, { + withFileTypes: true, + }) + .flatMap((entry) => + entry.isDirectory() ? path.join(featuresPath, entry.name) : [], + ); + +for (const directory of directories) { + const schemaPath = path.join(directory, 'supergraphSdl.graphql'); + + const features = loadFeatures(path.join(directory, '*.feature')); + + features.forEach((feature) => { + defineFeature(feature, (test) => { + let schema: GraphQLSchema; + + beforeAll(() => { + const supergraphSdl = fs.readFileSync(schemaPath, 'utf8'); + schema = buildComposedSchema(parse(supergraphSdl)); + }); + + feature.scenarios.forEach((scenario) => { + test(scenario.title, ({ given, when, then, pending }) => { + let queryDocument: DocumentNode; + let queryPlan: QueryPlan; + + const givenQuery = () => { + given(/^query$/im, (operationString: string) => { + queryDocument = parse(operationString); + validate(schema, queryDocument); + }); + }; + + const whenUsingAutoFragmentization = () => { + when(/using autofragmentization/i, () => { + pending(); + }); + }; + + const thenQueryPlanShouldBe = () => { + then(/^query plan$/i, (expectedQueryPlanString: string) => { + queryPlan = buildQueryPlan( + buildOperationContext(schema, queryDocument), + ); + + const expectedQueryPlan = JSON.parse(expectedQueryPlanString); + + expect(queryPlan).toMatchQueryPlan(expectedQueryPlan); + }); + }; + + // Step over each defined step in the .feature and execute the correct + // matching step fn defined above. + scenario.steps.forEach(({ stepText }) => { + const title = stepText.toLocaleLowerCase(); + + if (title === 'query') { + givenQuery(); + } else if (title === 'using autofragmentization') { + whenUsingAutoFragmentization(); + } else if (title === 'query plan') { + thenQueryPlanShouldBe(); + } else { + throw new Error( + `No matching step found for step "${stepText}" used \ +in scenario "${scenario.title}" in feature "${feature.title}"`, + ); + } + }); + }); + }); + }); + }); +} diff --git a/query-planner-js/src/__tests__/features/autofrag/auto-fragmentization.feature b/query-planner-js/src/__tests__/features/autofrag/auto-fragmentization.feature new file mode 100644 index 000000000..b87857e10 --- /dev/null +++ b/query-planner-js/src/__tests__/features/autofrag/auto-fragmentization.feature @@ -0,0 +1,58 @@ +Feature: Auto fragmentization in Query Planning + Scenario: Using interfaces + Given query + """ + { + field { + a { b { f1 f2 f4 } } + b { f1 f2 f4 } + iface { + ...on IFaceImpl1 { x } + ...on IFaceImpl2 { x } + } + } + } + """ + When using autofragmentization + Then query plan + """ + { + "kind": "QueryPlan", + "node": { + "kind": "Fetch", + "serviceName": "users", + "variableUsages": [], + "operation": "{field{...__QueryPlanFragment_2__}}fragment __QueryPlanFragment_0__ on B{f1 f2 f4}fragment __QueryPlanFragment_1__ on IFace{__typename ...on IFaceImpl1{x}...on IFaceImpl2{x}}fragment __QueryPlanFragment_2__ on SomeField{a{b{...__QueryPlanFragment_0__}}b{...__QueryPlanFragment_0__}iface{...__QueryPlanFragment_1__}}" + } + } + """ + + Scenario: Identical selection sets in different types + Given query + """ + { + sender { + name + address + location + } + receiver { + name + address + location + } + } + """ + When using autofragmentization + Then query plan + """ + { + "kind": "QueryPlan", + "node": { + "kind": "Fetch", + "serviceName": "users", + "variableUsages": [], + "operation": "{sender{...__QueryPlanFragment_0__}receiver{...__QueryPlanFragment_1__}}fragment __QueryPlanFragment_0__ on SendingUser{name address location}fragment __QueryPlanFragment_1__ on ReceivingUser{name address location}" + } + } + """ diff --git a/query-planner-js/src/__tests__/features/autofrag/supergraphSdl.graphql b/query-planner-js/src/__tests__/features/autofrag/supergraphSdl.graphql new file mode 100644 index 000000000..a84c3ef9b --- /dev/null +++ b/query-planner-js/src/__tests__/features/autofrag/supergraphSdl.graphql @@ -0,0 +1,68 @@ +schema + @graph(name: "users", url: "undefined") + @composedGraph(version: 1) +{ + query: Query +} + +scalar Location + +type Query { + sender: SendingUser @resolve(graph: "users") + receiver: ReceivingUser @resolve(graph: "users") + field: SomeField @resolve(graph: "users") +} + +type SendingUser + @owner(graph: "users") + @key(fields: "{ id }", graph: "users") +{ + id: ID! + name: String + address: String + location: Location +} + +type ReceivingUser + @owner(graph: "users") + @key(fields: "{ id }", graph: "users") +{ + id: ID! + name: String + address: String + location: Location +} + +interface IFace { + x: Int +} + +type IFaceImpl1 implements IFace { x: Int } +type IFaceImpl2 implements IFace { x: Int } + +type SomeField { + a: A + b: B + iface: IFace +} + +type A { + b: B +} + +type B { + f1: String + f2: String + f3: String + f4: String + f5: String + f6: String +} + +directive @composedGraph(version: Int!) on SCHEMA +directive @graph(name: String!, url: String!) repeatable on SCHEMA +directive @owner(graph: String!) on OBJECT +directive @key(fields: String!, graph: String!) repeatable on OBJECT +directive @resolve(graph: String!) on FIELD_DEFINITION +directive @provides(fields: String!) on FIELD_DEFINITION +directive @requires(fields: String!) on FIELD_DEFINITION diff --git a/query-planner-js/src/__tests__/features/basic/abstract-types.feature b/query-planner-js/src/__tests__/features/basic/abstract-types.feature new file mode 100644 index 000000000..b71dcd525 --- /dev/null +++ b/query-planner-js/src/__tests__/features/basic/abstract-types.feature @@ -0,0 +1,633 @@ +Feature: Query Planner > Abstract Types + +Scenario: handles an abstract type from the base service + Given query + """ + query GetProduct($upc: String!) { + product(upc: $upc) { + upc + name + price + } + } + """ + Then query plan + """ + { + "kind": "QueryPlan", + "node": { + "kind": "Sequence", + "nodes": [ + { + "kind": "Fetch", + "serviceName": "product", + "variableUsages": ["upc"], + "operation": "query($upc:String!){product(upc:$upc){__typename ...on Book{upc __typename isbn price}...on Furniture{upc name price}}}" + }, + { + "kind": "Flatten", + "path": ["product"], + "node": { + "kind": "Fetch", + "serviceName": "books", + "requires": [ + { + "kind": "InlineFragment", + "typeCondition": "Book", + "selections": [ + { "kind": "Field", "name": "__typename" }, + { "kind": "Field", "name": "isbn" } + ] + } + ], + "variableUsages": [], + "operation": "query($representations:[_Any!]!){_entities(representations:$representations){...on Book{__typename isbn title year}}}" + } + }, + { + "kind": "Flatten", + "path": ["product"], + "node": { + "kind": "Fetch", + "serviceName": "product", + "requires": [ + { + "kind": "InlineFragment", + "typeCondition": "Book", + "selections": [ + { "kind": "Field", "name": "__typename" }, + { "kind": "Field", "name": "isbn" }, + { "kind": "Field", "name": "title" }, + { "kind": "Field", "name": "year" } + ] + } + ], + "variableUsages": [], + "operation": "query($representations:[_Any!]!){_entities(representations:$representations){...on Book{name}}}" + } + } + ] + } + } + """ + +Scenario: can request fields on extended interfaces + Given query + """ + query GetProduct($upc: String!) { + product(upc: $upc) { + inStock + } + } + """ + Then query plan + """ + { + "kind": "QueryPlan", + "node": { + "kind": "Sequence", + "nodes": [ + { + "kind": "Fetch", + "serviceName": "product", + "variableUsages": ["upc"], + "operation": "query($upc:String!){product(upc:$upc){__typename ...on Book{__typename isbn}...on Furniture{__typename sku}}}" + }, + { + "kind": "Flatten", + "path": ["product"], + "node": { + "kind": "Fetch", + "serviceName": "inventory", + "requires": [ + { + "kind": "InlineFragment", + "typeCondition": "Book", + "selections": [ + { "kind": "Field", "name": "__typename" }, + { "kind": "Field", "name": "isbn" } + ] + }, + { + "kind": "InlineFragment", + "typeCondition": "Furniture", + "selections": [ + { "kind": "Field", "name": "__typename" }, + { "kind": "Field", "name": "sku" } + ] + } + ], + "variableUsages": [], + "operation": "query($representations:[_Any!]!){_entities(representations:$representations){...on Book{inStock}...on Furniture{inStock}}}" + } + } + ] + } + } + """ + +Scenario: can request fields on extended types that implement an interface + Given query + """ + query GetProduct($upc: String!) { + product(upc: $upc) { + inStock + ... on Furniture { + isHeavy + } + } + } + """ + Then query plan + """ + { + "kind": "QueryPlan", + "node": { + "kind": "Sequence", + "nodes": [ + { + "kind": "Fetch", + "serviceName": "product", + "variableUsages": ["upc"], + "operation": "query($upc:String!){product(upc:$upc){__typename ...on Book{__typename isbn}...on Furniture{__typename sku}}}" + }, + { + "kind": "Flatten", + "path": ["product"], + "node": { + "kind": "Fetch", + "serviceName": "inventory", + "requires": [ + { + "kind": "InlineFragment", + "typeCondition": "Book", + "selections": [ + { "kind": "Field", "name": "__typename" }, + { "kind": "Field", "name": "isbn" } + ] + }, + { + "kind": "InlineFragment", + "typeCondition": "Furniture", + "selections": [ + { "kind": "Field", "name": "__typename" }, + { "kind": "Field", "name": "sku" } + ] + } + ], + "variableUsages": [], + "operation": "query($representations:[_Any!]!){_entities(representations:$representations){...on Book{inStock}...on Furniture{inStock isHeavy}}}" + } + } + ] + } + } + """ + +Scenario: prunes unfilled type conditions + Given query + """ + query GetProduct($upc: String!) { + product(upc: $upc) { + inStock + ... on Furniture { + isHeavy + } + ... on Book { + isCheckedOut + } + } + } + """ + Then query plan + """ + { + "kind": "QueryPlan", + "node": { + "kind": "Sequence", + "nodes": [ + { + "kind": "Fetch", + "serviceName": "product", + "variableUsages": ["upc"], + "operation": "query($upc:String!){product(upc:$upc){__typename ...on Book{__typename isbn}...on Furniture{__typename sku}}}" + }, + { + "kind": "Flatten", + "path": ["product"], + "node": { + "kind": "Fetch", + "serviceName": "inventory", + "requires": [ + { + "kind": "InlineFragment", + "typeCondition": "Book", + "selections": [ + { "kind": "Field", "name": "__typename" }, + { "kind": "Field", "name": "isbn" } + ] + }, + { + "kind": "InlineFragment", + "typeCondition": "Furniture", + "selections": [ + { "kind": "Field", "name": "__typename" }, + { "kind": "Field", "name": "sku" } + ] + } + ], + "variableUsages": [], + "operation": "query($representations:[_Any!]!){_entities(representations:$representations){...on Book{inStock isCheckedOut}...on Furniture{inStock isHeavy}}}" + } + } + ] + } + } + """ + +Scenario: fetches interfaces returned from other services + Given query + """ + query GetUserAndProducts { + me { + reviews { + product { + price + ... on Book { + title + } + } + } + } + } + """ + Then query plan + """ + { + "kind": "QueryPlan", + "node": { + "kind": "Sequence", + "nodes": [ + { + "kind": "Fetch", + "serviceName": "accounts", + "variableUsages": [], + "operation": "{me{__typename id}}" + }, + { + "kind": "Flatten", + "path": ["me"], + "node": { + "kind": "Fetch", + "serviceName": "reviews", + "requires": [ + { + "kind": "InlineFragment", + "typeCondition": "User", + "selections": [ + { "kind": "Field", "name": "__typename" }, + { "kind": "Field", "name": "id" } + ] + } + ], + "variableUsages": [], + "operation": "query($representations:[_Any!]!){_entities(representations:$representations){...on User{reviews{product{__typename ...on Book{__typename isbn}...on Furniture{__typename upc}}}}}}" + } + }, + { + "kind": "Parallel", + "nodes": [ + { + "kind": "Flatten", + "path": ["me", "reviews", "@", "product"], + "node": { + "kind": "Fetch", + "serviceName": "product", + "requires": [ + { + "kind": "InlineFragment", + "typeCondition": "Book", + "selections": [ + { "kind": "Field", "name": "__typename" }, + { "kind": "Field", "name": "isbn" } + ] + }, + { + "kind": "InlineFragment", + "typeCondition": "Furniture", + "selections": [ + { "kind": "Field", "name": "__typename" }, + { "kind": "Field", "name": "upc" } + ] + } + ], + "variableUsages": [], + "operation": "query($representations:[_Any!]!){_entities(representations:$representations){...on Book{price}...on Furniture{price}}}" + } + }, + { + "kind": "Flatten", + "path": ["me", "reviews", "@", "product"], + "node": { + "kind": "Fetch", + "serviceName": "books", + "requires": [ + { + "kind": "InlineFragment", + "typeCondition": "Book", + "selections": [ + { "kind": "Field", "name": "__typename" }, + { "kind": "Field", "name": "isbn" } + ] + } + ], + "variableUsages": [], + "operation": "query($representations:[_Any!]!){_entities(representations:$representations){...on Book{title}}}" + } + } + ] + } + ] + } + } + """ + +Scenario: fetches composite fields from a foreign type casted to an interface [@provides field + Given query + """ + query GetUserAndProducts { + me { + reviews { + product { + price + ... on Book { + name + } + } + } + } + } + """ + Then query plan + """ + { + "kind": "QueryPlan", + "node": { + "kind": "Sequence", + "nodes": [ + { + "kind": "Fetch", + "serviceName": "accounts", + "variableUsages": [], + "operation": "{me{__typename id}}" + }, + { + "kind": "Flatten", + "path": ["me"], + "node": { + "kind": "Fetch", + "serviceName": "reviews", + "requires": [ + { + "kind": "InlineFragment", + "typeCondition": "User", + "selections": [ + { "kind": "Field", "name": "__typename" }, + { "kind": "Field", "name": "id" } + ] + } + ], + "variableUsages": [], + "operation": "query($representations:[_Any!]!){_entities(representations:$representations){...on User{reviews{product{__typename ...on Book{__typename isbn}...on Furniture{__typename upc}}}}}}" + } + }, + { + "kind": "Parallel", + "nodes": [ + { + "kind": "Flatten", + "path": ["me", "reviews", "@", "product"], + "node": { + "kind": "Fetch", + "serviceName": "product", + "requires": [ + { + "kind": "InlineFragment", + "typeCondition": "Book", + "selections": [ + { "kind": "Field", "name": "__typename" }, + { "kind": "Field", "name": "isbn" } + ] + }, + { + "kind": "InlineFragment", + "typeCondition": "Furniture", + "selections": [ + { "kind": "Field", "name": "__typename" }, + { "kind": "Field", "name": "upc" } + ] + } + ], + "variableUsages": [], + "operation": "query($representations:[_Any!]!){_entities(representations:$representations){...on Book{price}...on Furniture{price}}}" + } + }, + { + "kind": "Sequence", + "nodes": [ + { + "kind": "Flatten", + "path": ["me", "reviews", "@", "product"], + "node": { + "kind": "Fetch", + "serviceName": "books", + "requires": [ + { + "kind": "InlineFragment", + "typeCondition": "Book", + "selections": [ + { "kind": "Field", "name": "__typename" }, + { "kind": "Field", "name": "isbn" } + ] + } + ], + "variableUsages": [], + "operation": "query($representations:[_Any!]!){_entities(representations:$representations){...on Book{__typename isbn title year}}}" + } + }, + { + "kind": "Flatten", + "path": ["me", "reviews", "@", "product"], + "node": { + "kind": "Fetch", + "serviceName": "product", + "requires": [ + { + "kind": "InlineFragment", + "typeCondition": "Book", + "selections": [ + { "kind": "Field", "name": "__typename" }, + { "kind": "Field", "name": "isbn" }, + { "kind": "Field", "name": "title" }, + { "kind": "Field", "name": "year" } + ] + } + ], + "variableUsages": [], + "operation": "query($representations:[_Any!]!){_entities(representations:$representations){...on Book{name}}}" + } + } + ] + } + ] + } + ] + } + } + """ + +Scenario: allows for extending an interface from another service with fields + Given query + """ + query GetProduct($upc: String!) { + product(upc: $upc) { + reviews { + body + } + } + } + """ + Then query plan + """ + { + "kind": "QueryPlan", + "node": { + "kind": "Sequence", + "nodes": [ + { + "kind": "Fetch", + "serviceName": "product", + "variableUsages": ["upc"], + "operation": "query($upc:String!){product(upc:$upc){__typename ...on Book{__typename isbn}...on Furniture{__typename upc}}}" + }, + { + "kind": "Flatten", + "path": ["product"], + "node": { + "kind": "Fetch", + "serviceName": "reviews", + "requires": [ + { + "kind": "InlineFragment", + "typeCondition": "Book", + "selections": [ + { "kind": "Field", "name": "__typename" }, + { "kind": "Field", "name": "isbn" } + ] + }, + { + "kind": "InlineFragment", + "typeCondition": "Furniture", + "selections": [ + { "kind": "Field", "name": "__typename" }, + { "kind": "Field", "name": "upc" } + ] + } + ], + "variableUsages": [], + "operation": "query($representations:[_Any!]!){_entities(representations:$representations){...on Book{reviews{body}}...on Furniture{reviews{body}}}}" + } + } + ] + } + } + """ + +Scenario: handles unions from the same service + Given query + """ + query GetUserAndProducts { + me { + reviews { + product { + price + ... on Furniture { + brand { + ... on Ikea { + asile + } + ... on Amazon { + referrer + } + } + } + } + } + } + } + """ + Then query plan + """ + { + "kind": "QueryPlan", + "node": { + "kind": "Sequence", + "nodes": [ + { + "kind": "Fetch", + "serviceName": "accounts", + "variableUsages": [], + "operation": "{me{__typename id}}" + }, + { + "kind": "Flatten", + "path": ["me"], + "node": { + "kind": "Fetch", + "serviceName": "reviews", + "requires": [ + { + "kind": "InlineFragment", + "typeCondition": "User", + "selections": [ + { "kind": "Field", "name": "__typename" }, + { "kind": "Field", "name": "id" } + ] + } + ], + "variableUsages": [], + "operation": "query($representations:[_Any!]!){_entities(representations:$representations){...on User{reviews{product{__typename ...on Book{__typename isbn}...on Furniture{__typename upc}}}}}}" + } + }, + { + "kind": "Flatten", + "path": ["me", "reviews", "@", "product"], + "node": { + "kind": "Fetch", + "serviceName": "product", + "requires": [ + { + "kind": "InlineFragment", + "typeCondition": "Book", + "selections": [ + { "kind": "Field", "name": "__typename" }, + { "kind": "Field", "name": "isbn" } + ] + }, + { + "kind": "InlineFragment", + "typeCondition": "Furniture", + "selections": [ + { "kind": "Field", "name": "__typename" }, + { "kind": "Field", "name": "upc" } + ] + } + ], + "variableUsages": [], + "operation": "query($representations:[_Any!]!){_entities(representations:$representations){...on Book{price}...on Furniture{price brand{__typename ...on Ikea{asile}...on Amazon{referrer}}}}}" + } + } + ] + } + } + """ diff --git a/query-planner-js/src/__tests__/features/basic/aliases.feature b/query-planner-js/src/__tests__/features/basic/aliases.feature new file mode 100644 index 000000000..142fc26d1 --- /dev/null +++ b/query-planner-js/src/__tests__/features/basic/aliases.feature @@ -0,0 +1,304 @@ +Feature: Query Planning > Aliases + + +Scenario: supports simple aliases + Given query + """ + query GetProduct($upc: String!) { + product(upc: $upc) { + name + title: name + } + } + """ + Then query plan + """ + { + "kind": "QueryPlan", + "node": { + "kind": "Sequence", + "nodes": [ + { + "kind": "Fetch", + "serviceName": "product", + "variableUsages": ["upc"], + "operation": "query($upc:String!){product(upc:$upc){__typename ...on Book{__typename isbn}...on Furniture{name title:name}}}" + }, + { + "kind": "Flatten", + "path": ["product"], + "node": { + "kind": "Fetch", + "serviceName": "books", + "requires": [ + { + "kind": "InlineFragment", + "typeCondition": "Book", + "selections": [ + { "kind": "Field", "name": "__typename" }, + { "kind": "Field", "name": "isbn" } + ] + } + ], + "variableUsages": [], + "operation": "query($representations:[_Any!]!){_entities(representations:$representations){...on Book{__typename isbn title year}}}" + } + }, + { + "kind": "Flatten", + "path": ["product"], + "node": { + "kind": "Fetch", + "serviceName": "product", + "requires": [ + { + "kind": "InlineFragment", + "typeCondition": "Book", + "selections": [ + { "kind": "Field", "name": "__typename" }, + { "kind": "Field", "name": "isbn" }, + { "kind": "Field", "name": "title" }, + { "kind": "Field", "name": "year" } + ] + } + ], + "variableUsages": [], + "operation": "query($representations:[_Any!]!){_entities(representations:$representations){...on Book{name title:name}}}" + } + } + ] + } + } + """ + +Scenario: supports aliases of root fields on subservices + Given query + """ + query GetProduct($upc: String!) { + product(upc: $upc) { + name + title: name + reviews { + body + } + productReviews: reviews { + body + } + } + } + """ + Then query plan + """ + { + "kind": "QueryPlan", + "node": { + "kind": "Sequence", + "nodes": [ + { + "kind": "Fetch", + "serviceName": "product", + "variableUsages": ["upc"], + "operation": "query($upc:String!){product(upc:$upc){__typename ...on Book{__typename isbn}...on Furniture{name title:name __typename upc}}}" + }, + { + "kind": "Parallel", + "nodes": [ + { + "kind": "Sequence", + "nodes": [ + { + "kind": "Flatten", + "path": ["product"], + "node": { + "kind": "Fetch", + "serviceName": "books", + "requires": [ + { + "kind": "InlineFragment", + "typeCondition": "Book", + "selections": [ + { "kind": "Field", "name": "__typename" }, + { "kind": "Field", "name": "isbn" } + ] + } + ], + "variableUsages": [], + "operation": "query($representations:[_Any!]!){_entities(representations:$representations){...on Book{__typename isbn title year}}}" + } + }, + { + "kind": "Flatten", + "path": ["product"], + "node": { + "kind": "Fetch", + "serviceName": "product", + "requires": [ + { + "kind": "InlineFragment", + "typeCondition": "Book", + "selections": [ + { "kind": "Field", "name": "__typename" }, + { "kind": "Field", "name": "isbn" }, + { "kind": "Field", "name": "title" }, + { "kind": "Field", "name": "year" } + ] + } + ], + "variableUsages": [], + "operation": "query($representations:[_Any!]!){_entities(representations:$representations){...on Book{name title:name}}}" + } + } + ] + }, + { + "kind": "Flatten", + "path": ["product"], + "node": { + "kind": "Fetch", + "serviceName": "reviews", + "requires": [ + { + "kind": "InlineFragment", + "typeCondition": "Book", + "selections": [ + { "kind": "Field", "name": "__typename" }, + { "kind": "Field", "name": "isbn" } + ] + }, + { + "kind": "InlineFragment", + "typeCondition": "Furniture", + "selections": [ + { "kind": "Field", "name": "__typename" }, + { "kind": "Field", "name": "upc" } + ] + } + ], + "variableUsages": [], + "operation": "query($representations:[_Any!]!){_entities(representations:$representations){...on Book{reviews{body}productReviews:reviews{body}}...on Furniture{reviews{body}productReviews:reviews{body}}}}" + } + } + ] + } + ] + } + } + """ + +Scenario: supports aliases of nested fields on subservices + Given query + """ + query GetProduct($upc: String!) { + product(upc: $upc) { + name + title: name + reviews { + content: body + body + } + productReviews: reviews { + body + reviewer: author { + name: username + } + } + } + } + """ + Then query plan + """ + { + "kind": "QueryPlan", + "node": { + "kind": "Sequence", + "nodes": [ + { + "kind": "Fetch", + "serviceName": "product", + "variableUsages": ["upc"], + "operation": "query($upc:String!){product(upc:$upc){__typename ...on Book{__typename isbn}...on Furniture{name title:name __typename upc}}}" + }, + { + "kind": "Parallel", + "nodes": [ + { + "kind": "Sequence", + "nodes": [ + { + "kind": "Flatten", + "path": ["product"], + "node": { + "kind": "Fetch", + "serviceName": "books", + "requires": [ + { + "kind": "InlineFragment", + "typeCondition": "Book", + "selections": [ + { "kind": "Field", "name": "__typename" }, + { "kind": "Field", "name": "isbn" } + ] + } + ], + "variableUsages": [], + "operation": "query($representations:[_Any!]!){_entities(representations:$representations){...on Book{__typename isbn title year}}}" + } + }, + { + "kind": "Flatten", + "path": ["product"], + "node": { + "kind": "Fetch", + "serviceName": "product", + "requires": [ + { + "kind": "InlineFragment", + "typeCondition": "Book", + "selections": [ + { "kind": "Field", "name": "__typename" }, + { "kind": "Field", "name": "isbn" }, + { "kind": "Field", "name": "title" }, + { "kind": "Field", "name": "year" } + ] + } + ], + "variableUsages": [], + "operation": "query($representations:[_Any!]!){_entities(representations:$representations){...on Book{name title:name}}}" + } + } + ] + }, + { + "kind": "Flatten", + "path": ["product"], + "node": { + "kind": "Fetch", + "serviceName": "reviews", + "requires": [ + { + "kind": "InlineFragment", + "typeCondition": "Book", + "selections": [ + { "kind": "Field", "name": "__typename" }, + { "kind": "Field", "name": "isbn" } + ] + }, + { + "kind": "InlineFragment", + "typeCondition": "Furniture", + "selections": [ + { "kind": "Field", "name": "__typename" }, + { "kind": "Field", "name": "upc" } + ] + } + ], + "variableUsages": [], + "operation": "query($representations:[_Any!]!){_entities(representations:$representations){...on Book{reviews{content:body body}productReviews:reviews{body reviewer:author{name:username}}}...on Furniture{reviews{content:body body}productReviews:reviews{body reviewer:author{name:username}}}}}" + } + } + ] + } + ] + } + } + """ diff --git a/query-planner-js/src/__tests__/features/basic/boolean.feature b/query-planner-js/src/__tests__/features/basic/boolean.feature new file mode 100644 index 000000000..b008863ee --- /dev/null +++ b/query-planner-js/src/__tests__/features/basic/boolean.feature @@ -0,0 +1,328 @@ +Feature: Query Planning > Boolean + +Scenario: supports @skip when a boolean condition is met + Given query + """ + query GetReviewers { + topReviews { + body + author @skip(if: true) { + name + } + } + } + """ + Then query plan + """ + { + "kind": "QueryPlan", + "node": { + "kind": "Sequence", + "nodes": [ + { + "kind": "Fetch", + "serviceName": "reviews", + "variableUsages": [], + "operation": "{topReviews{body author@skip(if:true){__typename id}}}" + }, + { + "kind": "Flatten", + "path": ["topReviews", "@", "author"], + "node": { + "kind": "Fetch", + "serviceName": "accounts", + "requires": [ + { + "kind": "InlineFragment", + "typeCondition": "User", + "selections": [ + { "kind": "Field", "name": "__typename" }, + { "kind": "Field", "name": "id" } + ] + } + ], + "variableUsages": [], + "operation": "query($representations:[_Any!]!){_entities(representations:$representations){...on User{name}}}" + } + } + ] + } + } + """ + +Scenario: supports @skip when a boolean condition is met (variable driven) + Given query + """ + query GetReviewers($skip: Boolean! = true) { + topReviews { + body + author @skip(if: $skip) { + username + } + } + } + """ + Then query plan + """ + { + "kind": "QueryPlan", + "node": { + "kind": "Fetch", + "serviceName": "reviews", + "variableUsages": ["skip"], + "operation": "query($skip:Boolean!=true){topReviews{body author@skip(if:$skip){username}}}" + } + } + """ + +Scenario: supports @skip when a boolean condition is not met + Given query + """ + query GetReviewers { + topReviews { + body + author @skip(if: false) { + name + } + } + } + """ + Then query plan + """ + { + "kind": "QueryPlan", + "node": { + "kind": "Sequence", + "nodes": [ + { + "kind": "Fetch", + "serviceName": "reviews", + "variableUsages": [], + "operation": "{topReviews{body author@skip(if:false){__typename id}}}" + }, + { + "kind": "Flatten", + "path": ["topReviews", "@", "author"], + "node": { + "kind": "Fetch", + "serviceName": "accounts", + "requires": [ + { + "kind": "InlineFragment", + "typeCondition": "User", + "selections": [ + { "kind": "Field", "name": "__typename" }, + { "kind": "Field", "name": "id" } + ] + } + ], + "variableUsages": [], + "operation": "query($representations:[_Any!]!){_entities(representations:$representations){...on User{name}}}" + } + } + ] + } + } + """ + +Scenario: supports @skip when a boolean condition is not met (variable driven) + Given query + """ + query GetReviewers($skip: Boolean!) { + topReviews { + body + author @skip(if: $skip) { + name + } + } + } + """ + Then query plan + """ + { + "kind": "QueryPlan", + "node": { + "kind": "Sequence", + "nodes": [ + { + "kind": "Fetch", + "serviceName": "reviews", + "variableUsages": ["skip"], + "operation": "query($skip:Boolean!){topReviews{body author@skip(if:$skip){__typename id}}}" + }, + { + "kind": "Flatten", + "path": ["topReviews", "@", "author"], + "node": { + "kind": "Fetch", + "serviceName": "accounts", + "requires": [ + { + "kind": "InlineFragment", + "typeCondition": "User", + "selections": [ + { "kind": "Field", "name": "__typename" }, + { "kind": "Field", "name": "id" } + ] + } + ], + "variableUsages": [], + "operation": "query($representations:[_Any!]!){_entities(representations:$representations){...on User{name}}}" + } + } + ] + } + } + """ + +Scenario: supports @include when a boolean condition is not met + Given query + """ + query GetReviewers { + topReviews { + body + author @include(if: false) { + username + } + } + } + """ + Then query plan + """ + { + "kind": "QueryPlan", + "node": { + "kind": "Fetch", + "serviceName": "reviews", + "variableUsages": [], + "operation": "{topReviews{body author@include(if:false){username}}}" + } + } + """ + +Scenario: supports @include when a boolean condition is not met (variable driven) + Given query + """ + query GetReviewers($include: Boolean! = false) { + topReviews { + body + author @include(if: $include) { + username + } + } + } + """ + Then query plan + """ + { + "kind": "QueryPlan", + "node": { + "kind": "Fetch", + "serviceName": "reviews", + "variableUsages": ["include"], + "operation": "query($include:Boolean!=false){topReviews{body author@include(if:$include){username}}}" + } + } + """ + +Scenario: supports @include when a boolean condition is met + Given query + """ + query GetReviewers { + topReviews { + body + author @include(if: true) { + name + } + } + } + """ + Then query plan + """ + { + "kind": "QueryPlan", + "node": { + "kind": "Sequence", + "nodes": [ + { + "kind": "Fetch", + "serviceName": "reviews", + "variableUsages": [], + "operation": "{topReviews{body author@include(if:true){__typename id}}}" + }, + { + "kind": "Flatten", + "path": ["topReviews", "@", "author"], + "node": { + "kind": "Fetch", + "serviceName": "accounts", + "requires": [ + { + "kind": "InlineFragment", + "typeCondition": "User", + "selections": [ + { "kind": "Field", "name": "__typename" }, + { "kind": "Field", "name": "id" } + ] + } + ], + "variableUsages": [], + "operation": "query($representations:[_Any!]!){_entities(representations:$representations){...on User{name}}}" + } + } + ] + } + } + """ + + + +Scenario: supports @include when a boolean condition is met (variable driven) + Given query + """ + query GetReviewers($include: Boolean!) { + topReviews { + body + author @include(if: $include) { + name + } + } + } + """ + Then query plan + """ + { + "kind": "QueryPlan", + "node": { + "kind": "Sequence", + "nodes": [ + { + "kind": "Fetch", + "serviceName": "reviews", + "variableUsages": ["include"], + "operation": "query($include:Boolean!){topReviews{body author@include(if:$include){__typename id}}}" + }, + { + "kind": "Flatten", + "path": ["topReviews", "@", "author"], + "node": { + "kind": "Fetch", + "serviceName": "accounts", + "requires": [ + { + "kind": "InlineFragment", + "typeCondition": "User", + "selections": [ + { "kind": "Field", "name": "__typename" }, + { "kind": "Field", "name": "id" } + ] + } + ], + "variableUsages": [], + "operation": "query($representations:[_Any!]!){_entities(representations:$representations){...on User{name}}}" + } + } + ] + } + } + """ diff --git a/query-planner-js/src/__tests__/features/basic/build-query-plan.feature b/query-planner-js/src/__tests__/features/basic/build-query-plan.feature new file mode 100644 index 000000000..111981526 --- /dev/null +++ b/query-planner-js/src/__tests__/features/basic/build-query-plan.feature @@ -0,0 +1,1743 @@ +Feature: Query Planning > General + +Scenario: should not confuse union types with overlapping field names + Given query + """ + query { + body { + ...on Image { + attributes { + url + } + } + ...on Text { + attributes { + bold + text + } + } + } + } + """ + Then query plan + """ + { + "kind": "QueryPlan", + "node": { + "kind": "Fetch", + "serviceName": "documents", + "variableUsages": [], + "operation": "{body{__typename ...on Image{attributes{url}}...on Text{attributes{bold text}}}}" + } + } + """ + +Scenario: should use a single fetch when requesting a root field from one service + Given query + """ + query { + me { + name + } + } + """ + Then query plan + """ + { + "kind": "QueryPlan", + "node": { + "kind": "Fetch", + "serviceName": "accounts", + "variableUsages": [], + "operation": "{me{name}}" + } + } + """ + +Scenario: should use two independent fetches when requesting root fields from two services + Given query + """ + query { + me { + name + } + topProducts { + name + } + } + """ + Then query plan + """ + { + "kind": "QueryPlan", + "node": { + "kind": "Parallel", + "nodes": [ + { + "kind": "Fetch", + "serviceName": "accounts", + "variableUsages": [], + "operation": "{me{name}}" + }, + { + "kind": "Sequence", + "nodes": [ + { + "kind": "Fetch", + "serviceName": "product", + "variableUsages": [], + "operation": "{topProducts{__typename ...on Book{__typename isbn}...on Furniture{name}}}" + }, + { + "kind": "Flatten", + "path": ["topProducts", "@"], + "node": { + "kind": "Fetch", + "serviceName": "books", + "requires": [ + { + "kind": "InlineFragment", + "typeCondition": "Book", + "selections": [ + { "kind": "Field", "name": "__typename" }, + { "kind": "Field", "name": "isbn" } + ] + } + ], + "variableUsages": [], + "operation": "query($representations:[_Any!]!){_entities(representations:$representations){...on Book{__typename isbn title year}}}" + } + }, + { + "kind": "Flatten", + "path": ["topProducts", "@"], + "node": { + "kind": "Fetch", + "serviceName": "product", + "requires": [ + { + "kind": "InlineFragment", + "typeCondition": "Book", + "selections": [ + { "kind": "Field", "name": "__typename" }, + { "kind": "Field", "name": "isbn" }, + { "kind": "Field", "name": "title" }, + { "kind": "Field", "name": "year" } + ] + } + ], + "variableUsages": [], + "operation": "query($representations:[_Any!]!){_entities(representations:$representations){...on Book{name}}}" + } + } + ] + } + ] + } + } + """ + +Scenario: should use a single fetch when requesting multiple root fields from the same service + Given query + """ + query { + topProducts { + name + } + product(upc: "1") { + name + } + } + """ + Then query plan + """ + { + "kind": "QueryPlan", + "node": { + "kind": "Sequence", + "nodes": [ + { + "kind": "Fetch", + "serviceName": "product", + "variableUsages": [], + "operation": "{topProducts{__typename ...on Book{__typename isbn}...on Furniture{name}}product(upc:\"1\"){__typename ...on Book{__typename isbn}...on Furniture{name}}}" + }, + { + "kind": "Parallel", + "nodes": [ + { + "kind": "Sequence", + "nodes": [ + { + "kind": "Flatten", + "path": ["topProducts", "@"], + "node": { + "kind": "Fetch", + "serviceName": "books", + "requires": [ + { + "kind": "InlineFragment", + "typeCondition": "Book", + "selections": [ + { "kind": "Field", "name": "__typename" }, + { "kind": "Field", "name": "isbn" } + ] + } + ], + "variableUsages": [], + "operation": "query($representations:[_Any!]!){_entities(representations:$representations){...on Book{__typename isbn title year}}}" + } + }, + { + "kind": "Flatten", + "path": ["topProducts", "@"], + "node": { + "kind": "Fetch", + "serviceName": "product", + "requires": [ + { + "kind": "InlineFragment", + "typeCondition": "Book", + "selections": [ + { "kind": "Field", "name": "__typename" }, + { "kind": "Field", "name": "isbn" }, + { "kind": "Field", "name": "title" }, + { "kind": "Field", "name": "year" } + ] + } + ], + "variableUsages": [], + "operation": "query($representations:[_Any!]!){_entities(representations:$representations){...on Book{name}}}" + } + } + ] + }, + { + "kind": "Sequence", + "nodes": [ + { + "kind": "Flatten", + "path": ["product"], + "node": { + "kind": "Fetch", + "serviceName": "books", + "requires": [ + { + "kind": "InlineFragment", + "typeCondition": "Book", + "selections": [ + { "kind": "Field", "name": "__typename" }, + { "kind": "Field", "name": "isbn" } + ] + } + ], + "variableUsages": [], + "operation": "query($representations:[_Any!]!){_entities(representations:$representations){...on Book{__typename isbn title year}}}" + } + }, + { + "kind": "Flatten", + "path": ["product"], + "node": { + "kind": "Fetch", + "serviceName": "product", + "requires": [ + { + "kind": "InlineFragment", + "typeCondition": "Book", + "selections": [ + { "kind": "Field", "name": "__typename" }, + { "kind": "Field", "name": "isbn" }, + { "kind": "Field", "name": "title" }, + { "kind": "Field", "name": "year" } + ] + } + ], + "variableUsages": [], + "operation": "query($representations:[_Any!]!){_entities(representations:$representations){...on Book{name}}}" + } + } + ] + } + ] + } + ] + } + } + """ + +Scenario: should use a single fetch when requesting relationship subfields from the same service + Given query + """ + query { + topReviews { + body + author { + reviews { + body + } + } + } + } + """ + Then query plan + """ + { + "kind": "QueryPlan", + "node": { + "kind": "Fetch", + "serviceName": "reviews", + "variableUsages": [], + "operation": "{topReviews{body author{reviews{body}}}}" + } + } + """ + +Scenario: should use a single fetch when requesting relationship subfields and provided keys from the same service + Given query + """ + query { + topReviews { + body + author { + id + reviews { + body + } + } + } + } + """ + Then query plan + """ + { + "kind": "QueryPlan", + "node": { + "kind": "Fetch", + "serviceName": "reviews", + "variableUsages": [], + "operation": "{topReviews{body author{id reviews{body}}}}" + } + } + """ + +Scenario: when requesting an extension field from another service, it should add the field's representation requirements to the parent selection set and use a dependent fetch + Given query + """ + query { + me { + name + reviews { + body + } + } + } + """ + Then query plan + """ + { + "kind": "QueryPlan", + "node": { + "kind": "Sequence", + "nodes": [ + { + "kind": "Fetch", + "serviceName": "accounts", + "variableUsages": [], + "operation": "{me{name __typename id}}" + }, + { + "kind": "Flatten", + "path": ["me"], + "node": { + "kind": "Fetch", + "serviceName": "reviews", + "requires": [ + { + "kind": "InlineFragment", + "typeCondition": "User", + "selections": [ + { "kind": "Field", "name": "__typename" }, + { "kind": "Field", "name": "id" } + ] + } + ], + "variableUsages": [], + "operation": "query($representations:[_Any!]!){_entities(representations:$representations){...on User{reviews{body}}}}" + } + } + ] + } + } + """ + +Scenario: when requesting an extension field from another service, when the parent selection set is empty, should add the field's requirements to the parent selection set and use a dependent fetch + Given query + """ + query { + me { + reviews { + body + } + } + } + """ + Then query plan + """ + { + "kind": "QueryPlan", + "node": { + "kind": "Sequence", + "nodes": [ + { + "kind": "Fetch", + "serviceName": "accounts", + "variableUsages": [], + "operation": "{me{__typename id}}" + }, + { + "kind": "Flatten", + "path": ["me"], + "node": { + "kind": "Fetch", + "serviceName": "reviews", + "requires": [ + { + "kind": "InlineFragment", + "typeCondition": "User", + "selections": [ + { "kind": "Field", "name": "__typename" }, + { "kind": "Field", "name": "id" } + ] + } + ], + "variableUsages": [], + "operation": "query($representations:[_Any!]!){_entities(representations:$representations){...on User{reviews{body}}}}" + } + } + ] + } + } + """ + +Scenario: when requesting an extension field from another service, should only add requirements once + Given query + """ + query { + me { + reviews { + body + } + numberOfReviews + } + } + """ + Then query plan + """ + { + "kind": "QueryPlan", + "node": { + "kind": "Sequence", + "nodes": [ + { + "kind": "Fetch", + "serviceName": "accounts", + "variableUsages": [], + "operation": "{me{__typename id}}" + }, + { + "kind": "Flatten", + "path": ["me"], + "node": { + "kind": "Fetch", + "serviceName": "reviews", + "requires": [ + { + "kind": "InlineFragment", + "typeCondition": "User", + "selections": [ + { "kind": "Field", "name": "__typename" }, + { "kind": "Field", "name": "id" } + ] + } + ], + "variableUsages": [], + "operation": "query($representations:[_Any!]!){_entities(representations:$representations){...on User{reviews{body}numberOfReviews}}}" + } + } + ] + } + } + """ + +Scenario: when requesting a composite field with subfields from another service, it should add key fields to the parent selection set and use a dependent fetch + Given query + """ + query { + topReviews { + body + author { + name + } + } + } + """ + Then query plan + """ + { + "kind": "QueryPlan", + "node": { + "kind": "Sequence", + "nodes": [ + { + "kind": "Fetch", + "serviceName": "reviews", + "variableUsages": [], + "operation": "{topReviews{body author{__typename id}}}" + }, + { + "kind": "Flatten", + "path": ["topReviews", "@", "author"], + "node": { + "kind": "Fetch", + "serviceName": "accounts", + "requires": [ + { + "kind": "InlineFragment", + "typeCondition": "User", + "selections": [ + { "kind": "Field", "name": "__typename" }, + { "kind": "Field", "name": "id" } + ] + } + ], + "variableUsages": [], + "operation": "query($representations:[_Any!]!){_entities(representations:$representations){...on User{name}}}" + } + } + ] + } + } + """ + +Scenario: when requesting a composite field with subfields from another service, when requesting a field defined in another service which requires a field in the base service, it should add the field provided by base service in first Fetch + Given query + """ + query { + topCars { + retailPrice + } + } + """ + Then query plan + """ + { + "kind": "QueryPlan", + "node": { + "kind": "Sequence", + "nodes": [ + { + "kind": "Fetch", + "serviceName": "product", + "variableUsages": [], + "operation": "{topCars{__typename id price}}" + }, + { + "kind": "Flatten", + "path": ["topCars", "@"], + "node": { + "kind": "Fetch", + "serviceName": "reviews", + "requires": [ + { + "kind": "InlineFragment", + "typeCondition": "Car", + "selections": [ + { "kind": "Field", "name": "__typename" }, + { "kind": "Field", "name": "id" }, + { "kind": "Field", "name": "price" } + ] + } + ], + "variableUsages": [], + "operation": "query($representations:[_Any!]!){_entities(representations:$representations){...on Car{retailPrice}}}" + } + } + ] + } + } + """ + +Scenario: when requesting a composite field with subfields from another service, when the parent selection set is empty, it should add key fields to the parent selection set and use a dependent fetch + Given query + """ + query { + topReviews { + author { + name + } + } + } + """ + Then query plan + """ + { + "kind": "QueryPlan", + "node": { + "kind": "Sequence", + "nodes": [ + { + "kind": "Fetch", + "serviceName": "reviews", + "variableUsages": [], + "operation": "{topReviews{author{__typename id}}}" + }, + { + "kind": "Flatten", + "path": ["topReviews", "@", "author"], + "node": { + "kind": "Fetch", + "serviceName": "accounts", + "requires": [ + { + "kind": "InlineFragment", + "typeCondition": "User", + "selections": [ + { "kind": "Field", "name": "__typename" }, + { "kind": "Field", "name": "id" } + ] + } + ], + "variableUsages": [], + "operation": "query($representations:[_Any!]!){_entities(representations:$representations){...on User{name}}}" + } + } + ] + } + } + """ + +Scenario: when requesting a relationship field with extension subfields from a different service, it should first fetch the object using a key from the base service and then pass through the requirements + Given query + """ + query { + topReviews { + author { + birthDate + } + } + } + """ + Then query plan + """ + { + "kind": "QueryPlan", + "node": { + "kind": "Sequence", + "nodes": [ + { + "kind": "Fetch", + "serviceName": "reviews", + "variableUsages": [], + "operation": "{topReviews{author{__typename id}}}" + }, + { + "kind": "Flatten", + "path": ["topReviews", "@", "author"], + "node": { + "kind": "Fetch", + "serviceName": "accounts", + "requires": [ + { + "kind": "InlineFragment", + "typeCondition": "User", + "selections": [ + { "kind": "Field", "name": "__typename" }, + { "kind": "Field", "name": "id" } + ] + } + ], + "variableUsages": [], + "operation": "query($representations:[_Any!]!){_entities(representations:$representations){...on User{birthDate}}}" + } + } + ] + } + } + """ + +Scenario: for abstract types, it should add __typename when fetching objects of an interface type from a service + Given query + """ + query { + topProducts { + price + } + } + """ + Then query plan + """ + { + "kind": "QueryPlan", + "node": { + "kind": "Fetch", + "serviceName": "product", + "variableUsages": [], + "operation": "{topProducts{__typename ...on Book{price}...on Furniture{price}}}" + } + } + """ + +Scenario: should break up when traversing an extension field on an interface type from a service + Given query + """ + query { + topProducts { + price + reviews { + body + } + } + } + """ + Then query plan + """ + { + "kind": "QueryPlan", + "node": { + "kind": "Sequence", + "nodes": [ + { + "kind": "Fetch", + "serviceName": "product", + "variableUsages": [], + "operation": "{topProducts{__typename ...on Book{price __typename isbn}...on Furniture{price __typename upc}}}" + }, + { + "kind": "Flatten", + "path": ["topProducts", "@"], + "node": { + "kind": "Fetch", + "serviceName": "reviews", + "requires": [ + { + "kind": "InlineFragment", + "typeCondition": "Book", + "selections": [ + { "kind": "Field", "name": "__typename" }, + { "kind": "Field", "name": "isbn" } + ] + }, + { + "kind": "InlineFragment", + "typeCondition": "Furniture", + "selections": [ + { "kind": "Field", "name": "__typename" }, + { "kind": "Field", "name": "upc" } + ] + } + ], + "variableUsages": [], + "operation": "query($representations:[_Any!]!){_entities(representations:$representations){...on Book{reviews{body}}...on Furniture{reviews{body}}}}" + } + } + ] + } + } + """ + +Scenario: interface fragments should expand into possible types only + Given query + """ + query { + books { + ... on Product { + name + ... on Furniture { + upc + } + } + } + } + """ + Then query plan + """ + { + "kind": "QueryPlan", + "node": { + "kind": "Sequence", + "nodes": [ + { + "kind": "Fetch", + "serviceName": "books", + "variableUsages": [], + "operation": "{books{__typename isbn title year}}" + }, + { + "kind": "Flatten", + "path": ["books", "@"], + "node": { + "kind": "Fetch", + "serviceName": "product", + "requires": [ + { + "kind": "InlineFragment", + "typeCondition": "Book", + "selections": [ + { "kind": "Field", "name": "__typename" }, + { "kind": "Field", "name": "isbn" }, + { "kind": "Field", "name": "title" }, + { "kind": "Field", "name": "year" } + ] + } + ], + "variableUsages": [], + "operation": "query($representations:[_Any!]!){_entities(representations:$representations){...on Book{name}}}" + } + } + ] + } + } + """ + +Scenario: interface inside interface should expand into possible types only + Given query + """ + query { + product(upc: "") { + details { + country + } + } + } + """ + Then query plan + """ + { + "kind": "QueryPlan", + "node": { + "kind": "Fetch", + "serviceName": "product", + "variableUsages": [], + "operation": "{product(upc:\"\"){__typename ...on Book{details{country}}...on Furniture{details{country}}}}" + } + } + """ + +Scenario: experimental compression to downstream services should generate fragments internally to downstream requests + Given query + """ + query { + topReviews { + body + author + product { + name + price + details { + country + } + } + } + } + """ + When using autofragmentization + Then query plan + """ + { + "kind": "QueryPlan", + "node": { + "kind": "Sequence", + "nodes": [ + { + "kind": "Fetch", + "serviceName": "reviews", + "variableUsages": [], + "operation": "{topReviews{...__QueryPlanFragment_1__}}fragment __QueryPlanFragment_0__ on Product{__typename ...on Book{__typename isbn}...on Furniture{__typename upc}}fragment __QueryPlanFragment_1__ on Review{body author product{...__QueryPlanFragment_0__}}" + }, + { + "kind": "Parallel", + "nodes": [ + { + "kind": "Sequence", + "nodes": [ + { + "kind": "Flatten", + "path": ["topReviews", "@", "product"], + "node": { + "kind": "Fetch", + "serviceName": "books", + "requires": [ + { + "kind": "InlineFragment", + "typeCondition": "Book", + "selections": [ + { "kind": "Field", "name": "__typename" }, + { "kind": "Field", "name": "isbn" } + ] + } + ], + "variableUsages": [], + "operation": "query($representations:[_Any!]!){_entities(representations:$representations){...on Book{__typename isbn title year}}}" + } + }, + { + "kind": "Flatten", + "path": ["topReviews", "@", "product"], + "node": { + "kind": "Fetch", + "serviceName": "product", + "requires": [ + { + "kind": "InlineFragment", + "typeCondition": "Book", + "selections": [ + { "kind": "Field", "name": "__typename" }, + { "kind": "Field", "name": "isbn" }, + { "kind": "Field", "name": "title" }, + { "kind": "Field", "name": "year" } + ] + } + ], + "variableUsages": [], + "operation": "query($representations:[_Any!]!){_entities(representations:$representations){...on Book{name}}}" + } + } + ] + }, + { + "kind": "Flatten", + "path": ["topReviews", "@", "product"], + "node": { + "kind": "Fetch", + "serviceName": "product", + "requires": [ + { + "kind": "InlineFragment", + "typeCondition": "Furniture", + "selections": [ + { "kind": "Field", "name": "__typename" }, + { "kind": "Field", "name": "upc" } + ] + }, + { + "kind": "InlineFragment", + "typeCondition": "Book", + "selections": [ + { "kind": "Field", "name": "__typename" }, + { "kind": "Field", "name": "isbn" } + ] + } + ], + "variableUsages": [], + "operation": "query($representations:[_Any!]!){_entities(representations:$representations){...on Furniture{name price details{country}}...on Book{price details{country}}}}" + } + } + ] + } + ] + } + } + """ + +Scenario: experimental compression to downstream services shouldn't generate fragments for selection sets of length 2 or less + Given query + """ + query { + topReviews { + body + author + } + } + """ + When using autofragmentization + Then query plan + """ + { + "kind": "QueryPlan", + "node": { + "kind": "Fetch", + "serviceName": "reviews", + "variableUsages": [], + "operation": "{topReviews{body author}}" + } + } + """ + +Scenario: experimental compression to downstream services should generate fragments for selection sets of length 3 or greater + Given query + """ + query { + topReviews { + id + body + author + } + } + """ + When using autofragmentization + Then query plan + """ + { + "kind": "QueryPlan", + "node": { + "kind": "Fetch", + "serviceName": "reviews", + "variableUsages": [], + "operation": "{topReviews{...__QueryPlanFragment_0__}}fragment __QueryPlanFragment_0__ on Review{id body author}" + } + } + """ + +Scenario: experimental compression to downstream services should generate fragments correctly when aliases are used + Given query + """ + query { + reviews: topReviews { + content: body + author + product { + name + cost: price + details { + origin: country + } + } + } + } + """ + When using autofragmentization + Then query plan + """ + { + "kind": "QueryPlan", + "node": { + "kind": "Sequence", + "nodes": [ + { + "kind": "Fetch", + "serviceName": "reviews", + "variableUsages": [], + "operation": "{reviews:topReviews{...__QueryPlanFragment_1__}}fragment __QueryPlanFragment_0__ on Product{__typename ...on Book{__typename isbn}...on Furniture{__typename upc}}fragment __QueryPlanFragment_1__ on Review{content:body author product{...__QueryPlanFragment_0__}}" + }, + { + "kind": "Parallel", + "nodes": [ + { + "kind": "Sequence", + "nodes": [ + { + "kind": "Flatten", + "path": ["reviews", "@", "product"], + "node": { + "kind": "Fetch", + "serviceName": "books", + "requires": [ + { + "kind": "InlineFragment", + "typeCondition": "Book", + "selections": [ + { "kind": "Field", "name": "__typename" }, + { "kind": "Field", "name": "isbn" } + ] + } + ], + "variableUsages": [], + "operation": "query($representations:[_Any!]!){_entities(representations:$representations){...on Book{__typename isbn title year}}}" + } + }, + { + "kind": "Flatten", + "path": ["reviews", "@", "product"], + "node": { + "kind": "Fetch", + "serviceName": "product", + "requires": [ + { + "kind": "InlineFragment", + "typeCondition": "Book", + "selections": [ + { "kind": "Field", "name": "__typename" }, + { "kind": "Field", "name": "isbn" }, + { "kind": "Field", "name": "title" }, + { "kind": "Field", "name": "year" } + ] + } + ], + "variableUsages": [], + "operation": "query($representations:[_Any!]!){_entities(representations:$representations){...on Book{name}}}" + } + } + ] + }, + { + "kind": "Flatten", + "path": ["reviews", "@", "product"], + "node": { + "kind": "Fetch", + "serviceName": "product", + "requires": [ + { + "kind": "InlineFragment", + "typeCondition": "Furniture", + "selections": [ + { "kind": "Field", "name": "__typename" }, + { "kind": "Field", "name": "upc" } + ] + }, + { + "kind": "InlineFragment", + "typeCondition": "Book", + "selections": [ + { "kind": "Field", "name": "__typename" }, + { "kind": "Field", "name": "isbn" } + ] + } + ], + "variableUsages": [], + "operation": "query($representations:[_Any!]!){_entities(representations:$representations){...on Furniture{name cost:price details{origin:country}}...on Book{cost:price details{origin:country}}}}" + } + } + ] + } + ] + } + } + """ + +Scenario: should properly expand nested unions with inline fragments + Given query + """ + query { + body { + ... on Image { + ... on Body { + ... on Image { + attributes { + url + } + } + ... on Text { + attributes { + bold + text + } + } + } + } + ... on Text { + attributes { + bold + } + } + } + } + """ + Then query plan + """ + { + "kind": "QueryPlan", + "node": { + "kind": "Fetch", + "serviceName": "documents", + "variableUsages": [], + "operation": "{body{__typename ...on Image{attributes{url}}...on Text{attributes{bold}}}}" + } + } + """ + +Scenario: deduplicates fields / selections regardless of adjacency and type condition nesting for inline fragments + Given query + """ + query { + body { + ... on Image { + ... on Text { + attributes { + bold + } + } + } + ... on Body { + ... on Text { + attributes { + bold + text + } + } + } + ... on Text { + attributes { + bold + text + } + } + } + } + """ + Then query plan + """ + { + "kind": "QueryPlan", + "node": { + "kind": "Fetch", + "serviceName": "documents", + "variableUsages": [], + "operation": "{body{__typename ...on Text{attributes{bold text}}}}" + } + } + """ + +Scenario: deduplicates fields / selections regardless of adjacency and type condition nesting for named fragment spreads + Given query + """ + fragment TextFragment on Text { + attributes { + bold + text + } + } + + query { + body { + ... on Image { + ...TextFragment + } + ... on Body { + ...TextFragment + } + ...TextFragment + } + } + """ + Then query plan + """ + { + "kind": "QueryPlan", + "node": { + "kind": "Fetch", + "serviceName": "documents", + "variableUsages": [], + "operation": "{body{__typename ...on Text{attributes{bold text}}}}" + } + } + """ + +Scenario: supports basic, single-service mutation + Given query + """ + mutation Login($username: String!, $password: String!) { + login(username: $username, password: $password) { + id + } + } + """ + Then query plan + """ + { + "kind": "QueryPlan", + "node": { + "kind": "Fetch", + "serviceName": "accounts", + "variableUsages": [ + "username", + "password" + ], + "operation": "mutation($username:String!$password:String!){login(username:$username password:$password){id}}" + } + } + """ + +# ported from: https://github.com/apollographql/apollo-server/blob/main/packages/apollo-gateway/src/__tests__/integration/mutations.test.ts#L13 +Scenario: supports mutations with a cross-service request + Given query + """ + mutation Login($username: String!, $password: String!) { + login(username: $username, password: $password) { + reviews { + product { + upc + } + } + } + } + """ + Then query plan + """ + { + "kind": "QueryPlan", + "node": { + "kind": "Sequence", + "nodes": [ + { + "kind": "Fetch", + "serviceName": "accounts", + "variableUsages": [ + "username", + "password" + ], + "operation": "mutation($username:String!$password:String!){login(username:$username password:$password){__typename id}}" + }, + { + "kind": "Flatten", + "path": [ + "login" + ], + "node": { + "kind": "Fetch", + "serviceName": "reviews", + "requires": [ + { + "kind": "InlineFragment", + "typeCondition": "User", + "selections": [ + { + "kind": "Field", + "name": "__typename" + }, + { + "kind": "Field", + "name": "id" + } + ] + } + ], + "variableUsages": [], + "operation": "query($representations:[_Any!]!){_entities(representations:$representations){...on User{reviews{product{__typename ...on Book{__typename isbn}...on Furniture{upc}}}}}}" + } + }, + { + "kind": "Flatten", + "path": [ + "login", + "reviews", + "@", + "product" + ], + "node": { + "kind": "Fetch", + "serviceName": "product", + "requires": [ + { + "kind": "InlineFragment", + "typeCondition": "Book", + "selections": [ + { + "kind": "Field", + "name": "__typename" + }, + { + "kind": "Field", + "name": "isbn" + } + ] + } + ], + "variableUsages": [], + "operation": "query($representations:[_Any!]!){_entities(representations:$representations){...on Book{upc}}}" + } + } + ] + } + } + """ + +# ported from: https://github.com/apollographql/apollo-server/blob/main/packages/apollo-gateway/src/__tests__/integration/mutations.test.ts#L48 +Scenario: returning across service boundaries + Given query + """ + mutation Review($upc: String!, $body: String!) { + reviewProduct(upc: $upc, body: $body) { + ... on Furniture { + name + } + } + } + """ + Then query plan + """ + { + "kind": "QueryPlan", + "node": { + "kind": "Sequence", + "nodes": [ + { + "kind": "Fetch", + "serviceName": "reviews", + "variableUsages": [ + "upc", + "body" + ], + "operation": "mutation($upc:String!$body:String!){reviewProduct(upc:$upc body:$body){__typename ...on Furniture{__typename upc}}}" + }, + { + "kind": "Flatten", + "path": [ + "reviewProduct" + ], + "node": { + "kind": "Fetch", + "serviceName": "product", + "requires": [ + { + "kind": "InlineFragment", + "typeCondition": "Furniture", + "selections": [ + { + "kind": "Field", + "name": "__typename" + }, + { + "kind": "Field", + "name": "upc" + } + ] + } + ], + "variableUsages": [], + "operation": "query($representations:[_Any!]!){_entities(representations:$representations){...on Furniture{name}}}" + } + } + ] + } + } + """ + +# ported from: https://github.com/apollographql/apollo-server/blob/main/packages/apollo-gateway/src/__tests__/integration/mutations.test.ts#L75 +Scenario: supports multiple root mutations + Given query + """ + mutation LoginAndReview( + $username: String! + $password: String! + $upc: String! + $body: String! + ) { + login(username: $username, password: $password) { + reviews { + product { + upc + } + } + } + reviewProduct(upc: $upc, body: $body) { + ... on Furniture { + name + } + } + } + """ + Then query plan + """ + { + "kind": "QueryPlan", + "node": { + "kind": "Sequence", + "nodes": [ + { + "kind": "Fetch", + "serviceName": "accounts", + "variableUsages": [ + "username", + "password" + ], + "operation": "mutation($username:String!$password:String!){login(username:$username password:$password){__typename id}}" + }, + { + "kind": "Flatten", + "path": [ + "login" + ], + "node": { + "kind": "Fetch", + "serviceName": "reviews", + "requires": [ + { + "kind": "InlineFragment", + "typeCondition": "User", + "selections": [ + { + "kind": "Field", + "name": "__typename" + }, + { + "kind": "Field", + "name": "id" + } + ] + } + ], + "variableUsages": [], + "operation": "query($representations:[_Any!]!){_entities(representations:$representations){...on User{reviews{product{__typename ...on Book{__typename isbn}...on Furniture{upc}}}}}}" + } + }, + { + "kind": "Flatten", + "path": [ + "login", + "reviews", + "@", + "product" + ], + "node": { + "kind": "Fetch", + "serviceName": "product", + "requires": [ + { + "kind": "InlineFragment", + "typeCondition": "Book", + "selections": [ + { + "kind": "Field", + "name": "__typename" + }, + { + "kind": "Field", + "name": "isbn" + } + ] + } + ], + "variableUsages": [], + "operation": "query($representations:[_Any!]!){_entities(representations:$representations){...on Book{upc}}}" + } + }, + { + "kind": "Fetch", + "serviceName": "reviews", + "variableUsages": [ + "upc", + "body" + ], + "operation": "mutation($upc:String!$body:String!){reviewProduct(upc:$upc body:$body){__typename ...on Furniture{__typename upc}}}" + }, + { + "kind": "Flatten", + "path": [ + "reviewProduct" + ], + "node": { + "kind": "Fetch", + "serviceName": "product", + "requires": [ + { + "kind": "InlineFragment", + "typeCondition": "Furniture", + "selections": [ + { + "kind": "Field", + "name": "__typename" + }, + { + "kind": "Field", + "name": "upc" + } + ] + } + ], + "variableUsages": [], + "operation": "query($representations:[_Any!]!){_entities(representations:$representations){...on Furniture{name}}}" + } + } + ] + } + } + """ + +# ported from: https://github.com/apollographql/apollo-server/blob/main/packages/apollo-gateway/src/__tests__/integration/mutations.test.ts#L136 +Scenario: multiple root mutations with correct service order + Given query + """ + mutation LoginAndReview( + $upc: String! + $body: String! + $updatedReview: UpdateReviewInput! + $username: String! + $password: String! + $reviewId: ID! + ) { + reviewProduct(upc: $upc, body: $body) { + ... on Furniture { + upc + } + } + updateReview(review: $updatedReview) { + id + body + } + login(username: $username, password: $password) { + reviews { + product { + upc + } + } + } + deleteReview(id: $reviewId) + } + """ + Then query plan + """ + { + "kind": "QueryPlan", + "node": { + "kind": "Sequence", + "nodes": [ + { + "kind": "Fetch", + "serviceName": "reviews", + "variableUsages": [ + "upc", + "body", + "updatedReview" + ], + "operation": "mutation($upc:String!$body:String!$updatedReview:UpdateReviewInput!){reviewProduct(upc:$upc body:$body){__typename ...on Furniture{upc}}updateReview(review:$updatedReview){id body}}" + }, + { + "kind": "Fetch", + "serviceName": "accounts", + "variableUsages": [ + "username", + "password" + ], + "operation": "mutation($username:String!$password:String!){login(username:$username password:$password){__typename id}}" + }, + { + "kind": "Flatten", + "path": [ + "login" + ], + "node": { + "kind": "Fetch", + "serviceName": "reviews", + "requires": [ + { + "kind": "InlineFragment", + "typeCondition": "User", + "selections": [ + { + "kind": "Field", + "name": "__typename" + }, + { + "kind": "Field", + "name": "id" + } + ] + } + ], + "variableUsages": [], + "operation": "query($representations:[_Any!]!){_entities(representations:$representations){...on User{reviews{product{__typename ...on Book{__typename isbn}...on Furniture{upc}}}}}}" + } + }, + { + "kind": "Flatten", + "path": [ + "login", + "reviews", + "@", + "product" + ], + "node": { + "kind": "Fetch", + "serviceName": "product", + "requires": [ + { + "kind": "InlineFragment", + "typeCondition": "Book", + "selections": [ + { + "kind": "Field", + "name": "__typename" + }, + { + "kind": "Field", + "name": "isbn" + } + ] + } + ], + "variableUsages": [], + "operation": "query($representations:[_Any!]!){_entities(representations:$representations){...on Book{upc}}}" + } + }, + { + "kind": "Fetch", + "serviceName": "reviews", + "variableUsages": [ + "reviewId" + ], + "operation": "mutation($reviewId:ID!){deleteReview(id:$reviewId)}" + } + ] + } + } + """ + +Scenario: supports arrays + Given query + """ + query MergeArrays { + me { + # goodAddress + goodDescription + metadata { + address + } + } + } + """ + Then query plan + """ + { + "kind": "QueryPlan", + "node": { + "kind": "Sequence", + "nodes": [ + { + "kind": "Fetch", + "serviceName": "accounts", + "variableUsages": [], + "operation": "{me{__typename id metadata{description address}}}" + }, + { + "kind": "Flatten", + "path": [ + "me" + ], + "node": { + "kind": "Fetch", + "serviceName": "inventory", + "requires": [ + { + "kind": "InlineFragment", + "typeCondition": "User", + "selections": [ + { + "kind": "Field", + "name": "__typename" + }, + { + "kind": "Field", + "name": "id" + }, + { + "kind": "Field", + "name": "metadata", + "selections": [ + { + "kind": "Field", + "name": "description" + } + ] + } + ] + } + ], + "variableUsages": [], + "operation": "query($representations:[_Any!]!){_entities(representations:$representations){...on User{goodDescription}}}" + } + } + ] + } + } + """ diff --git a/query-planner-js/src/__tests__/features/basic/custom-directives.feature b/query-planner-js/src/__tests__/features/basic/custom-directives.feature new file mode 100644 index 000000000..864ca4b4f --- /dev/null +++ b/query-planner-js/src/__tests__/features/basic/custom-directives.feature @@ -0,0 +1,73 @@ +Feature: Query Planning > Custom Directives + +Scenario: successfully passes directives along in requests to an underlying service + Given query + """ + query GetReviewers { + topReviews { + body @stream + } + } + """ + Then query plan + """ + { + "kind": "QueryPlan", + "node": { + "kind": "Fetch", + "serviceName": "reviews", + "variableUsages": [], + "operation": "{topReviews{body@stream}}" + } + } + """ + +Scenario: successfully passes directives and their variables along in requests to underlying services + Given query + """ + query GetReviewers { + topReviews { + body @stream + author @transform(from: "JSON") { + name @stream + } + } + } + """ + Then query plan + """ + { + "kind": "QueryPlan", + "node": { + "kind": "Sequence", + "nodes": [ + { + "kind": "Fetch", + "serviceName": "reviews", + "variableUsages": [], + "operation": "{topReviews{body@stream author@transform(from:\"JSON\"){__typename id}}}" + }, + { + "kind": "Flatten", + "path": ["topReviews", "@", "author"], + "node": { + "kind": "Fetch", + "serviceName": "accounts", + "requires": [ + { + "kind": "InlineFragment", + "typeCondition": "User", + "selections": [ + { "kind": "Field", "name": "__typename" }, + { "kind": "Field", "name": "id" } + ] + } + ], + "variableUsages": [], + "operation": "query($representations:[_Any!]!){_entities(representations:$representations){...on User{name@stream}}}" + } + } + ] + } + } + """ diff --git a/query-planner-js/src/__tests__/features/basic/execution-style.feature b/query-planner-js/src/__tests__/features/basic/execution-style.feature new file mode 100644 index 000000000..87f86deb2 --- /dev/null +++ b/query-planner-js/src/__tests__/features/basic/execution-style.feature @@ -0,0 +1,37 @@ +Feature: Query Planning > Execution Style + +Scenario: supports parallel root fields + Given query + """ + query GetUserAndReviews { + me { + username + } + topReviews { + body + } + } + """ + Then query plan + """ + { + "kind": "QueryPlan", + "node": { + "kind": "Parallel", + "nodes": [ + { + "kind": "Fetch", + "serviceName": "accounts", + "variableUsages": [], + "operation": "{me{username}}" + }, + { + "kind": "Fetch", + "serviceName": "reviews", + "variableUsages": [], + "operation": "{topReviews{body}}" + } + ] + } + } + """ diff --git a/query-planner-js/src/__tests__/features/basic/fragments.feature b/query-planner-js/src/__tests__/features/basic/fragments.feature new file mode 100644 index 000000000..83f033b49 --- /dev/null +++ b/query-planner-js/src/__tests__/features/basic/fragments.feature @@ -0,0 +1,345 @@ +Feature: Query Planning > Fragments + +# important thing here: fetches to accounts service +Scenario: supports inline fragments (one level) + Given query + """ + query GetUser { + me { + ... on User { + username + } + } + } + """ + Then query plan + """ + {"kind":"QueryPlan","node":{"kind":"Fetch","serviceName":"accounts","variableUsages":[],"operation":"{me{username}}"}} + """ + +# important things: calls [accounts, reviews, products, books] +Scenario: supports inline fragments (multi level) + Given query + """ + query GetUser { + me { + ... on User { + username + reviews { + ... on Review { + body + product { + ... on Product { + ... on Book { + title + } + ... on Furniture { + name + } + } + } + } + } + } + } + } + """ + Then query plan + """ + { + "kind": "QueryPlan", + "node": { + "kind": "Sequence", + "nodes": [ + { + "kind": "Fetch", + "serviceName": "accounts", + "variableUsages": [], + "operation": "{me{username __typename id}}" + }, + { + "kind": "Flatten", + "path": ["me"], + "node": { + "kind": "Fetch", + "serviceName": "reviews", + "requires": [ + { + "kind": "InlineFragment", + "typeCondition": "User", + "selections": [ + { "kind": "Field", "name": "__typename" }, + { "kind": "Field", "name": "id" } + ] + } + ], + "variableUsages": [], + "operation": "query($representations:[_Any!]!){_entities(representations:$representations){...on User{reviews{body product{__typename ...on Book{__typename isbn}...on Furniture{__typename upc}}}}}}" + } + }, + { + "kind": "Parallel", + "nodes": [ + { + "kind": "Flatten", + "path": ["me", "reviews", "@", "product"], + "node": { + "kind": "Fetch", + "serviceName": "books", + "requires": [ + { + "kind": "InlineFragment", + "typeCondition": "Book", + "selections": [ + { "kind": "Field", "name": "__typename" }, + { "kind": "Field", "name": "isbn" } + ] + } + ], + "variableUsages": [], + "operation": "query($representations:[_Any!]!){_entities(representations:$representations){...on Book{title}}}" + } + }, + { + "kind": "Flatten", + "path": ["me", "reviews", "@", "product"], + "node": { + "kind": "Fetch", + "serviceName": "product", + "requires": [ + { + "kind": "InlineFragment", + "typeCondition": "Furniture", + "selections": [ + { "kind": "Field", "name": "__typename" }, + { "kind": "Field", "name": "upc" } + ] + } + ], + "variableUsages": [], + "operation": "query($representations:[_Any!]!){_entities(representations:$representations){...on Furniture{name}}}" + } + } + ] + } + ] + } + } + """ + +Scenario: supports named fragments (one level) + Given query + """ + query GetUser { + me { + ...userDetails + } + } + + fragment userDetails on User { + username + } + """ + Then query plan + """ + { + "kind": "QueryPlan", + "node": { + "kind": "Fetch", + "serviceName": "accounts", + "variableUsages": [], + "operation": "{me{username}}" + } + } + """ + +# important: calls accounts service +Scenario: supports multiple named fragments (one level, mixed ordering) + Given query + """ + fragment userInfo on User { + name + } + query GetUser { + me { + ...userDetails + ...userInfo + } + } + + fragment userDetails on User { + username + } + """ + Then query plan + """ + { + "kind": "QueryPlan", + "node": { + "kind": "Fetch", + "serviceName": "accounts", + "variableUsages": [], + "operation": "{me{username name}}" + } + } + """ + +Scenario: supports multiple named fragments (multi level, mixed ordering) + Given query + """ + fragment reviewDetails on Review { + body + } + query GetUser { + me { + ...userDetails + } + } + + fragment userDetails on User { + username + reviews { + ...reviewDetails + } + } + """ + Then query plan + """ + { + "kind": "QueryPlan", + "node": { + "kind": "Sequence", + "nodes": [ + { + "kind": "Fetch", + "serviceName": "accounts", + "variableUsages": [], + "operation": "{me{username __typename id}}" + }, + { + "kind": "Flatten", + "path": ["me"], + "node": { + "kind": "Fetch", + "serviceName": "reviews", + "requires": [ + { + "kind": "InlineFragment", + "typeCondition": "User", + "selections": [ + { "kind": "Field", "name": "__typename" }, + { "kind": "Field", "name": "id" } + ] + } + ], + "variableUsages": [], + "operation": "query($representations:[_Any!]!){_entities(representations:$representations){...on User{reviews{body}}}}" + } + } + ] + } + } + """ + +# important: calls accounts & reviews, uses `variableUsages` +Scenario: supports variables within fragments + Given query + """ + query GetUser($format: Boolean) { + me { + ...userDetails + } + } + + fragment userDetails on User { + username + reviews { + body(format: $format) + } + } + """ + Then query plan + """ + { + "kind": "QueryPlan", + "node": { + "kind": "Sequence", + "nodes": [ + { + "kind": "Fetch", + "serviceName": "accounts", + "variableUsages": [], + "operation": "{me{username __typename id}}" + }, + { + "kind": "Flatten", + "path": ["me"], + "node": { + "kind": "Fetch", + "serviceName": "reviews", + "requires": [ + { + "kind": "InlineFragment", + "typeCondition": "User", + "selections": [ + { "kind": "Field", "name": "__typename" }, + { "kind": "Field", "name": "id" } + ] + } + ], + "variableUsages": ["format"], + "operation": "query($representations:[_Any!]!$format:Boolean){_entities(representations:$representations){...on User{reviews{body(format:$format)}}}}" + } + } + ] + } + } + """ + +Scenario: supports root fragments + Given query + """ + query GetUser { + ... on Query { + me { + username + } + } + } + """ + Then query plan + """ + { + "kind": "QueryPlan", + "node": { + "kind": "Fetch", + "serviceName": "accounts", + "variableUsages": [], + "operation": "{me{username}}" + } + } + """ + +Scenario: supports directives on inline fragments (https://github.com/apollographql/federation/issues/177) + Given query + """ + query GetVehicle { + vehicle(id:"rav4") { + ... on Car @fragmentDirective { + price + thing { + ... on Ikea { + asile + } + } + } + ... on Van { + price @fieldDirective + } + } + } + """ + Then query plan + """ + {"kind":"QueryPlan","node":{"kind":"Fetch","serviceName":"product","variableUsages":[],"operation":"{vehicle(id:\"rav4\"){__typename ...on Car@fragmentDirective{price thing{__typename ...on Ikea{asile}}}...on Van{price@fieldDirective}}}"}} + """ diff --git a/query-planner-js/src/__tests__/features/basic/introspection.feature b/query-planner-js/src/__tests__/features/basic/introspection.feature new file mode 100644 index 000000000..bfae66e14 --- /dev/null +++ b/query-planner-js/src/__tests__/features/basic/introspection.feature @@ -0,0 +1,120 @@ +Feature: Introspection queries + + Scenario: Can execute schema introspection query + Given query + """ + query IntrospectionQuery { + __schema { + queryType { + name + } + mutationType { + name + } + subscriptionType { + name + } + types { + ...FullType + } + directives { + name + description + locations + args { + ...InputValue + } + } + } + } + fragment FullType on __Type { + kind + name + description + fields(includeDeprecated: true) { + name + description + args { + ...InputValue + } + type { + ...TypeRef + } + isDeprecated + deprecationReason + } + inputFields { + ...InputValue + } + interfaces { + ...TypeRef + } + enumValues(includeDeprecated: true) { + name + description + isDeprecated + deprecationReason + } + possibleTypes { + ...TypeRef + } + } + fragment InputValue on __InputValue { + name + description + type { + ...TypeRef + } + defaultValue + } + fragment TypeRef on __Type { + kind + name + ofType { + kind + name + ofType { + kind + name + ofType { + kind + name + ofType { + kind + name + ofType { + kind + name + ofType { + kind + name + ofType { + kind + name + } + } + } + } + } + } + } + } + """ + Then query plan + """ + { "kind": "QueryPlan" } + """ + + Scenario: Can execute type introspection query + Given query + """ + query($foo:String!) { + __type(name:$foo) { + enumValues{ __typename name } + } + } + """ + Then query plan + """ + { "kind": "QueryPlan" } + """ diff --git a/query-planner-js/src/__tests__/features/basic/mutations.feature b/query-planner-js/src/__tests__/features/basic/mutations.feature new file mode 100644 index 000000000..f6eaf15b5 --- /dev/null +++ b/query-planner-js/src/__tests__/features/basic/mutations.feature @@ -0,0 +1,331 @@ +Feature: Query Planning > Mutations + +Scenario: supports mutations + Given query + """ + mutation Login($username: String!, $password: String!) { + login(username: $username, password: $password) { + reviews { + product { + upc + } + } + } + } + """ + Then query plan + """ + { + "kind": "QueryPlan", + "node": { + "kind": "Sequence", + "nodes": [ + { + "kind": "Fetch", + "serviceName": "accounts", + "variableUsages": ["username", "password"], + "operation": "mutation($username:String!$password:String!){login(username:$username password:$password){__typename id}}" + }, + { + "kind": "Flatten", + "path": ["login"], + "node": { + "kind": "Fetch", + "serviceName": "reviews", + "requires": [ + { + "kind": "InlineFragment", + "typeCondition": "User", + "selections": [ + { "kind": "Field", "name": "__typename" }, + { "kind": "Field", "name": "id" } + ] + } + ], + "variableUsages": [], + "operation": "query($representations:[_Any!]!){_entities(representations:$representations){...on User{reviews{product{__typename ...on Book{__typename isbn}...on Furniture{upc}}}}}}" + } + }, + { + "kind": "Flatten", + "path": ["login", "reviews", "@", "product"], + "node": { + "kind": "Fetch", + "serviceName": "product", + "requires": [ + { + "kind": "InlineFragment", + "typeCondition": "Book", + "selections": [ + { "kind": "Field", "name": "__typename" }, + { "kind": "Field", "name": "isbn" } + ] + } + ], + "variableUsages": [], + "operation": "query($representations:[_Any!]!){_entities(representations:$representations){...on Book{upc}}}" + } + } + ] + } + } + """ + +Scenario: mutations across service boundaries + Given query + """ + mutation Review($upc: String!, $body: String!) { + reviewProduct(upc: $upc, body: $body) { + ... on Furniture { + name + } + } + } + """ + Then query plan + """ + { + "kind": "QueryPlan", + "node": { + "kind": "Sequence", + "nodes": [ + { + "kind": "Fetch", + "serviceName": "reviews", + "variableUsages": ["upc", "body"], + "operation": "mutation($upc:String!$body:String!){reviewProduct(upc:$upc body:$body){__typename ...on Furniture{__typename upc}}}" + }, + { + "kind": "Flatten", + "path": ["reviewProduct"], + "node": { + "kind": "Fetch", + "serviceName": "product", + "requires": [ + { + "kind": "InlineFragment", + "typeCondition": "Furniture", + "selections": [ + { "kind": "Field", "name": "__typename" }, + { "kind": "Field", "name": "upc" } + ] + } + ], + "variableUsages": [], + "operation": "query($representations:[_Any!]!){_entities(representations:$representations){...on Furniture{name}}}" + } + } + ] + } + } + + """ + +Scenario: multiple root mutations + Given query + """ + mutation LoginAndReview( + $username: String! + $password: String! + $upc: String! + $body: String! + ) { + login(username: $username, password: $password) { + reviews { + product { + upc + } + } + } + reviewProduct(upc: $upc, body: $body) { + ... on Furniture { + name + } + } + } + """ + Then query plan + """ + { + "kind": "QueryPlan", + "node": { + "kind": "Sequence", + "nodes": [ + { + "kind": "Fetch", + "serviceName": "accounts", + "variableUsages": ["username", "password"], + "operation": "mutation($username:String!$password:String!){login(username:$username password:$password){__typename id}}" + }, + { + "kind": "Flatten", + "path": ["login"], + "node": { + "kind": "Fetch", + "serviceName": "reviews", + "requires": [ + { + "kind": "InlineFragment", + "typeCondition": "User", + "selections": [ + { "kind": "Field", "name": "__typename" }, + { "kind": "Field", "name": "id" } + ] + } + ], + "variableUsages": [], + "operation": "query($representations:[_Any!]!){_entities(representations:$representations){...on User{reviews{product{__typename ...on Book{__typename isbn}...on Furniture{upc}}}}}}" + } + }, + { + "kind": "Flatten", + "path": ["login", "reviews", "@", "product"], + "node": { + "kind": "Fetch", + "serviceName": "product", + "requires": [ + { + "kind": "InlineFragment", + "typeCondition": "Book", + "selections": [ + { "kind": "Field", "name": "__typename" }, + { "kind": "Field", "name": "isbn" } + ] + } + ], + "variableUsages": [], + "operation": "query($representations:[_Any!]!){_entities(representations:$representations){...on Book{upc}}}" + } + }, + { + "kind": "Fetch", + "serviceName": "reviews", + "variableUsages": ["upc", "body"], + "operation": "mutation($upc:String!$body:String!){reviewProduct(upc:$upc body:$body){__typename ...on Furniture{__typename upc}}}" + }, + { + "kind": "Flatten", + "path": ["reviewProduct"], + "node": { + "kind": "Fetch", + "serviceName": "product", + "requires": [ + { + "kind": "InlineFragment", + "typeCondition": "Furniture", + "selections": [ + { "kind": "Field", "name": "__typename" }, + { "kind": "Field", "name": "upc" } + ] + } + ], + "variableUsages": [], + "operation": "query($representations:[_Any!]!){_entities(representations:$representations){...on Furniture{name}}}" + } + } + ] + } + } + """ + +# important: order: Review > Update > Login > Delete +Scenario: multiple root mutations with correct service order + Given query + """ + mutation LoginAndReview( + $upc: String! + $body: String! + $updatedReview: UpdateReviewInput! + $username: String! + $password: String! + $reviewId: ID! + ) { + reviewProduct(upc: $upc, body: $body) { + ... on Furniture { + upc + } + } + updateReview(review: $updatedReview) { + id + body + } + login(username: $username, password: $password) { + reviews { + product { + upc + } + } + } + deleteReview(id: $reviewId) + } + """ + Then query plan + """ + { + "kind": "QueryPlan", + "node": { + "kind": "Sequence", + "nodes": [ + { + "kind": "Fetch", + "serviceName": "reviews", + "variableUsages": ["upc", "body", "updatedReview"], + "operation": "mutation($upc:String!$body:String!$updatedReview:UpdateReviewInput!){reviewProduct(upc:$upc body:$body){__typename ...on Furniture{upc}}updateReview(review:$updatedReview){id body}}" + }, + { + "kind": "Fetch", + "serviceName": "accounts", + "variableUsages": ["username", "password"], + "operation": "mutation($username:String!$password:String!){login(username:$username password:$password){__typename id}}" + }, + { + "kind": "Flatten", + "path": ["login"], + "node": { + "kind": "Fetch", + "serviceName": "reviews", + "requires": [ + { + "kind": "InlineFragment", + "typeCondition": "User", + "selections": [ + { "kind": "Field", "name": "__typename" }, + { "kind": "Field", "name": "id" } + ] + } + ], + "variableUsages": [], + "operation": "query($representations:[_Any!]!){_entities(representations:$representations){...on User{reviews{product{__typename ...on Book{__typename isbn}...on Furniture{upc}}}}}}" + } + }, + { + "kind": "Flatten", + "path": ["login", "reviews", "@", "product"], + "node": { + "kind": "Fetch", + "serviceName": "product", + "requires": [ + { + "kind": "InlineFragment", + "typeCondition": "Book", + "selections": [ + { "kind": "Field", "name": "__typename" }, + { "kind": "Field", "name": "isbn" } + ] + } + ], + "variableUsages": [], + "operation": "query($representations:[_Any!]!){_entities(representations:$representations){...on Book{upc}}}" + } + }, + { + "kind": "Fetch", + "serviceName": "reviews", + "variableUsages": ["reviewId"], + "operation": "mutation($reviewId:ID!){deleteReview(id:$reviewId)}" + } + ] + } + } + """ + diff --git a/query-planner-js/src/__tests__/features/basic/provides.feature b/query-planner-js/src/__tests__/features/basic/provides.feature new file mode 100644 index 000000000..f6d4fb54c --- /dev/null +++ b/query-planner-js/src/__tests__/features/basic/provides.feature @@ -0,0 +1,76 @@ +Feature: Query Planner > Provides + +Scenario: does not have to go to another service when field is given + Given query + """ + query GetReviewers { + topReviews { + author { + username + } + } + } + """ + Then query plan + """ + { + "kind": "QueryPlan", + "node": { + "kind": "Fetch", + "serviceName": "reviews", + "variableUsages": [], + "operation": "{topReviews{author{username}}}" + } + } + """ + +# make sure the accounts service doesn't have User.username in its query +Scenario: does not load fields provided even when going to other service + Given query + """ + query GetReviewers { + topReviews { + author { + username + name + } + } + } + """ + Then query plan + """ + { + "kind": "QueryPlan", + "node": { + "kind": "Sequence", + "nodes": [ + { + "kind": "Fetch", + "serviceName": "reviews", + "variableUsages": [], + "operation": "{topReviews{author{username __typename id}}}" + }, + { + "kind": "Flatten", + "path": ["topReviews", "@", "author"], + "node": { + "kind": "Fetch", + "serviceName": "accounts", + "requires": [ + { + "kind": "InlineFragment", + "typeCondition": "User", + "selections": [ + { "kind": "Field", "name": "__typename" }, + { "kind": "Field", "name": "id" } + ] + } + ], + "variableUsages": [], + "operation": "query($representations:[_Any!]!){_entities(representations:$representations){...on User{name}}}" + } + } + ] + } + } + """ diff --git a/query-planner-js/src/__tests__/features/basic/requires.feature b/query-planner-js/src/__tests__/features/basic/requires.feature new file mode 100644 index 000000000..73dc1a233 --- /dev/null +++ b/query-planner-js/src/__tests__/features/basic/requires.feature @@ -0,0 +1,98 @@ +Feature: Query Planning > requires + +# requires { isbn, title, year } from books service +Scenario: supports passing additional fields defined by a requires + Given query + """ + query GetReviwedBookNames { + me { + reviews { + product { + ... on Book { + name + } + } + } + } + } + """ + Then query plan + """ + { + "kind": "QueryPlan", + "node": { + "kind": "Sequence", + "nodes": [ + { + "kind": "Fetch", + "serviceName": "accounts", + "variableUsages": [], + "operation": "{me{__typename id}}" + }, + { + "kind": "Flatten", + "path": ["me"], + "node": { + "kind": "Fetch", + "serviceName": "reviews", + "requires": [ + { + "kind": "InlineFragment", + "typeCondition": "User", + "selections": [ + { "kind": "Field", "name": "__typename" }, + { "kind": "Field", "name": "id" } + ] + } + ], + "variableUsages": [], + "operation": "query($representations:[_Any!]!){_entities(representations:$representations){...on User{reviews{product{__typename ...on Book{__typename isbn}}}}}}" + } + }, + { + "kind": "Flatten", + "path": ["me", "reviews", "@", "product"], + "node": { + "kind": "Fetch", + "serviceName": "books", + "requires": [ + { + "kind": "InlineFragment", + "typeCondition": "Book", + "selections": [ + { "kind": "Field", "name": "__typename" }, + { "kind": "Field", "name": "isbn" } + ] + } + ], + "variableUsages": [], + "operation": "query($representations:[_Any!]!){_entities(representations:$representations){...on Book{__typename isbn title year}}}" + } + }, + { + "kind": "Flatten", + "path": ["me", "reviews", "@", "product"], + "node": { + "kind": "Fetch", + "serviceName": "product", + "requires": [ + { + "kind": "InlineFragment", + "typeCondition": "Book", + "selections": [ + { "kind": "Field", "name": "__typename" }, + { "kind": "Field", "name": "isbn" }, + { "kind": "Field", "name": "title" }, + { "kind": "Field", "name": "year" } + ] + } + ], + "variableUsages": [], + "operation": "query($representations:[_Any!]!){_entities(representations:$representations){...on Book{name}}}" + } + } + ] + } + } + + """ diff --git a/query-planner-js/src/__tests__/features/basic/single-service.feature b/query-planner-js/src/__tests__/features/basic/single-service.feature new file mode 100644 index 000000000..59cd7d9f3 --- /dev/null +++ b/query-planner-js/src/__tests__/features/basic/single-service.feature @@ -0,0 +1,89 @@ +Feature: Query Planning > Single Service + +# I don't think we need to move this test -- looks way too simple, maybe an +# early-written test? +# Scenario: executes a query plan over concrete types + +# this test looks a bit deceiving -- this is the correct query plan, but when +# executed, __typename should be returned +Scenario: does not remove __typename on root types + Given query + """ + query GetUser { + __typename + } + """ + Then query plan + """ + {"kind":"QueryPlan"} + """ + +Scenario: does not remove __typename if that is all that is requested on an entity + Given query + """ + query GetUser { + me { + __typename + } + } + """ + Then query plan + """ + { + "kind": "QueryPlan", + "node": { + "kind": "Fetch", + "serviceName": "accounts", + "variableUsages": [], + "operation": "{me{__typename}}" + } + } + """ + +Scenario: does not remove __typename if that is all that is requested on a value type + Given query + """ + query GetUser { + me { + account { + __typename + } + } + } + """ + Then query plan + """ + { + "kind": "QueryPlan", + "node": { + "kind": "Fetch", + "serviceName": "accounts", + "variableUsages": [], + "operation": "{me{account{__typename}}}" + } + } + """ + +Scenario: does not remove __typename if that is all that is requested on a union type + Given query + """ + query GetUser { + me { + accountType { + __typename + } + } + } + """ + Then query plan + """ + { + "kind": "QueryPlan", + "node": { + "kind": "Fetch", + "serviceName": "accounts", + "variableUsages": [], + "operation": "{me{accountType{__typename}}}" + } + } + """ diff --git a/query-planner-js/src/__tests__/features/basic/supergraphSdl.graphql b/query-planner-js/src/__tests__/features/basic/supergraphSdl.graphql new file mode 100644 index 000000000..9303f38d6 --- /dev/null +++ b/query-planner-js/src/__tests__/features/basic/supergraphSdl.graphql @@ -0,0 +1,274 @@ +schema + @core(feature: "https://specs.apollo.dev/core/v0.1"), + @core(feature: "https://specs.apollo.dev/join/v0.1") +{ + query: Query + mutation: Mutation +} + +directive @core(feature: String!) repeatable on SCHEMA + +directive @join__field(graph: join__Graph, requires: join__FieldSet, provides: join__FieldSet) on FIELD_DEFINITION + +directive @join__type(graph: join__Graph!, key: join__FieldSet) repeatable on OBJECT | INTERFACE + +directive @join__owner(graph: join__Graph!) on OBJECT | INTERFACE + +directive @join__graph(name: String!, url: String!) on ENUM_VALUE + +directive @stream on FIELD + +directive @transform(from: String!) on FIELD + +type Account { + type: String +} + +union AccountType = PasswordAccount | SMSAccount + +type Amazon { + referrer: String +} + +union Body = Image | Text + +type Book implements Product + @join__owner(graph: BOOKS) + @join__type(graph: BOOKS, key: "isbn") + @join__type(graph: INVENTORY, key: "isbn") + @join__type(graph: PRODUCT, key: "isbn") + @join__type(graph: REVIEWS, key: "isbn") +{ + isbn: String! @join__field(graph: BOOKS) + title: String @join__field(graph: BOOKS) + year: Int @join__field(graph: BOOKS) + similarBooks: [Book]! @join__field(graph: BOOKS) + metadata: [MetadataOrError] @join__field(graph: BOOKS) + inStock: Boolean @join__field(graph: INVENTORY) + isCheckedOut: Boolean @join__field(graph: INVENTORY) + upc: String! @join__field(graph: PRODUCT) + sku: String! @join__field(graph: PRODUCT) + name(delimeter: String = " "): String @join__field(graph: PRODUCT, requires: "title year") + price: String @join__field(graph: PRODUCT) + details: ProductDetailsBook @join__field(graph: PRODUCT) + reviews: [Review] @join__field(graph: REVIEWS) + relatedReviews: [Review!]! @join__field(graph: REVIEWS, requires: "similarBooks { isbn }") +} + +union Brand = Ikea | Amazon + +type Car implements Vehicle + @join__owner(graph: PRODUCT) + @join__type(graph: PRODUCT, key: "id") + @join__type(graph: REVIEWS, key: "id") +{ + id: String! @join__field(graph: PRODUCT) + description: String @join__field(graph: PRODUCT) + price: String @join__field(graph: PRODUCT) + retailPrice: String @join__field(graph: REVIEWS, requires: "price") + thing: Thing +} + +type Error { + code: Int + message: String +} + +type Furniture implements Product + @join__owner(graph: PRODUCT) + @join__type(graph: PRODUCT, key: "upc") + @join__type(graph: PRODUCT, key: "sku") + @join__type(graph: INVENTORY, key: "sku") + @join__type(graph: REVIEWS, key: "upc") +{ + upc: String! @join__field(graph: PRODUCT) + sku: String! @join__field(graph: PRODUCT) + name: String @join__field(graph: PRODUCT) + price: String @join__field(graph: PRODUCT) + brand: Brand @join__field(graph: PRODUCT) + metadata: [MetadataOrError] @join__field(graph: PRODUCT) + details: ProductDetailsFurniture @join__field(graph: PRODUCT) + inStock: Boolean @join__field(graph: INVENTORY) + isHeavy: Boolean @join__field(graph: INVENTORY) + reviews: [Review] @join__field(graph: REVIEWS) +} + +type Ikea { + asile: Int +} + +type Image { + name: String! + attributes: ImageAttributes! +} + +type ImageAttributes { + url: String! +} + +scalar join__FieldSet + +enum join__Graph { + ACCOUNTS @join__graph(name: "accounts" url: "undefined") + BOOKS @join__graph(name: "books" url: "undefined") + DOCUMENTS @join__graph(name: "documents" url: "undefined") + INVENTORY @join__graph(name: "inventory" url: "undefined") + PRODUCT @join__graph(name: "product" url: "undefined") + REVIEWS @join__graph(name: "reviews" url: "undefined") +} + +type KeyValue { + key: String! + value: String! +} + +type Library + @join__owner(graph: BOOKS) + @join__type(graph: BOOKS, key: "id") + @join__type(graph: ACCOUNTS, key: "id") +{ + id: ID! @join__field(graph: BOOKS) + name: String @join__field(graph: BOOKS) + userAccount(id: ID! = 1): User @join__field(graph: ACCOUNTS, requires: "name") +} + +union MetadataOrError = KeyValue | Error + +type Mutation { + login(username: String!, password: String!): User @join__field(graph: ACCOUNTS) + reviewProduct(upc: String!, body: String!): Product @join__field(graph: REVIEWS) + updateReview(review: UpdateReviewInput!): Review @join__field(graph: REVIEWS) + deleteReview(id: ID!): Boolean @join__field(graph: REVIEWS) +} + +type Name { + first: String + last: String +} + +type PasswordAccount + @join__owner(graph: ACCOUNTS) + @join__type(graph: ACCOUNTS, key: "email") +{ + email: String! @join__field(graph: ACCOUNTS) +} + +interface Product { + upc: String! + sku: String! + name: String + price: String + details: ProductDetails + inStock: Boolean + reviews: [Review] +} + +interface ProductDetails { + country: String +} + +type ProductDetailsBook implements ProductDetails { + country: String + pages: Int +} + +type ProductDetailsFurniture implements ProductDetails { + country: String + color: String +} + +type Query { + user(id: ID!): User @join__field(graph: ACCOUNTS) + me: User @join__field(graph: ACCOUNTS) + book(isbn: String!): Book @join__field(graph: BOOKS) + books: [Book] @join__field(graph: BOOKS) + library(id: ID!): Library @join__field(graph: BOOKS) + body: Body! @join__field(graph: DOCUMENTS) + product(upc: String!): Product @join__field(graph: PRODUCT) + vehicle(id: String!): Vehicle @join__field(graph: PRODUCT) + topProducts(first: Int = 5): [Product] @join__field(graph: PRODUCT) + topCars(first: Int = 5): [Car] @join__field(graph: PRODUCT) + topReviews(first: Int = 5): [Review] @join__field(graph: REVIEWS) +} + +type Review + @join__owner(graph: REVIEWS) + @join__type(graph: REVIEWS, key: "id") +{ + id: ID! @join__field(graph: REVIEWS) + body(format: Boolean = false): String @join__field(graph: REVIEWS) + author: User @join__field(graph: REVIEWS, provides: "username") + product: Product @join__field(graph: REVIEWS) + metadata: [MetadataOrError] @join__field(graph: REVIEWS) +} + +type SMSAccount + @join__owner(graph: ACCOUNTS) + @join__type(graph: ACCOUNTS, key: "number") +{ + number: String @join__field(graph: ACCOUNTS) +} + +type Text { + name: String! + attributes: TextAttributes! +} + +type TextAttributes { + bold: Boolean + text: String +} + +union Thing = Car | Ikea + +input UpdateReviewInput { + id: ID! + body: String +} + +type User + @join__owner(graph: ACCOUNTS) + @join__type(graph: ACCOUNTS, key: "id") + @join__type(graph: ACCOUNTS, key: "username name { first last }") + @join__type(graph: INVENTORY, key: "id") + @join__type(graph: PRODUCT, key: "id") + @join__type(graph: REVIEWS, key: "id") +{ + id: ID! @join__field(graph: ACCOUNTS) + name: Name @join__field(graph: ACCOUNTS) + username: String @join__field(graph: ACCOUNTS) + birthDate(locale: String): String @join__field(graph: ACCOUNTS) + account: Account @join__field(graph: ACCOUNTS) + accountType: AccountType @join__field(graph: ACCOUNTS) + metadata: [UserMetadata] @join__field(graph: ACCOUNTS) + goodDescription: Boolean @join__field(graph: INVENTORY, requires: "metadata { description }") + vehicle: Vehicle @join__field(graph: PRODUCT) + thing: Thing @join__field(graph: PRODUCT) + reviews: [Review] @join__field(graph: REVIEWS) + numberOfReviews: Int! @join__field(graph: REVIEWS) + goodAddress: Boolean @join__field(graph: REVIEWS, requires: "metadata { address }") +} + +type UserMetadata { + name: String + address: String + description: String +} + +type Van implements Vehicle + @join__owner(graph: PRODUCT) + @join__type(graph: PRODUCT, key: "id") + @join__type(graph: REVIEWS, key: "id") +{ + id: String! @join__field(graph: PRODUCT) + description: String @join__field(graph: PRODUCT) + price: String @join__field(graph: PRODUCT) + retailPrice: String @join__field(graph: REVIEWS, requires: "price") +} + +interface Vehicle { + id: String! + description: String + price: String + retailPrice: String +} \ No newline at end of file diff --git a/query-planner-js/src/__tests__/features/basic/value-types.feature b/query-planner-js/src/__tests__/features/basic/value-types.feature new file mode 100644 index 000000000..f6dbf0326 --- /dev/null +++ b/query-planner-js/src/__tests__/features/basic/value-types.feature @@ -0,0 +1,108 @@ +Feature: Query Planner > Value Types + +Scenario: resolves value types within their respective services + Given query + """ + fragment Metadata on MetadataOrError { + ... on KeyValue { + key + value + } + ... on Error { + code + message + } + } + + query ProducsWithMetadata { + topProducts(first: 10) { + upc + ... on Book { + metadata { + ...Metadata + } + } + ... on Furniture { + metadata { + ...Metadata + } + } + reviews { + metadata { + ...Metadata + } + } + } + } + """ + Then query plan + """ + { + "kind": "QueryPlan", + "node": { + "kind": "Sequence", + "nodes": [ + { + "kind": "Fetch", + "serviceName": "product", + "variableUsages": [], + "operation": "{topProducts(first:10){__typename ...on Book{upc __typename isbn}...on Furniture{upc metadata{__typename ...on KeyValue{key value}...on Error{code message}}__typename}}}" + }, + { + "kind": "Parallel", + "nodes": [ + { + "kind": "Flatten", + "path": ["topProducts", "@"], + "node": { + "kind": "Fetch", + "serviceName": "books", + "requires": [ + { + "kind": "InlineFragment", + "typeCondition": "Book", + "selections": [ + { "kind": "Field", "name": "__typename" }, + { "kind": "Field", "name": "isbn" } + ] + } + ], + "variableUsages": [], + "operation": "query($representations:[_Any!]!){_entities(representations:$representations){...on Book{metadata{__typename ...on KeyValue{key value}...on Error{code message}}}}}" + } + }, + { + "kind": "Flatten", + "path": ["topProducts", "@"], + "node": { + "kind": "Fetch", + "serviceName": "reviews", + "requires": [ + { + "kind": "InlineFragment", + "typeCondition": "Book", + "selections": [ + { "kind": "Field", "name": "__typename" }, + { "kind": "Field", "name": "isbn" } + ] + }, + { + "kind": "InlineFragment", + "typeCondition": "Furniture", + "selections": [ + { "kind": "Field", "name": "__typename" }, + { "kind": "Field", "name": "upc" } + ] + } + ], + "variableUsages": [], + "operation": "query($representations:[_Any!]!){_entities(representations:$representations){...on Book{reviews{metadata{__typename ...on KeyValue{key value}...on Error{code message}}}}...on Furniture{reviews{metadata{__typename ...on KeyValue{key value}...on Error{code message}}}}}}" + } + } + ] + } + ] + } + } + """ + diff --git a/query-planner-js/src/__tests__/features/basic/variables.feature b/query-planner-js/src/__tests__/features/basic/variables.feature new file mode 100644 index 000000000..a1b12900a --- /dev/null +++ b/query-planner-js/src/__tests__/features/basic/variables.feature @@ -0,0 +1,263 @@ +Feature: Query Planning > Variables + +# calls product with variable +Scenario: passes variables to root fields + Given query + """ + query GetProduct($upc: String!) { + product(upc: $upc) { + name + } + } + """ + Then query plan + """ + { + "kind": "QueryPlan", + "node": { + "kind": "Sequence", + "nodes": [ + { + "kind": "Fetch", + "serviceName": "product", + "variableUsages": ["upc"], + "operation": "query($upc:String!){product(upc:$upc){__typename ...on Book{__typename isbn}...on Furniture{name}}}" + }, + { + "kind": "Flatten", + "path": ["product"], + "node": { + "kind": "Fetch", + "serviceName": "books", + "requires": [ + { + "kind": "InlineFragment", + "typeCondition": "Book", + "selections": [ + { "kind": "Field", "name": "__typename" }, + { "kind": "Field", "name": "isbn" } + ] + } + ], + "variableUsages": [], + "operation": "query($representations:[_Any!]!){_entities(representations:$representations){...on Book{__typename isbn title year}}}" + } + }, + { + "kind": "Flatten", + "path": ["product"], + "node": { + "kind": "Fetch", + "serviceName": "product", + "requires": [ + { + "kind": "InlineFragment", + "typeCondition": "Book", + "selections": [ + { "kind": "Field", "name": "__typename" }, + { "kind": "Field", "name": "isbn" }, + { "kind": "Field", "name": "title" }, + { "kind": "Field", "name": "year" } + ] + } + ], + "variableUsages": [], + "operation": "query($representations:[_Any!]!){_entities(representations:$representations){...on Book{name}}}" + } + } + ] + } + } + """ + +# calls product with default variable +Scenario: supports default variables in a variable definition + Given query + """ + query GetProduct($upc: String = "1") { + product(upc: $upc) { + name + } + } + """ + Then query plan + """ + { + "kind": "QueryPlan", + "node": { + "kind": "Sequence", + "nodes": [ + { + "kind": "Fetch", + "serviceName": "product", + "variableUsages": ["upc"], + "operation": "query($upc:String=\"1\"){product(upc:$upc){__typename ...on Book{__typename isbn}...on Furniture{name}}}" + }, + { + "kind": "Flatten", + "path": ["product"], + "node": { + "kind": "Fetch", + "serviceName": "books", + "requires": [ + { + "kind": "InlineFragment", + "typeCondition": "Book", + "selections": [ + { "kind": "Field", "name": "__typename" }, + { "kind": "Field", "name": "isbn" } + ] + } + ], + "variableUsages": [], + "operation": "query($representations:[_Any!]!){_entities(representations:$representations){...on Book{__typename isbn title year}}}" + } + }, + { + "kind": "Flatten", + "path": ["product"], + "node": { + "kind": "Fetch", + "serviceName": "product", + "requires": [ + { + "kind": "InlineFragment", + "typeCondition": "Book", + "selections": [ + { "kind": "Field", "name": "__typename" }, + { "kind": "Field", "name": "isbn" }, + { "kind": "Field", "name": "title" }, + { "kind": "Field", "name": "year" } + ] + } + ], + "variableUsages": [], + "operation": "query($representations:[_Any!]!){_entities(representations:$representations){...on Book{name}}}" + } + } + ] + } + } + """ + +# calls reviews service with variable; calls accounts +Scenario: passes variables to nested services + Given query + """ + query GetProductsForUser($format: Boolean) { + me { + reviews { + body(format: $format) + } + } + } + """ + Then query plan + """ + { + "kind": "QueryPlan", + "node": { + "kind": "Sequence", + "nodes": [ + { + "kind": "Fetch", + "serviceName": "accounts", + "variableUsages": [], + "operation": "{me{__typename id}}" + }, + { + "kind": "Flatten", + "path": ["me"], + "node": { + "kind": "Fetch", + "serviceName": "reviews", + "requires": [ + { + "kind": "InlineFragment", + "typeCondition": "User", + "selections": [ + { "kind": "Field", "name": "__typename" }, + { "kind": "Field", "name": "id" } + ] + } + ], + "variableUsages": ["format"], + "operation": "query($representations:[_Any!]!$format:Boolean){_entities(representations:$representations){...on User{reviews{body(format:$format)}}}}" + } + } + ] + } + } + """ + +# XXX I think this test relies on execution to use the default variable, not the query plan +Scenario: works with default variables in the schema + Given query + """ + query LibraryUser($libraryId: ID!, $userId: ID) { + library(id: $libraryId) { + userAccount(id: $userId) { + id + name + } + } + } + """ + Then query plan + """ + { + "kind": "QueryPlan", + "node": { + "kind": "Sequence", + "nodes": [ + { + "kind": "Fetch", + "serviceName": "books", + "variableUsages": ["libraryId"], + "operation": "query($libraryId:ID!){library(id:$libraryId){__typename id name}}" + }, + { + "kind": "Flatten", + "path": ["library"], + "node": { + "kind": "Fetch", + "serviceName": "accounts", + "requires": [ + { + "kind": "InlineFragment", + "typeCondition": "Library", + "selections": [ + { "kind": "Field", "name": "__typename" }, + { "kind": "Field", "name": "id" }, + { "kind": "Field", "name": "name" } + ] + } + ], + "variableUsages": ["userId"], + "operation": "query($representations:[_Any!]!$userId:ID){_entities(representations:$representations){...on Library{userAccount(id:$userId){id name}}}}" + } + } + ] + } + } + """ + +Scenario: String arguments with quotes that need to be escaped. + Given query + """ + query { + vehicle(id: "{\"make\":\"Toyota\",\"model\":\"Rav4\",\"trim\":\"Limited\"}") + } + """ + Then query plan + """ + { + "kind": "QueryPlan", + "node": { + "kind": "Fetch", + "serviceName": "product", + "variableUsages": [], + "operation": "{vehicle(id:\"{\\\"make\\\":\\\"Toyota\\\",\\\"model\\\":\\\"Rav4\\\",\\\"trim\\\":\\\"Limited\\\"}\"){__typename}}" + } + } + """ diff --git a/query-planner-js/src/__tests__/features/multiple-keys/multiple-keys.feature b/query-planner-js/src/__tests__/features/multiple-keys/multiple-keys.feature new file mode 100644 index 000000000..58acfcd02 --- /dev/null +++ b/query-planner-js/src/__tests__/features/multiple-keys/multiple-keys.feature @@ -0,0 +1,72 @@ +Feature: Query Planning > Multiple keys + + Scenario: Multiple @key fields + Given query + """ + query { + reviews { + body + author { + name + risk + } + } + } + """ + Then query plan + """ + { + "kind": "QueryPlan", + "node": { + "kind": "Sequence", + "nodes": [ + { + "kind": "Fetch", + "serviceName": "reviews", + "variableUsages": [], + "operation": "{reviews{body author{__typename id}}}" + }, + { + "kind": "Flatten", + "path": ["reviews", "@", "author"], + "node": { + "kind": "Fetch", + "serviceName": "users", + "requires": [ + { + "kind": "InlineFragment", + "typeCondition": "User", + "selections": [ + { "kind": "Field", "name": "__typename" }, + { "kind": "Field", "name": "id" } + ] + } + ], + "variableUsages": [], + "operation": "query($representations:[_Any!]!){_entities(representations:$representations){...on User{name __typename ssn}}}" + } + }, + { + "kind": "Flatten", + "path": ["reviews", "@", "author"], + "node": { + "kind": "Fetch", + "serviceName": "actuary", + "requires": [ + { + "kind": "InlineFragment", + "typeCondition": "User", + "selections": [ + { "kind": "Field", "name": "__typename" }, + { "kind": "Field", "name": "ssn" } + ] + } + ], + "variableUsages": [], + "operation": "query($representations:[_Any!]!){_entities(representations:$representations){...on User{risk}}}" + } + } + ] + } + } + """ diff --git a/query-planner-js/src/__tests__/features/multiple-keys/supergraphSdl.graphql b/query-planner-js/src/__tests__/features/multiple-keys/supergraphSdl.graphql new file mode 100644 index 000000000..785c190d9 --- /dev/null +++ b/query-planner-js/src/__tests__/features/multiple-keys/supergraphSdl.graphql @@ -0,0 +1,56 @@ +schema + @core(feature: "https://specs.apollo.dev/core/v0.1"), + @core(feature: "https://specs.apollo.dev/join/v0.1") +{ + query: Query +} + +directive @core(feature: String!) repeatable on SCHEMA + +directive @join__field(graph: join__Graph, requires: join__FieldSet, provides: join__FieldSet) on FIELD_DEFINITION + +directive @join__type(graph: join__Graph!, key: join__FieldSet) repeatable on OBJECT | INTERFACE + +directive @join__owner(graph: join__Graph!) on OBJECT | INTERFACE + +directive @join__graph(name: String!, url: String!) on ENUM_VALUE + +type Group { + id: ID + name: String +} + +scalar join__FieldSet + +enum join__Graph { + USERS @join__graph(name: "users" url: "undefined") + REVIEWS @join__graph(name: "reviews" url: "undefined") + ACTUARY @join__graph(name: "actuary" url: "undefined") +} + +type Query { + users: [User!]! @join__field(graph: USERS) + reviews: [Review!]! @join__field(graph: REVIEWS) +} + +type Review { + id: ID! + author: User! + body: String! +} + +type User + @join__owner(graph: USERS) + @join__type(graph: USERS, key: "ssn") + @join__type(graph: USERS, key: "id") + @join__type(graph: USERS, key: "group { id }") + @join__type(graph: REVIEWS, key: "id") + @join__type(graph: ACTUARY, key: "ssn") +{ + id: ID! @join__field(graph: USERS) + ssn: ID! @join__field(graph: USERS) + name: String! @join__field(graph: USERS) + group: Group @join__field(graph: USERS) + reviews: [Review!]! @join__field(graph: REVIEWS) + risk: Float @join__field(graph: ACTUARY) +} \ No newline at end of file diff --git a/query-planner-js/src/__tests__/tsconfig.json b/query-planner-js/src/__tests__/tsconfig.json new file mode 100644 index 000000000..5f062f92b --- /dev/null +++ b/query-planner-js/src/__tests__/tsconfig.json @@ -0,0 +1,8 @@ +{ + "extends": "../../tsconfig.test", + "include": ["**/*"], + "references": [ + { "path": "../../" }, + { "path": "../../../federation-integration-testsuite-js" }, + ] +} diff --git a/query-planner-js/src/buildQueryPlan.ts b/query-planner-js/src/buildQueryPlan.ts new file mode 100644 index 000000000..dab39b2fb --- /dev/null +++ b/query-planner-js/src/buildQueryPlan.ts @@ -0,0 +1,1189 @@ +import { isNotNullOrUndefined } from './utilities/predicates'; +import { + DocumentNode, + FieldNode, + FragmentDefinitionNode, + getNamedType, + getOperationRootType, + GraphQLAbstractType, + GraphQLCompositeType, + GraphQLError, + GraphQLField, + GraphQLObjectType, + GraphQLSchema, + GraphQLType, + InlineFragmentNode, + isAbstractType, + isCompositeType, + isIntrospectionType, + isListType, + isNamedType, + isObjectType, + Kind, + OperationDefinitionNode, + SelectionSetNode, + typeFromAST, + TypeNameMetaFieldDef, + visit, + VariableDefinitionNode, + OperationTypeNode, + print, + stripIgnoredCharacters, +} from 'graphql'; +import { + Field, + FieldSet, + groupByParentType, + groupByResponseName, + matchesField, + selectionSetFromFieldSet, + Scope, +} from './FieldSet'; +import { + FetchNode, + ParallelNode, + PlanNode, + SequenceNode, + QueryPlan, + ResponsePath, + OperationContext, + trimSelectionNodes, + FragmentMap, +} from './QueryPlan'; +import { getFieldDef, getResponseName } from './utilities/graphql'; +import { MultiMap } from './utilities/MultiMap'; +import { getFederationMetadataForType, getFederationMetadataForField } from './composedSchema'; + +const typenameField = { + kind: Kind.FIELD, + name: { + kind: Kind.NAME, + value: TypeNameMetaFieldDef.name, + }, +}; + +export interface BuildQueryPlanOptions { + autoFragmentization: boolean; +} + +export function buildQueryPlan( + operationContext: OperationContext, + options: BuildQueryPlanOptions = { autoFragmentization: false }, +): QueryPlan { + const context = buildQueryPlanningContext(operationContext, options); + + if (context.operation.operation === 'subscription') { + throw new GraphQLError( + 'Query planning does not support subscriptions for now.', + [context.operation], + ); + } + + const rootType = getOperationRootType(context.schema, context.operation); + + const isMutation = context.operation.operation === 'mutation'; + + const fields = collectFields( + context, + context.newScope(rootType), + context.operation.selectionSet, + ); + + // Mutations are a bit more specific in how FetchGroups can be built, as some + // calls to the same service may need to be executed serially. + const groups = isMutation + ? splitRootFieldsSerially(context, fields) + : splitRootFields(context, fields); + + const nodes = groups.map(group => + executionNodeForGroup(context, group, rootType), + ); + + return { + kind: 'QueryPlan', + node: nodes.length + // if an operation is a mutation, we run the root fields in sequence, + // otherwise we run them in parallel + ? flatWrap(isMutation ? 'Sequence' : 'Parallel', nodes) + : undefined, + }; +} + +function executionNodeForGroup( + context: QueryPlanningContext, + { + serviceName, + fields, + requiredFields, + internalFragments, + mergeAt, + dependentGroups, + }: FetchGroup, + parentType?: GraphQLCompositeType, +): PlanNode { + const selectionSet = selectionSetFromFieldSet(fields, parentType); + const requires = + requiredFields.length > 0 + ? selectionSetFromFieldSet(requiredFields) + : undefined; + const variableUsages = context.getVariableUsages( + selectionSet, + internalFragments, + ); + + const operation = requires + ? operationForEntitiesFetch({ + selectionSet, + variableUsages, + internalFragments, + }) + : operationForRootFetch({ + selectionSet, + variableUsages, + internalFragments, + operation: context.operation.operation, + }); + + const fetchNode: FetchNode = { + kind: 'Fetch', + serviceName, + requires: requires ? trimSelectionNodes(requires?.selections) : undefined, + variableUsages: Object.keys(variableUsages), + operation: stripIgnoredCharacters(print(operation)), + }; + + const node: PlanNode = + mergeAt && mergeAt.length > 0 + ? { + kind: 'Flatten', + path: mergeAt, + node: fetchNode, + } + : fetchNode; + + if (dependentGroups.length > 0) { + const dependentNodes = dependentGroups.map(dependentGroup => + executionNodeForGroup(context, dependentGroup), + ); + + return flatWrap('Sequence', [node, flatWrap('Parallel', dependentNodes)]); + } else { + return node; + } +} + +interface VariableUsages { + [name: string]: VariableDefinitionNode +} + +function mapFetchNodeToVariableDefinitions( + variableUsages: VariableUsages, +): VariableDefinitionNode[] { + return variableUsages ? Object.values(variableUsages) : []; +} + +function operationForRootFetch({ + selectionSet, + variableUsages, + internalFragments, + operation = 'query', +}: { + selectionSet: SelectionSetNode; + variableUsages: VariableUsages; + internalFragments: Set; + operation?: OperationTypeNode; +}): DocumentNode { + return { + kind: Kind.DOCUMENT, + definitions: [ + { + kind: Kind.OPERATION_DEFINITION, + operation, + selectionSet, + variableDefinitions: mapFetchNodeToVariableDefinitions(variableUsages), + }, + ...internalFragments, + ], + }; +} + +function operationForEntitiesFetch({ + selectionSet, + variableUsages, + internalFragments, +}: { + selectionSet: SelectionSetNode; + variableUsages: VariableUsages; + internalFragments: Set; +}): DocumentNode { + const representationsVariable = { + kind: Kind.VARIABLE, + name: { kind: Kind.NAME, value: 'representations' }, + }; + + return { + kind: Kind.DOCUMENT, + definitions: [ + { + kind: Kind.OPERATION_DEFINITION, + operation: 'query', + variableDefinitions: ([ + { + kind: Kind.VARIABLE_DEFINITION, + variable: representationsVariable, + type: { + kind: Kind.NON_NULL_TYPE, + type: { + kind: Kind.LIST_TYPE, + type: { + kind: Kind.NON_NULL_TYPE, + type: { + kind: Kind.NAMED_TYPE, + name: { kind: Kind.NAME, value: '_Any' }, + }, + }, + }, + }, + }, + ] as VariableDefinitionNode[]).concat( + mapFetchNodeToVariableDefinitions(variableUsages), + ), + selectionSet: { + kind: Kind.SELECTION_SET, + selections: [ + { + kind: Kind.FIELD, + name: { kind: Kind.NAME, value: '_entities' }, + arguments: [ + { + kind: Kind.ARGUMENT, + name: { + kind: Kind.NAME, + value: representationsVariable.name.value, + }, + value: representationsVariable, + }, + ], + selectionSet, + }, + ], + }, + }, + ...internalFragments, + ], + }; +} + +// Wraps the given nodes in a ParallelNode or SequenceNode, unless there's only +// one node, in which case it is returned directly. Any nodes of the same kind +// in the given list have their sub-nodes flattened into the list: ie, +// flatWrap('Sequence', [a, flatWrap('Sequence', b, c), d]) returns a SequenceNode +// with four children. +function flatWrap( + kind: ParallelNode['kind'] | SequenceNode['kind'], + nodes: PlanNode[], +): PlanNode { + if (nodes.length === 0) { + throw Error('programming error: should always be called with nodes'); + } + if (nodes.length === 1) { + return nodes[0]; + } + return { + kind, + nodes: nodes.flatMap(n => (n.kind === kind ? n.nodes : [n])), + } as PlanNode; +} + +function splitRootFields( + context: QueryPlanningContext, + fields: FieldSet, +): FetchGroup[] { + const groupsByService: { + [serviceName: string]: FetchGroup; + } = Object.create(null); + + function groupForService(serviceName: string) { + let group = groupsByService[serviceName]; + + if (!group) { + group = new FetchGroup(serviceName); + groupsByService[serviceName] = group; + } + + return group; + } + + splitFields(context, [], fields, field => { + const { scope, fieldNode, fieldDef } = field; + const { parentType } = scope; + + const owningService = context.getOwningService(parentType, fieldDef); + + if (!owningService) { + throw new GraphQLError( + `Couldn't find owning service for field "${parentType.name}.${fieldDef.name}"`, + fieldNode, + ); + } + + return groupForService(owningService); + }); + + return Object.values(groupsByService); +} + +// For mutations, we need to respect the order of the fields, in order to +// determine which fields can be batched together in the same request. If +// they're "split" by fields belonging to other services, then we need to manage +// the proper sequencing at the gateway level. In this example, we need 3 +// FetchGroups (requests) in sequence: +// +// mutation abc { +// createReview() # reviews service (1) +// updateReview() # reviews service (1) +// login() # account service (2) +// deleteReview() # reviews service (3) +// } +function splitRootFieldsSerially( + context: QueryPlanningContext, + fields: FieldSet, +): FetchGroup[] { + const fetchGroups: FetchGroup[] = []; + + function groupForField(serviceName: string) { + let group: FetchGroup; + + // If the most recent FetchGroup in the array belongs to the same service, + // the field in question can be batched within that group. + const previousGroup = fetchGroups[fetchGroups.length - 1]; + if (previousGroup && previousGroup.serviceName === serviceName) { + return previousGroup; + } + + // If there's no previous group, or the previous group is from a different + // service, then we need to add a new FetchGroup. + group = new FetchGroup(serviceName); + fetchGroups.push(group); + + return group; + } + + splitFields(context, [], fields, field => { + const { scope, fieldNode, fieldDef } = field; + const { parentType } = scope; + + const owningService = context.getOwningService(parentType, fieldDef); + + if (!owningService) { + throw new GraphQLError( + `Couldn't find owning service for field "${parentType.name}.${fieldDef.name}"`, + fieldNode, + ); + } + + return groupForField(owningService); + }); + + return fetchGroups; +} + +function splitSubfields( + context: QueryPlanningContext, + path: ResponsePath, + fields: FieldSet, + parentGroup: FetchGroup, +) { + splitFields(context, path, fields, field => { + const { scope, fieldNode, fieldDef } = field; + const { parentType } = scope; + + const parentIsValueType = !isObjectType(parentType) || getFederationMetadataForType(parentType)?.isValueType; + + let baseService, owningService; + + if (parentIsValueType) { + baseService = parentGroup.serviceName; + owningService = parentGroup.serviceName; + } else { + baseService = context.getBaseService(parentType); + owningService = context.getOwningService(parentType, fieldDef); + } + + if (!baseService) { + throw new GraphQLError( + `Couldn't find base service for type "${parentType.name}"`, + fieldNode, + ); + } + + if (!owningService) { + throw new GraphQLError( + `Couldn't find owning service for field "${parentType.name}.${fieldDef.name}"`, + fieldNode, + ); + } + // Is the field defined on the base service? + if (owningService === baseService) { + // Can we fetch the field from the parent group? + if ( + owningService === parentGroup.serviceName || + parentGroup.providedFields.some(matchesField(field)) + ) { + return parentGroup; + } else { + // We need to fetch the key fields from the parent group first, and then + // use a dependent fetch from the owning service. + let keyFields = context.getKeyFields({ + parentType, + serviceName: parentGroup.serviceName, + }); + if ( + keyFields.length === 0 || + (keyFields.length === 1 && + keyFields[0].fieldDef.name === '__typename') + ) { + // Only __typename key found. + // In some cases, the parent group does not have any @key directives. + // Fall back to owning group's keys + keyFields = context.getKeyFields({ + parentType, + serviceName: owningService, + }); + } + return parentGroup.dependentGroupForService(owningService, keyFields); + } + } else { + // It's an extension field, so we need to fetch the required fields first. + const requiredFields = context.getRequiredFields( + parentType, + fieldDef, + owningService, + ); + + // Can we fetch the required fields from the parent group? + if ( + requiredFields.every(requiredField => + parentGroup.providedFields.some(matchesField(requiredField)), + ) + ) { + if (owningService === parentGroup.serviceName) { + return parentGroup; + } else { + return parentGroup.dependentGroupForService( + owningService, + requiredFields, + ); + } + } else { + // We need to go through the base group first. + + const keyFields = context.getKeyFields({ + parentType, + serviceName: parentGroup.serviceName, + }); + + if (!keyFields) { + throw new GraphQLError( + `Couldn't find keys for type "${parentType.name}}" in service "${baseService}"`, + fieldNode, + ); + } + + if (baseService === parentGroup.serviceName) { + return parentGroup.dependentGroupForService( + owningService, + requiredFields, + ); + } + + const baseGroup = parentGroup.dependentGroupForService( + baseService, + keyFields, + ); + + return baseGroup.dependentGroupForService( + owningService, + requiredFields, + ); + } + } + }); +} + +function splitFields( + context: QueryPlanningContext, + path: ResponsePath, + fields: FieldSet, + groupForField: (field: Field) => FetchGroup, +) { + for (const fieldsForResponseName of groupByResponseName(fields).values()) { + for (const [parentType, fieldsForParentType] of groupByParentType(fieldsForResponseName)) { + // Field nodes that share the same response name and parent type are guaranteed + // to have the same field name and arguments. We only need the other nodes when + // merging selection sets, to take node-specific subfields and directives + // into account. + + const field = fieldsForParentType[0]; + const { scope, fieldDef } = field; + + // We skip `__typename` for root types. + if (fieldDef.name === TypeNameMetaFieldDef.name) { + const { schema } = context; + const roots = [ + schema.getQueryType(), + schema.getMutationType(), + schema.getSubscriptionType(), + ] + .filter(isNotNullOrUndefined) + .map(type => type.name); + if (roots.indexOf(parentType.name) > -1) continue; + } + + // We skip introspection fields like `__schema` and `__type`. + if (isIntrospectionType(getNamedType(fieldDef.type))) { + continue; + } + + if (isObjectType(parentType) && scope.possibleTypes.includes(parentType)) { + // If parent type is an object type, we can directly look for the right + // group. + const group = groupForField(field as Field); + group.fields.push( + completeField( + context, + scope as Scope, + group, + path, + fieldsForParentType, + ), + ); + } else { + // For interfaces however, we need to look at all possible runtime types. + + /** + * The following is an optimization to prevent an explosion of type + * conditions to services when it isn't needed. If all possible runtime + * types can be fufilled by only one service then we don't need to + * expand the fields into unique type conditions. + */ + + // Collect all of the field defs on the possible runtime types + const possibleFieldDefs = scope.possibleTypes.map( + runtimeType => context.getFieldDef(runtimeType, field.fieldNode), + ); + + // If none of the field defs have a federation property, this interface's + // implementors can all be resolved within the same service. + const hasNoExtendingFieldDefs = !possibleFieldDefs.some( + (field) => getFederationMetadataForField(field)?.graphName, + ); + + // With no extending field definitions, we can engage the optimization + if (hasNoExtendingFieldDefs) { + const group = groupForField(field as Field); + group.fields.push( + completeField(context, scope, group, path, fieldsForParentType) + ); + continue; + } + + // We keep track of which possible runtime parent types can be fetched + // from which group, + const groupsByRuntimeParentTypes = new MultiMap< + FetchGroup, + GraphQLObjectType + >(); + + for (const runtimeParentType of scope.possibleTypes) { + const fieldDef = context.getFieldDef( + runtimeParentType, + field.fieldNode, + ); + groupsByRuntimeParentTypes.add( + groupForField({ + scope: context.newScope(runtimeParentType, scope), + fieldNode: field.fieldNode, + fieldDef, + }), + runtimeParentType, + ); + } + + // We add the field separately for each runtime parent type. + for (const [group, runtimeParentTypes] of groupsByRuntimeParentTypes) { + for (const runtimeParentType of runtimeParentTypes) { + // We need to adjust the fields to contain the right fieldDef for + // their runtime parent type. + + const fieldDef = context.getFieldDef( + runtimeParentType, + field.fieldNode, + ); + + const fieldsWithRuntimeParentType = fieldsForParentType.map(field => ({ + ...field, + fieldDef, + })); + + group.fields.push( + completeField( + context, + context.newScope(runtimeParentType, scope), + group, + path, + fieldsWithRuntimeParentType, + ), + ); + } + } + } + } + } +} + +function completeField( + context: QueryPlanningContext, + scope: Scope, + parentGroup: FetchGroup, + path: ResponsePath, + fields: FieldSet, +): Field { + const { fieldNode, fieldDef } = fields[0]; + const returnType = getNamedType(fieldDef.type); + + if (!isCompositeType(returnType)) { + // FIXME: We should look at all field nodes to make sure we take directives + // into account (or remove directives for the time being). + return { scope, fieldNode, fieldDef }; + } else { + // For composite types, we need to recurse. + + const fieldPath = addPath(path, getResponseName(fieldNode), fieldDef.type); + + const subGroup = new FetchGroup(parentGroup.serviceName); + subGroup.mergeAt = fieldPath; + + subGroup.providedFields = context.getProvidedFields( + fieldDef, + parentGroup.serviceName, + ); + + // For abstract types, we always need to request `__typename` + if (isAbstractType(returnType)) { + subGroup.fields.push({ + scope: context.newScope(returnType, scope), + fieldNode: typenameField, + fieldDef: TypeNameMetaFieldDef, + }); + } + + const subfields = collectSubfields(context, returnType, fields); + splitSubfields(context, fieldPath, subfields, subGroup); + + parentGroup.otherDependentGroups.push(...subGroup.dependentGroups); + + let definition: FragmentDefinitionNode; + let selectionSet = selectionSetFromFieldSet(subGroup.fields, returnType); + + if (context.autoFragmentization && subGroup.fields.length > 2) { + ({ definition, selectionSet } = getInternalFragment( + selectionSet, + returnType, + context, + )); + parentGroup.internalFragments.add(definition); + } + + // "Hoist" internalFragments of the subGroup into the parentGroup so all + // fragments can be included in the final request for the root FetchGroup + subGroup.internalFragments.forEach(fragment => { + parentGroup.internalFragments.add(fragment); + }); + + return { + scope, + fieldNode: { + ...fieldNode, + selectionSet, + }, + fieldDef, + }; + } +} + +function getInternalFragment( + selectionSet: SelectionSetNode, + returnType: GraphQLCompositeType, + context: QueryPlanningContext +) { + const key = JSON.stringify(selectionSet); + if (!context.internalFragments.has(key)) { + const name = `__QueryPlanFragment_${context.internalFragmentCount++}__`; + + const definition: FragmentDefinitionNode = { + kind: Kind.FRAGMENT_DEFINITION, + name: { + kind: Kind.NAME, + value: name, + }, + typeCondition: { + kind: Kind.NAMED_TYPE, + name: { + kind: Kind.NAME, + value: returnType.name, + }, + }, + selectionSet, + }; + + const fragmentSelection: SelectionSetNode = { + kind: Kind.SELECTION_SET, + selections: [ + { + kind: Kind.FRAGMENT_SPREAD, + name: { + kind: Kind.NAME, + value: name, + }, + }, + ], + }; + + context.internalFragments.set(key, { + name, + definition, + selectionSet: fragmentSelection, + }); + } + + return context.internalFragments.get(key)!; +} + +function collectFields( + context: QueryPlanningContext, + scope: Scope, + selectionSet: SelectionSetNode, + fields: FieldSet = [], + visitedFragmentNames: { [fragmentName: string]: boolean } = Object.create( + null, + ), +): FieldSet { + for (const selection of selectionSet.selections) { + switch (selection.kind) { + case Kind.FIELD: + const fieldDef = context.getFieldDef(scope.parentType, selection); + fields.push({ scope, fieldNode: selection, fieldDef }); + break; + case Kind.INLINE_FRAGMENT: { + const newScope = context.newScope(getFragmentCondition(selection), scope); + if (newScope.possibleTypes.length === 0) { + break; + } + + newScope.directives = selection.directives; + + collectFields( + context, + newScope, + selection.selectionSet, + fields, + visitedFragmentNames, + ); + break; + } + case Kind.FRAGMENT_SPREAD: + const fragmentName = selection.name.value; + + const fragment = context.fragments[fragmentName]; + if (!fragment) { + continue; + } + + const newScope = context.newScope(getFragmentCondition(fragment), scope); + if (newScope.possibleTypes.length === 0) { + continue; + } + + if (visitedFragmentNames[fragmentName]) { + continue; + } + visitedFragmentNames[fragmentName] = true; + + collectFields( + context, + newScope, + fragment.selectionSet, + fields, + visitedFragmentNames, + ); + break; + } + } + + return fields; + + function getFragmentCondition( + fragment: FragmentDefinitionNode | InlineFragmentNode, + ): GraphQLCompositeType { + const typeConditionNode = fragment.typeCondition; + if (!typeConditionNode) return scope.parentType; + + return typeFromAST( + context.schema, + typeConditionNode, + ) as GraphQLCompositeType; + } +} + +// Collecting subfields collapses parent types, because it merges +// selection sets without taking the runtime parent type of the field +// into account. If we want to keep track of multiple levels of possible +// types, this is where that would need to happen. +export function collectSubfields( + context: QueryPlanningContext, + returnType: GraphQLCompositeType, + fields: FieldSet, +): FieldSet { + let subfields: FieldSet = []; + const visitedFragmentNames = Object.create(null); + + for (const field of fields) { + const selectionSet = field.fieldNode.selectionSet; + + if (selectionSet) { + subfields = collectFields( + context, + context.newScope(returnType), + selectionSet, + subfields, + visitedFragmentNames, + ); + } + } + + return subfields; +} + +class FetchGroup { + constructor( + public readonly serviceName: string, + public readonly fields: FieldSet = [], + public readonly internalFragments: Set = new Set() + ) {} + + requiredFields: FieldSet = []; + providedFields: FieldSet = []; + + mergeAt?: ResponsePath; + + private dependentGroupsByService: { + [serviceName: string]: FetchGroup; + } = Object.create(null); + public otherDependentGroups: FetchGroup[] = []; + + dependentGroupForService(serviceName: string, requiredFields: FieldSet) { + let group = this.dependentGroupsByService[serviceName]; + + if (!group) { + group = new FetchGroup(serviceName); + group.mergeAt = this.mergeAt; + this.dependentGroupsByService[serviceName] = group; + } + + if (requiredFields) { + if (group.requiredFields) { + group.requiredFields.push(...requiredFields); + } else { + group.requiredFields = requiredFields; + } + this.fields.push(...requiredFields); + } + + return group; + } + + get dependentGroups() { + return [ + ...Object.values(this.dependentGroupsByService), + ...this.otherDependentGroups, + ]; + } +} + +// Adapted from buildExecutionContext in graphql-js +export function buildOperationContext( + schema: GraphQLSchema, + document: DocumentNode, + operationName?: string, +): OperationContext { + let operation: OperationDefinitionNode | undefined; + const fragments: { + [fragmentName: string]: FragmentDefinitionNode; + } = Object.create(null); + document.definitions.forEach(definition => { + switch (definition.kind) { + case Kind.OPERATION_DEFINITION: + if (!operationName && operation) { + throw new GraphQLError( + 'Must provide operation name if query contains ' + + 'multiple operations.', + ); + } + if ( + !operationName || + (definition.name && definition.name.value === operationName) + ) { + operation = definition; + } + break; + case Kind.FRAGMENT_DEFINITION: + fragments[definition.name.value] = definition; + break; + } + }); + if (!operation) { + if (operationName) { + throw new GraphQLError(`Unknown operation named "${operationName}".`); + } else { + throw new GraphQLError('Must provide an operation.'); + } + } + + return { schema, operation, fragments }; +} + +export function buildQueryPlanningContext( + { operation, schema, fragments }: OperationContext, + options: BuildQueryPlanOptions, +): QueryPlanningContext { + return new QueryPlanningContext( + schema, + operation, + fragments, + options.autoFragmentization, + ); +} + +export class QueryPlanningContext { + public internalFragments: Map< + string, + { + name: string; + definition: FragmentDefinitionNode; + selectionSet: SelectionSetNode; + } + > = new Map(); + + public internalFragmentCount = 0; + + protected variableDefinitions: { + [name: string]: VariableDefinitionNode; + }; + + constructor( + public readonly schema: GraphQLSchema, + public readonly operation: OperationDefinitionNode, + public readonly fragments: FragmentMap, + public readonly autoFragmentization: boolean, + ) { + this.variableDefinitions = Object.create(null); + visit(operation, { + VariableDefinition: definition => { + this.variableDefinitions[definition.variable.name.value] = definition; + }, + }); + } + + getFieldDef(parentType: GraphQLCompositeType, fieldNode: FieldNode) { + const fieldName = fieldNode.name.value; + + const fieldDef = getFieldDef(this.schema, parentType, fieldName); + + if (!fieldDef) { + throw new GraphQLError( + `Cannot query field "${fieldNode.name.value}" on type "${String( + parentType, + )}"`, + fieldNode, + ); + } + + return fieldDef; + } + + getPossibleTypes( + type: GraphQLAbstractType | GraphQLObjectType, + ): ReadonlyArray { + return isAbstractType(type) ? this.schema.getPossibleTypes(type) : [type]; + } + + getVariableUsages( + selectionSet: SelectionSetNode, + fragments: Set, + ) { + const usages: { + [name: string]: VariableDefinitionNode; + } = Object.create(null); + + // Construct a document of the selection set and fragment definitions so we + // can visit them, adding all variable usages to the `usages` object. + const document: DocumentNode = { + kind: Kind.DOCUMENT, + definitions: [ + { kind: Kind.OPERATION_DEFINITION, selectionSet, operation: 'query' }, + ...Array.from(fragments), + ], + }; + + visit(document, { + Variable: (node) => { + usages[node.name.value] = this.variableDefinitions[node.name.value]; + }, + }); + + return usages; + } + + newScope( + parentType: TParent, + enclosingScope?: Scope, + ): Scope { + return { + parentType, + possibleTypes: enclosingScope + ? this.getPossibleTypes(parentType).filter(type => + enclosingScope.possibleTypes.includes(type), + ) + : this.getPossibleTypes(parentType), + enclosingScope, + }; + } + + getBaseService(parentType: GraphQLObjectType): string | undefined { + return getFederationMetadataForType(parentType)?.graphName; + } + + getOwningService( + parentType: GraphQLObjectType, + fieldDef: GraphQLField, + ): string | undefined { + return ( + getFederationMetadataForField(fieldDef)?.graphName ?? + this.getBaseService(parentType) + ); + } + + getKeyFields({ + parentType, + serviceName, + fetchAll = false, + }: { + parentType: GraphQLCompositeType; + serviceName: string; + fetchAll?: boolean; + }): FieldSet { + const keyFields: FieldSet = []; + + keyFields.push({ + scope: { + parentType, + possibleTypes: this.getPossibleTypes(parentType), + }, + fieldNode: typenameField, + fieldDef: TypeNameMetaFieldDef, + }); + + for (const possibleType of this.getPossibleTypes(parentType)) { + const keys = getFederationMetadataForType(possibleType)?.keys?.get(serviceName); + + if (!(keys && keys.length > 0)) continue; + + if (fetchAll) { + keyFields.push( + ...keys.flatMap(key => + collectFields(this, this.newScope(possibleType), { + kind: Kind.SELECTION_SET, + selections: key, + }), + ), + ); + } else { + keyFields.push( + ...collectFields(this, this.newScope(possibleType), { + kind: Kind.SELECTION_SET, + selections: keys[0], + }), + ); + } + } + + return keyFields; + } + + getRequiredFields( + parentType: GraphQLCompositeType, + fieldDef: GraphQLField, + serviceName: string, + ): FieldSet { + const requiredFields: FieldSet = []; + + requiredFields.push(...this.getKeyFields({ parentType, serviceName })); + + const fieldFederationMetadata = getFederationMetadataForField(fieldDef); + if (fieldFederationMetadata?.requires) { + requiredFields.push( + ...collectFields(this, this.newScope(parentType), { + kind: Kind.SELECTION_SET, + selections: fieldFederationMetadata.requires, + }), + ); + } + + return requiredFields; + } + + getProvidedFields( + fieldDef: GraphQLField, + serviceName: string, + ): FieldSet { + const returnType = getNamedType(fieldDef.type); + if (!isCompositeType(returnType)) return []; + + const providedFields: FieldSet = []; + + providedFields.push( + ...this.getKeyFields({ + parentType: returnType, + serviceName, + fetchAll: true, + }), + ); + + const fieldFederationMetadata = getFederationMetadataForField(fieldDef); + if (fieldFederationMetadata?.provides) { + providedFields.push( + ...collectFields(this, this.newScope(returnType), { + kind: Kind.SELECTION_SET, + selections: fieldFederationMetadata.provides, + }), + ); + } + + return providedFields; + } +} + +function addPath(path: ResponsePath, responseName: string, type: GraphQLType) { + path = [...path, responseName]; + + while (!isNamedType(type)) { + if (isListType(type)) { + path.push('@'); + } + + type = type.ofType; + } + + return path; +} diff --git a/query-planner-js/src/composedSchema/__tests__/buildComposedSchema.test.ts b/query-planner-js/src/composedSchema/__tests__/buildComposedSchema.test.ts new file mode 100644 index 000000000..27ed82092 --- /dev/null +++ b/query-planner-js/src/composedSchema/__tests__/buildComposedSchema.test.ts @@ -0,0 +1,73 @@ +import fs from 'fs'; +import { + GraphQLDirective, + GraphQLNamedType, + GraphQLSchema, + parse, +} from 'graphql'; +import path from 'path'; +import { buildComposedSchema } from '..'; + +describe('buildComposedSchema', () => { + let schema: GraphQLSchema; + + beforeAll(() => { + const schemaPath = path.join( + __dirname, + '../../__tests__/features/basic/', + 'supergraphSdl.graphql', + ); + const supergraphSdl = fs.readFileSync(schemaPath, 'utf8'); + + schema = buildComposedSchema(parse(supergraphSdl)); + }); + + it(`doesn't include core directives`, () => { + const directives = schema + .getDirectives() + .filter((directive) => isAssociatedWithFeature(directive, 'core')); + expect(directives).toEqual([]); + }); + + it(`doesn't include core types`, () => { + const types = Object.values(schema.getTypeMap()).filter((type) => + isAssociatedWithFeature(type, 'core'), + ); + expect(types).toEqual([]); + }); + + it(`doesn't include join directives`, () => { + const directives = schema + .getDirectives() + .filter((directive) => isAssociatedWithFeature(directive, 'join')); + expect(directives).toEqual([]); + }); + + it(`doesn't include join types`, () => { + const types = Object.values(schema.getTypeMap()).filter((type) => + isAssociatedWithFeature(type, 'join'), + ); + expect(types).toEqual([]); + }); + + it(`does pass through other custom directives`, () => { + expect(schema.getDirectives()).toEqual( + expect.arrayContaining([ + expect.objectContaining({ name: 'transform' }), + expect.objectContaining({ name: 'stream' }), + ]), + ); + }); +}); + +type NamedSchemaElement = GraphQLDirective | GraphQLNamedType; + +function isAssociatedWithFeature( + element: NamedSchemaElement, + featureName: string, +) { + return ( + element.name === `${featureName}` || + element.name.startsWith(`${featureName}__`) + ); +} diff --git a/query-planner-js/src/composedSchema/__tests__/tsconfig.json b/query-planner-js/src/composedSchema/__tests__/tsconfig.json new file mode 100644 index 000000000..0a2bbf99d --- /dev/null +++ b/query-planner-js/src/composedSchema/__tests__/tsconfig.json @@ -0,0 +1,8 @@ +{ + "extends": "../../../tsconfig.test", + "include": ["**/*"], + "references": [ + { "path": "../../../" }, + { "path": "../../../../federation-integration-testsuite-js" }, + ] +} diff --git a/query-planner-js/src/composedSchema/buildComposedSchema.ts b/query-planner-js/src/composedSchema/buildComposedSchema.ts new file mode 100644 index 000000000..9630c9b19 --- /dev/null +++ b/query-planner-js/src/composedSchema/buildComposedSchema.ts @@ -0,0 +1,240 @@ +import { + buildASTSchema, + DocumentNode, + GraphQLDirective, + GraphQLError, + GraphQLNamedType, + GraphQLSchema, + isDirective, + isEnumType, + isIntrospectionType, + isObjectType, +} from 'graphql'; +import { assert } from '../utilities/assert'; +import { + getArgumentValuesForDirective, + getArgumentValuesForRepeatableDirective, + isASTKind, + parseSelections, +} from '../utilities/graphql'; +import { MultiMap } from '../utilities/MultiMap'; +import { + FederationFieldMetadata, + FederationTypeMetadata, + FieldSet, + GraphMap, +} from './metadata'; + +export function buildComposedSchema(document: DocumentNode): GraphQLSchema { + const schema = buildASTSchema(document); + + // TODO: We should follow the Bootstrap algorithm from the Core Schema spec + // to handle renames of @core itself. + const coreName = 'core'; + + const coreDirective = schema.getDirective(coreName); + + assert(coreDirective, `Expected core schema, but can't find @core directive`); + + // TODO: We should follow the CollectFeatures algorithm from the Core Schema + // spec here, and use the collected features to validate feature + // versions and handle renames. + + const coreDirectivesArgs = getArgumentValuesForRepeatableDirective( + coreDirective, + schema.astNode!, + ); + + for (const coreDirectiveArgs of coreDirectivesArgs) { + const feature: string = coreDirectiveArgs['feature']; + + if ( + !( + feature === 'https://specs.apollo.dev/core/v0.1' || + feature === 'https://specs.apollo.dev/join/v0.1' + ) + ) { + throw new GraphQLError( + `Unsupported core schema feature and/or version: ${feature}`, + schema.astNode!, + ); + } + } + + const joinName = 'join'; + + function getJoinDirective(name: string) { + const fullyQualifiedName = `${joinName}__${name}`; + + const directive = schema.getDirective(fullyQualifiedName); + assert( + directive, + `Composed schema should define @${fullyQualifiedName} directive`, + ); + return directive; + } + + const ownerDirective = getJoinDirective('owner'); + const typeDirective = getJoinDirective('type'); + const fieldDirective = getJoinDirective('field'); + const graphDirective = getJoinDirective('graph'); + + const graphEnumType = schema.getType(`${joinName}__Graph`); + assert(isEnumType(graphEnumType), `${joinName}__Graph should be an enum`); + + const graphMap: GraphMap = Object.create(null); + + schema.extensions = { + ...schema.extensions, + federation: { + graphs: graphMap, + }, + }; + + for (const graphValue of graphEnumType.getValues()) { + const name = graphValue.name; + + const graphDirectiveArgs = getArgumentValuesForDirective( + graphDirective, + graphValue.astNode!, + ); + assert( + graphDirectiveArgs, + `${graphEnumType.name} value ${name} in composed schema should have a @${graphDirective.name} directive`, + ); + + const graphName: string = graphDirectiveArgs['name']; + const url: string = graphDirectiveArgs['url']; + + graphMap[name] = { + name: graphName, + url, + }; + } + + for (const type of Object.values(schema.getTypeMap())) { + if (isIntrospectionType(type)) continue; + + // We currently only allow join spec directives on object types. + if (!isObjectType(type)) continue; + + assert( + type.astNode, + `GraphQL type "${type.name}" should contain AST nodes`, + ); + + const ownerDirectiveArgs = getArgumentValuesForDirective( + ownerDirective, + type.astNode, + ); + + const typeMetadata: FederationTypeMetadata = ownerDirectiveArgs + ? { + graphName: graphMap[ownerDirectiveArgs?.['graph']].name, + keys: new MultiMap(), + isValueType: false, + } + : { + isValueType: true, + }; + + type.extensions = { + ...type.extensions, + federation: typeMetadata, + }; + + const typeDirectivesArgs = getArgumentValuesForRepeatableDirective( + typeDirective, + type.astNode, + ); + + assert( + !(typeMetadata.isValueType && typeDirectivesArgs.length >= 1), + `GraphQL type "${type.name}" cannot have a @${typeDirective.name} \ +directive without an @${ownerDirective.name} directive`, + ); + + for (const typeDirectiveArgs of typeDirectivesArgs) { + const graphName = graphMap[typeDirectiveArgs['graph']].name; + + const keyFields = parseFieldSet(typeDirectiveArgs['key']); + + typeMetadata.keys?.add(graphName, keyFields); + } + + for (const fieldDef of Object.values(type.getFields())) { + assert( + fieldDef.astNode, + `Field "${type.name}.${fieldDef.name}" should contain AST nodes`, + ); + + const fieldDirectiveArgs = getArgumentValuesForDirective( + fieldDirective, + fieldDef.astNode, + ); + + if (!fieldDirectiveArgs) continue; + + const fieldMetadata: FederationFieldMetadata = { + graphName: graphMap[fieldDirectiveArgs?.['graph']]?.name, + }; + + fieldDef.extensions = { + ...fieldDef.extensions, + federation: fieldMetadata, + }; + + const { requires, provides } = fieldDirectiveArgs; + + if (requires) { + fieldMetadata.requires = parseFieldSet(requires); + } + + if (provides) { + fieldMetadata.provides = parseFieldSet(provides); + } + } + } + + // We filter out schema elements that should not be exported to get to the + // API schema. + + const schemaConfig = schema.toConfig(); + + return new GraphQLSchema({ + ...schemaConfig, + types: schemaConfig.types.filter(isExported), + directives: schemaConfig.directives.filter(isExported), + }); + + // TODO: Implement the IsExported algorithm from the Core Schema spec. + function isExported(element: NamedSchemaElement) { + return !(isAssociatedWithFeature(element, coreName) || isAssociatedWithFeature(element, joinName)) + } + + function isAssociatedWithFeature( + element: NamedSchemaElement, + featureName: string, + ) { + return ( + // Only directives can use the unprefixed feature name + isDirective(element) && element.name === featureName || + element.name.startsWith(`${featureName}__`) + ); + } +} + +type NamedSchemaElement = GraphQLDirective | GraphQLNamedType; + +function parseFieldSet(source: string): FieldSet { + const selections = parseSelections(source); + + assert( + selections.every(isASTKind('Field', 'InlineFragment')), + `Field sets may not contain fragment spreads, but found: "${source}"`, + ); + + assert(selections.length > 0, `Field sets may not be empty`); + + return selections; +} diff --git a/query-planner-js/src/composedSchema/index.ts b/query-planner-js/src/composedSchema/index.ts new file mode 100644 index 000000000..5794db0c3 --- /dev/null +++ b/query-planner-js/src/composedSchema/index.ts @@ -0,0 +1,2 @@ +export { buildComposedSchema } from './buildComposedSchema'; +export * from './metadata'; diff --git a/query-planner-js/src/composedSchema/metadata.ts b/query-planner-js/src/composedSchema/metadata.ts new file mode 100644 index 000000000..45f5174bf --- /dev/null +++ b/query-planner-js/src/composedSchema/metadata.ts @@ -0,0 +1,56 @@ +import { FieldNode, InlineFragmentNode, GraphQLField, GraphQLObjectType } from 'graphql'; +import { MultiMap } from '../utilities/MultiMap'; + +declare module 'graphql' { + interface GraphQLSchemaExtensions { + federation?: FederationSchemaMetadata; + } + + interface GraphQLObjectTypeExtensions { + federation?: FederationTypeMetadata; + } + + interface GraphQLFieldExtensions< + _TSource, + _TContext, + _TArgs = { [argName: string]: any } + > { + federation?: FederationFieldMetadata; + } +} + +export function getFederationMetadataForType( + type: GraphQLObjectType, +): FederationTypeMetadata | undefined { + return type.extensions?.federation; +} + +export function getFederationMetadataForField( + field: GraphQLField, +): FederationFieldMetadata | undefined { + return field.extensions?.federation; +} + +export type GraphName = string; +export type FieldSet = readonly (FieldNode | InlineFragmentNode)[]; + +export interface Graph { + name: string; + url: string; +} + +export type GraphMap = { [graphName: string]: Graph }; +export interface FederationSchemaMetadata { + graphs: GraphMap; +} +export interface FederationTypeMetadata { + graphName?: GraphName; + keys?: MultiMap; + isValueType: boolean; +} + +export interface FederationFieldMetadata { + graphName?: GraphName; + requires?: FieldSet; + provides?: FieldSet; +} diff --git a/query-planner-js/src/index.ts b/query-planner-js/src/index.ts index fd5efc393..dc90306a3 100644 --- a/query-planner-js/src/index.ts +++ b/query-planner-js/src/index.ts @@ -1,118 +1,37 @@ -import { - Kind, - SelectionNode as GraphQLJSSelectionNode, -} from 'graphql'; -import * as wasm from '@apollo/query-planner-wasm'; - export { queryPlanSerializer, astSerializer } from './snapshotSerializers'; export { prettyFormatQueryPlan } from './prettyFormatQueryPlan'; -export type QueryPlannerPointer = number; - -export function getQueryPlanner(schema: string): QueryPlannerPointer { - return wasm.getQueryPlanner(schema); -} - -export function getQueryPlan(planner_ptr: QueryPlannerPointer, query: string, options: any): QueryPlan { - return wasm.getQueryPlan(planner_ptr, query, options) -} - -export type ResponsePath = (string | number)[]; - -export interface QueryPlan { - kind: 'QueryPlan'; - node?: PlanNode; -} - -export type PlanNode = SequenceNode | ParallelNode | FetchNode | FlattenNode; - -export interface SequenceNode { - kind: 'Sequence'; - nodes: PlanNode[]; -} - -export interface ParallelNode { - kind: 'Parallel'; - nodes: PlanNode[]; -} - -export interface FetchNode { - kind: 'Fetch'; - serviceName: string; - variableUsages?: string[]; - requires?: QueryPlanSelectionNode[]; - operation: string; -} +export * from './QueryPlan'; +import { QueryPlan } from './QueryPlan'; -export interface FlattenNode { - kind: 'Flatten'; - path: ResponsePath; - node: PlanNode; -} +export * from './composedSchema'; +import { buildComposedSchema } from './composedSchema'; +import { GraphQLSchema, parse } from 'graphql'; +import { buildOperationContext, buildQueryPlan } from './buildQueryPlan'; -/** - * SelectionNodes from GraphQL-js _can_ have a FragmentSpreadNode - * but this SelectionNode is specifically typing the `requires` key - * in a built query plan, where there can't be FragmentSpreadNodes - * since that info is contained in the `FetchNode.operation` - */ -export type QueryPlanSelectionNode = QueryPlanFieldNode | QueryPlanInlineFragmentNode; +// We temporarily export the same API we used for the wasm query planner, +// but implemented as a facade on top of the TypeScript one. This is ugly +// and inefficient (we shouldn't be parsing the schema and/or query again), +// but the goal is to get things working first without making changes to +// the gateway code. -export interface QueryPlanFieldNode { - readonly kind: 'Field'; - readonly alias?: string; - readonly name: string; - readonly selections?: QueryPlanSelectionNode[]; -} +export type QueryPlannerPointer = { + composedSchema: GraphQLSchema +}; -export interface QueryPlanInlineFragmentNode { - readonly kind: 'InlineFragment'; - readonly typeCondition?: string; - readonly selections: QueryPlanSelectionNode[]; +export function getQueryPlanner(schema: string): QueryPlannerPointer { + return { + composedSchema: buildComposedSchema(parse(schema)), + }; } -export function getResponseName(node: QueryPlanFieldNode): string { - return node.alias ? node.alias : node.name; +export function getQueryPlan( + planner_ptr: QueryPlannerPointer, + query: string, + options: any, +): QueryPlan { + return buildQueryPlan( + buildOperationContext(planner_ptr.composedSchema, parse(query)), + options, + ); } - -/** - * Converts a GraphQL-js SelectionNode to our newly defined SelectionNode - * - * This function is used to remove the unneeded pieces of a SelectionSet's - * `.selections`. It is only ever called on a query plan's `requires` field, - * so we can guarantee there won't be any FragmentSpreads passed in. That's why - * we can ignore the case where `selection.kind === Kind.FRAGMENT_SPREAD` - */ -export const trimSelectionNodes = ( - selections: readonly GraphQLJSSelectionNode[], -): QueryPlanSelectionNode[] => { - /** - * Using an array to push to instead of returning value from `selections.map` - * because TypeScript thinks we can encounter a `Kind.FRAGMENT_SPREAD` here, - * so if we mapped the array directly to the return, we'd have to `return undefined` - * from one branch of the map and then `.filter(Boolean)` on that returned - * array - */ - const remapped: QueryPlanSelectionNode[] = []; - - selections.forEach((selection) => { - if (selection.kind === Kind.FIELD) { - remapped.push({ - kind: Kind.FIELD, - name: selection.name.value, - selections: - selection.selectionSet && - trimSelectionNodes(selection.selectionSet.selections), - }); - } - if (selection.kind === Kind.INLINE_FRAGMENT) { - remapped.push({ - kind: Kind.INLINE_FRAGMENT, - typeCondition: selection.typeCondition?.name.value, - selections: trimSelectionNodes(selection.selectionSet.selections), - }); - } - }); - - return remapped; -}; diff --git a/query-planner-js/src/utilities/MultiMap.ts b/query-planner-js/src/utilities/MultiMap.ts new file mode 100644 index 000000000..d4e29e760 --- /dev/null +++ b/query-planner-js/src/utilities/MultiMap.ts @@ -0,0 +1,11 @@ +export class MultiMap extends Map { + add(key: K, value: V): this { + const values = this.get(key); + if (values) { + values.push(value); + } else { + this.set(key, [value]); + } + return this; + } +} diff --git a/query-planner-js/src/utilities/__tests__/deepMerge.test.ts b/query-planner-js/src/utilities/__tests__/deepMerge.test.ts new file mode 100644 index 000000000..545968ba9 --- /dev/null +++ b/query-planner-js/src/utilities/__tests__/deepMerge.test.ts @@ -0,0 +1,77 @@ +import { deepMerge } from '../deepMerge'; + +describe('deepMerge', () => { + it('merges basic', () => { + const target = { + a: 1, + b: 2, + }; + + const source = { + b: 3, + c: 4, + }; + + expect(deepMerge(target, source)).toEqual({ + a: 1, + b: 3, + c: 4, + }); + }); + + it('merges nested objects', () => { + const target = { + a: 1, + b: { + someProperty: 1, + overwrittenProperty: 'clean', + }, + }; + + const source = { + b: { + overwrittenProperty: 'dirty', + newProperty: 'new', + }, + c: 4, + }; + + expect(deepMerge(target, source)).toEqual({ + a: 1, + b: { + newProperty: 'new', + overwrittenProperty: 'dirty', + someProperty: 1, + }, + c: 4, + }); + }); + + it('ignores merging __proto__ fields', () => { + const target = {}; + + // Bypass setters on __proto__ + const source = JSON.parse('{"__proto__": {"pollution": true}}'); + deepMerge(target, source); + + expect(Object.prototype.hasOwnProperty('pollution')).toBe(false); + }); + + it('merges arrays', () => { + const target = { + a: 1, + b: [{ c: 1, d: 2 }], + }; + + const source = { + e: 2, + b: [{ f: 3 }], + }; + + expect(deepMerge(target, source)).toEqual({ + a: 1, + e: 2, + b: [{ c: 1, d: 2, f: 3 }], + }); + }); +}); diff --git a/query-planner-js/src/utilities/array.ts b/query-planner-js/src/utilities/array.ts new file mode 100644 index 000000000..f9ebc201b --- /dev/null +++ b/query-planner-js/src/utilities/array.ts @@ -0,0 +1,77 @@ +import { isNotNullOrUndefined } from './predicates'; + +export function compactMap( + array: T[], + callbackfn: (value: T, index: number, array: T[]) => U | null | undefined, +): U[] { + return array.reduce( + (accumulator, element, index, array) => { + const result = callbackfn(element, index, array); + if (isNotNullOrUndefined(result)) { + accumulator.push(result); + } + return accumulator; + }, + [] as U[], + ); +} + +export function partition( + array: T[], + predicate: (element: T, index: number, array: T[]) => element is U, +): [U[], T[]]; +export function partition( + array: T[], + predicate: (element: T, index: number, array: T[]) => boolean, +): [T[], T[]]; +export function partition( + array: T[], + predicate: (element: T, index: number, array: T[]) => boolean, +): [T[], T[]] { + array.map; + return array.reduce( + (accumulator, element, index) => { + return ( + predicate(element, index, array) + ? accumulator[0].push(element) + : accumulator[1].push(element), + accumulator + ); + }, + [[], []] as [T[], T[]], + ); +} + +export function findAndExtract( + array: T[], + predicate: (element: T, index: number, array: T[]) => boolean, +): [T | undefined, T[]] { + const index = array.findIndex(predicate); + if (index === -1) return [undefined, array]; + + let remaining = array.slice(0, index); + if (index < array.length - 1) { + remaining.push(...array.slice(index + 1)); + } + + return [array[index], remaining]; +} + +export function groupBy(keyFunction: (element: T) => U) { + return (iterable: Iterable) => { + const result = new Map(); + + for (const element of iterable) { + const key = keyFunction(element); + const group = result.get(key); + + if (group) { + group.push(element); + } else { + result.set(key, [element]); + } + } + + return result; + }; +} diff --git a/query-planner-js/src/utilities/assert.ts b/query-planner-js/src/utilities/assert.ts new file mode 100644 index 000000000..e4c53793f --- /dev/null +++ b/query-planner-js/src/utilities/assert.ts @@ -0,0 +1,5 @@ +export function assert(condition: any, message: string): asserts condition { + if (!condition) { + throw new Error(message); + } +} diff --git a/query-planner-js/src/utilities/deepMerge.ts b/query-planner-js/src/utilities/deepMerge.ts new file mode 100644 index 000000000..fb5504a38 --- /dev/null +++ b/query-planner-js/src/utilities/deepMerge.ts @@ -0,0 +1,30 @@ +import { isObject } from './predicates'; + +export function deepMerge(target: any, source: any): any { + if (source === undefined || source === null) return target; + + for (const key of Object.keys(source)) { + if (source[key] === undefined || key === '__proto__') continue; + + if (target[key] && isObject(source[key])) { + deepMerge(target[key], source[key]); + } else if ( + Array.isArray(source[key]) && + Array.isArray(target[key]) && + source[key].length === target[key].length + ) { + let i = 0; + for (; i < source[key].length; i++) { + if (isObject(target[key][i]) && isObject(source[key][i])) { + deepMerge(target[key][i], source[key][i]); + } else { + target[key][i] = source[key][i]; + } + } + } else { + target[key] = source[key]; + } + } + + return target; +} diff --git a/query-planner-js/src/utilities/graphql.ts b/query-planner-js/src/utilities/graphql.ts new file mode 100644 index 000000000..a843ceb31 --- /dev/null +++ b/query-planner-js/src/utilities/graphql.ts @@ -0,0 +1,156 @@ +import { + ASTKindToNode, + ASTNode, + DirectiveNode, + FieldNode, + GraphQLCompositeType, + GraphQLDirective, + GraphQLField, + GraphQLInterfaceType, + GraphQLNullableType, + GraphQLObjectType, + GraphQLSchema, + GraphQLType, + GraphQLUnionType, + isListType, + isNonNullType, + Kind, + ListTypeNode, + NamedTypeNode, + OperationDefinitionNode, + parse, + print, + SchemaMetaFieldDef, + SelectionNode, + SelectionSetNode, + TypeMetaFieldDef, + TypeNameMetaFieldDef, + TypeNode, +} from 'graphql'; +import { getArgumentValues } from 'graphql/execution/values'; +import { assert } from './assert'; + +/** + * Not exactly the same as the executor's definition of getFieldDef, in this + * statically evaluated environment we do not always have an Object type, + * and need to handle Interface and Union types. + */ +export function getFieldDef( + schema: GraphQLSchema, + parentType: GraphQLCompositeType, + fieldName: string, +): GraphQLField | undefined { + if ( + fieldName === SchemaMetaFieldDef.name && + schema.getQueryType() === parentType + ) { + return SchemaMetaFieldDef; + } + if ( + fieldName === TypeMetaFieldDef.name && + schema.getQueryType() === parentType + ) { + return TypeMetaFieldDef; + } + if ( + fieldName === TypeNameMetaFieldDef.name && + (parentType instanceof GraphQLObjectType || + parentType instanceof GraphQLInterfaceType || + parentType instanceof GraphQLUnionType) + ) { + return TypeNameMetaFieldDef; + } + if ( + parentType instanceof GraphQLObjectType || + parentType instanceof GraphQLInterfaceType + ) { + return parentType.getFields()[fieldName]; + } + + return undefined; +} + +export function getResponseName(node: FieldNode): string { + return node.alias ? node.alias.value : node.name.value; +} + +export function allNodesAreOfSameKind( + firstNode: T, + remainingNodes: ASTNode[], +): remainingNodes is T[] { + return !remainingNodes.some(node => node.kind !== firstNode.kind); +} + +export function astFromType( + type: GraphQLNullableType, +): NamedTypeNode | ListTypeNode; +export function astFromType(type: GraphQLType): TypeNode { + if (isListType(type)) { + return { kind: Kind.LIST_TYPE, type: astFromType(type.ofType) }; + } else if (isNonNullType(type)) { + return { kind: Kind.NON_NULL_TYPE, type: astFromType(type.ofType) }; + } else { + return { + kind: Kind.NAMED_TYPE, + name: { kind: Kind.NAME, value: type.name }, + }; + } +} + +export function printWithReducedWhitespace(ast: ASTNode): string { + return print(ast) + .replace(/\s+/g, ' ') + .trim(); +} + +export function parseSelectionSet(source: string): SelectionSetNode { + return (parse(`query ${source}`) + .definitions[0] as OperationDefinitionNode).selectionSet; +} + +export function parseSelections(source: string): ReadonlyArray { + return (parse(`query { ${source} }`) + .definitions[0] as OperationDefinitionNode).selectionSet.selections; +} + +// Using `getArgumentValues` from `graphql-js` ensures that arguments are of the right type, +// and that required arguments are present. + +export function getArgumentValuesForDirective( + directiveDef: GraphQLDirective, + node: { directives?: readonly DirectiveNode[] } & ASTNode, +): Record | undefined { + assert( + !directiveDef.isRepeatable, + 'Use getArgumentValuesForRepeatableDirective for repeatable directives', + ); + + if (!node.directives) return undefined; + + const directiveNode = node.directives.find( + (directiveNode) => directiveNode.name.value === directiveDef.name, + ); + + if (!directiveNode) return undefined; + return getArgumentValues(directiveDef, directiveNode); +} + +export function getArgumentValuesForRepeatableDirective( + directiveDef: GraphQLDirective, + node: { directives?: readonly DirectiveNode[] } & ASTNode, +): Record[] { + if (!node.directives) return []; + + const directiveNodes = node.directives.filter( + (directiveNode) => directiveNode.name.value === directiveDef.name, + ); + + return directiveNodes.map((directiveNode) => + getArgumentValues(directiveDef, directiveNode), + ); +} + +export function isASTKind(...kinds: K[]) { + return (node: ASTNode): node is ASTKindToNode[K] => + kinds.some((kind) => node.kind === kind); +} diff --git a/query-planner-js/src/utilities/predicates.ts b/query-planner-js/src/utilities/predicates.ts new file mode 100644 index 000000000..373ec7583 --- /dev/null +++ b/query-planner-js/src/utilities/predicates.ts @@ -0,0 +1,14 @@ +export function isObject(value: any): value is object { + return ( + value !== undefined && + value !== null && + typeof value === 'object' && + !Array.isArray(value) + ); +} + +export function isNotNullOrUndefined( + value: T | null | undefined, +): value is T { + return value !== null && typeof value !== 'undefined'; +} diff --git a/query-planner-wasm/package.json b/query-planner-wasm/package.json index a8e1910cd..30a0b4ed9 100644 --- a/query-planner-wasm/package.json +++ b/query-planner-wasm/package.json @@ -6,7 +6,7 @@ "build-esm": "wasm-pack build --target bundler --out-dir module --out-name index --scope apollo", "build-cjs": "wasm-pack build --target nodejs --out-dir dist --out-name index --scope apollo", "remove-pkg-cruft": "rm module/package.json dist/package.json dist/.gitignore module/.gitignore dist/README.md module/README.md", - "monorepo-prepare": "npm run build-esm && npm run build-cjs && npm run remove-pkg-cruft" + "_monorepo-prepare": "npm run build-esm && npm run build-cjs && npm run remove-pkg-cruft" }, "author": "opensource@apollographql.com", "license": "MIT",