;; GraphQL parser, executor, and hyperscript integration tests ;; ── Tokenizer ───────────────────────────────────────────────────── (defsuite "gql-tokenizer" (deftest "tokenizes simple query" (let ((tokens (gql-tokenize "{ user { name } }"))) (assert= "brace-open" (get (nth tokens 0) :type)) (assert= "name" (get (nth tokens 1) :type)) (assert= "user" (get (nth tokens 1) :value)) (assert= "brace-open" (get (nth tokens 2) :type)) (assert= "name" (get (nth tokens 3) :type)) (assert= "name" (get (nth tokens 3) :value)) (assert= "brace-close" (get (nth tokens 4) :type)) (assert= "brace-close" (get (nth tokens 5) :type)) (assert= "eof" (get (nth tokens 6) :type)))) (deftest "tokenizes arguments" (let ((tokens (gql-tokenize "user(id: 42)"))) (assert= "name" (get (nth tokens 0) :type)) (assert= "paren-open" (get (nth tokens 1) :type)) (assert= "name" (get (nth tokens 2) :type)) (assert= "id" (get (nth tokens 2) :value)) (assert= "colon" (get (nth tokens 3) :type)) (assert= "number" (get (nth tokens 4) :type)) (assert= 42 (get (nth tokens 4) :value)) (assert= "paren-close" (get (nth tokens 5) :type)))) (deftest "tokenizes string values" (let ((tokens (gql-tokenize "name: \"hello world\""))) (assert= "string" (get (nth tokens 2) :type)) (assert= "hello world" (get (nth tokens 2) :value)))) (deftest "tokenizes dollar sign for variables" (let ((tokens (gql-tokenize "$id"))) (assert= "dollar" (get (nth tokens 0) :type)) (assert= "name" (get (nth tokens 1) :type)) (assert= "id" (get (nth tokens 1) :value)))) (deftest "tokenizes bang for non-null" (let ((tokens (gql-tokenize "String!"))) (assert= "name" (get (nth tokens 0) :type)) (assert= "bang" (get (nth tokens 1) :type)))) (deftest "tokenizes spread operator" (let ((tokens (gql-tokenize "...UserFields"))) (assert= "spread" (get (nth tokens 0) :type)) (assert= "name" (get (nth tokens 1) :type)) (assert= "UserFields" (get (nth tokens 1) :value)))) (deftest "tokenizes at sign for directives" (let ((tokens (gql-tokenize "@skip(if: true)"))) (assert= "at" (get (nth tokens 0) :type)) (assert= "name" (get (nth tokens 1) :type)) (assert= "skip" (get (nth tokens 1) :value)))) (deftest "skips comments" (let ((tokens (gql-tokenize "{ # this is a comment\n user }"))) (assert= "brace-open" (get (nth tokens 0) :type)) (assert= "name" (get (nth tokens 1) :type)) (assert= "user" (get (nth tokens 1) :value)) (assert= "brace-close" (get (nth tokens 2) :type)))) (deftest "skips commas" (let ((tokens (gql-tokenize "name, email, age"))) (assert= 4 (length tokens)) (assert= "name" (get (nth tokens 0) :value)) (assert= "email" (get (nth tokens 1) :value)) (assert= "age" (get (nth tokens 2) :value)))) (deftest "tokenizes negative numbers" (let ((tokens (gql-tokenize "-42"))) (assert= "number" (get (nth tokens 0) :type)) (assert= -42 (get (nth tokens 0) :value)))) (deftest "tokenizes float numbers" (let ((tokens (gql-tokenize "3.14"))) (assert= "number" (get (nth tokens 0) :type)) (assert= 3.14 (get (nth tokens 0) :value)))) (deftest "tokenizes list brackets" (let ((tokens (gql-tokenize "[1, 2, 3]"))) (assert= "bracket-open" (get (nth tokens 0) :type)) (assert= "number" (get (nth tokens 1) :type)) (assert= "bracket-close" (get (nth tokens 4) :type))))) ;; ── Parser: shorthand queries ───────────────────────────────────── (defsuite "gql-parse-shorthand" (deftest "parses shorthand query" (let ((doc (gql-parse "{ user { name } }"))) (assert= (quote gql-doc) (first doc)) (let ((op (nth doc 1))) (assert= (quote gql-query) (first op)) (assert= nil (gql-op-name op)) (assert= 1 (length (gql-op-selections op))) (let ((field (first (gql-op-selections op)))) (assert= (quote gql-field) (first field)) (assert= "user" (gql-field-name field)) (assert= 1 (length (gql-field-selections field))) (assert= "name" (gql-field-name (first (gql-field-selections field)))))))) (deftest "parses multiple root fields" (let ((doc (gql-parse "{ user { name } posts { title } }"))) (let ((sels (gql-op-selections (nth doc 1)))) (assert= 2 (length sels)) (assert= "user" (gql-field-name (first sels))) (assert= "posts" (gql-field-name (nth sels 1)))))) (deftest "parses scalar field without sub-selections" (let ((doc (gql-parse "{ name email }"))) (let ((sels (gql-op-selections (nth doc 1)))) (assert= 2 (length sels)) (assert= "name" (gql-field-name (first sels))) (assert= "email" (gql-field-name (nth sels 1))) (assert= 0 (length (gql-field-selections (first sels)))))))) ;; ── Parser: named operations ────────────────────────────────────── (defsuite "gql-parse-operations" (deftest "parses named query" (let ((doc (gql-parse "query GetUser { user { name } }"))) (let ((op (nth doc 1))) (assert= (quote gql-query) (first op)) (assert= "GetUser" (gql-op-name op))))) (deftest "parses mutation" (let ((doc (gql-parse "mutation CreatePost { createPost(title: \"Hello\") { id } }"))) (let ((op (nth doc 1))) (assert= (quote gql-mutation) (first op)) (assert= "CreatePost" (gql-op-name op)) (let ((field (first (gql-op-selections op)))) (assert= "createPost" (gql-field-name field)) (assert= 1 (length (gql-field-args field))) (assert= "title" (first (first (gql-field-args field)))) (assert= "Hello" (nth (first (gql-field-args field)) 1)))))) (deftest "parses subscription" (let ((doc (gql-parse "subscription OnMessage { messageAdded { text } }"))) (let ((op (nth doc 1))) (assert= (quote gql-subscription) (first op)) (assert= "OnMessage" (gql-op-name op))))) (deftest "parses anonymous query keyword" (let ((doc (gql-parse "query { user { name } }"))) (let ((op (nth doc 1))) (assert= (quote gql-query) (first op)) (assert= nil (gql-op-name op)))))) ;; ── Parser: arguments ───────────────────────────────────────────── (defsuite "gql-parse-arguments" (deftest "parses integer argument" (let ((doc (gql-parse "{ user(id: 42) { name } }"))) (let ((field (first (gql-op-selections (nth doc 1))))) (assert= 1 (length (gql-field-args field))) (assert= "id" (first (first (gql-field-args field)))) (assert= 42 (nth (first (gql-field-args field)) 1))))) (deftest "parses string argument" (let ((doc (gql-parse "{ post(slug: \"hello-world\") { title } }"))) (let ((field (first (gql-op-selections (nth doc 1))))) (assert= "slug" (first (first (gql-field-args field)))) (assert= "hello-world" (nth (first (gql-field-args field)) 1))))) (deftest "parses boolean arguments" (let ((doc (gql-parse "{ posts(published: true) { title } }"))) (let ((field (first (gql-op-selections (nth doc 1))))) (assert= true (nth (first (gql-field-args field)) 1))))) (deftest "parses null argument" (let ((doc (gql-parse "{ posts(cursor: null) { title } }"))) (let ((field (first (gql-op-selections (nth doc 1))))) (assert= nil (nth (first (gql-field-args field)) 1))))) (deftest "parses multiple arguments" (let ((doc (gql-parse "{ posts(limit: 10, offset: 20) { title } }"))) (let ((field (first (gql-op-selections (nth doc 1))))) (assert= 2 (length (gql-field-args field))) (assert= "limit" (first (first (gql-field-args field)))) (assert= 10 (nth (first (gql-field-args field)) 1)) (assert= "offset" (first (nth (gql-field-args field) 1))) (assert= 20 (nth (nth (gql-field-args field) 1) 1))))) (deftest "parses enum argument" (let ((doc (gql-parse "{ posts(status: PUBLISHED) { title } }"))) (let ((field (first (gql-op-selections (nth doc 1))))) (assert= "PUBLISHED" (nth (first (gql-field-args field)) 1))))) (deftest "parses list argument" (let ((doc (gql-parse "{ posts(ids: [1, 2, 3]) { title } }"))) (let ((field (first (gql-op-selections (nth doc 1))))) (let ((val (nth (first (gql-field-args field)) 1))) (assert= 3 (length val)) (assert= 1 (first val)) (assert= 3 (nth val 2)))))) (deftest "parses object argument" (let ((doc (gql-parse "{ createPost(input: {title: \"Hi\", draft: true}) { id } }"))) (let ((field (first (gql-op-selections (nth doc 1))))) (let ((val (nth (first (gql-field-args field)) 1))) (assert= "Hi" (get val :title)) (assert= true (get val :draft))))))) ;; ── Parser: variables ───────────────────────────────────────────── (defsuite "gql-parse-variables" (deftest "parses variable definitions" (let ((doc (gql-parse "query GetUser($id: ID!) { user(id: $id) { name } }"))) (let ((op (nth doc 1))) (assert= 1 (length (gql-op-vars op))) (let ((vdef (first (gql-op-vars op)))) (assert= (quote gql-var-def) (first vdef)) (assert= "id" (nth vdef 1)) (assert= (quote gql-non-null) (first (nth vdef 2))))))) (deftest "parses variable reference in arguments" (let ((doc (gql-parse "query($id: Int) { user(id: $id) { name } }"))) (let ((field (first (gql-op-selections (nth doc 1))))) (let ((arg-val (nth (first (gql-field-args field)) 1))) (assert= (quote gql-var) (first arg-val)) (assert= "id" (nth arg-val 1)))))) (deftest "parses multiple variable definitions" (let ((doc (gql-parse "query($limit: Int = 10, $offset: Int) { posts { title } }"))) (let ((vars (gql-op-vars (nth doc 1)))) (assert= 2 (length vars)) (assert= "limit" (nth (first vars) 1)) (assert= 10 (nth (first vars) 3)) (assert= "offset" (nth (nth vars 1) 1)) (assert= nil (nth (nth vars 1) 3))))) (deftest "parses list type in variable" (let ((doc (gql-parse "query($ids: [Int!]!) { posts(ids: $ids) { title } }"))) (let ((vdef (first (gql-op-vars (nth doc 1))))) (let ((typ (nth vdef 2))) (assert= (quote gql-non-null) (first typ)) (assert= (quote gql-list-type) (first (nth typ 1)))))))) ;; ── Parser: fragments ───────────────────────────────────────────── (defsuite "gql-parse-fragments" (deftest "parses fragment definition" (let ((doc (gql-parse "fragment UserFields on User { name email }"))) (let ((frag (nth doc 1))) (assert= (quote gql-fragment) (first frag)) (assert= "UserFields" (gql-frag-name frag)) (assert= "User" (gql-frag-type frag)) (assert= 2 (length (gql-frag-selections frag)))))) (deftest "parses fragment spread" (let ((doc (gql-parse "{ user { ...UserFields } }"))) (let ((field (first (gql-op-selections (nth doc 1))))) (let ((spread (first (gql-field-selections field)))) (assert= (quote gql-fragment-spread) (first spread)) (assert= "UserFields" (nth spread 1)))))) (deftest "parses inline fragment" (let ((doc (gql-parse "{ node { ... on User { name } ... on Post { title } } }"))) (let ((sels (gql-field-selections (first (gql-op-selections (nth doc 1)))))) (assert= 2 (length sels)) (assert= (quote gql-inline-fragment) (first (first sels))) (assert= "User" (nth (first sels) 1)) (assert= "Post" (nth (nth sels 1) 1))))) (deftest "parses multi-definition document" (let ((doc (gql-parse "query GetUser { user { ...UserFields } } fragment UserFields on User { name email }"))) (assert= 3 (length doc)) (assert= (quote gql-query) (first (nth doc 1))) (assert= (quote gql-fragment) (first (nth doc 2)))))) ;; ── Parser: directives ──────────────────────────────────────────── (defsuite "gql-parse-directives" (deftest "parses field directive" (let ((doc (gql-parse "{ user { name @uppercase } }"))) (let ((field (first (gql-field-selections (first (gql-op-selections (nth doc 1))))))) (assert= 1 (length (gql-field-directives field))) (let ((dir (first (gql-field-directives field)))) (assert= (quote gql-directive) (first dir)) (assert= "uppercase" (nth dir 1)))))) (deftest "parses directive with arguments" (let ((doc (gql-parse "{ user { avatar @resize(width: 100, height: 100) } }"))) (let ((field (first (gql-field-selections (first (gql-op-selections (nth doc 1))))))) (let ((dir (first (gql-field-directives field)))) (assert= "resize" (nth dir 1)) (assert= 2 (length (nth dir 2)))))))) ;; ── Parser: aliases ─────────────────────────────────────────────── (defsuite "gql-parse-aliases" (deftest "parses field alias" (let ((doc (gql-parse "{ smallPic: profilePic(size: 64) { url } }"))) (let ((field (first (gql-op-selections (nth doc 1))))) (assert= "profilePic" (gql-field-name field)) (assert= "smallPic" (gql-field-alias field))))) (deftest "parses multiple aliased fields" (let ((doc (gql-parse "{ small: pic(size: 64) { url } large: pic(size: 256) { url } }"))) (let ((sels (gql-op-selections (nth doc 1)))) (assert= 2 (length sels)) (assert= "small" (gql-field-alias (first sels))) (assert= "large" (gql-field-alias (nth sels 1))) (assert= "pic" (gql-field-name (first sels))) (assert= "pic" (gql-field-name (nth sels 1))))))) ;; ── Serializer ──────────────────────────────────────────────────── (defsuite "gql-serialize" (deftest "round-trips shorthand query" (let ((src "query { user { name email } }")) (let ((doc (gql-parse src)) (out (gql-serialize doc))) (let ((doc2 (gql-parse out))) (assert= (quote gql-query) (first (nth doc2 1))) (let ((sels (gql-op-selections (nth doc2 1)))) (assert= 1 (length sels)) (assert= "user" (gql-field-name (first sels)))))))) (deftest "serializes mutation" (let ((doc (gql-parse "mutation { createPost(title: \"Hi\") { id } }"))) (let ((out (gql-serialize doc))) (assert (string-contains? out "mutation")) (assert (string-contains? out "createPost")))))) ;; ── Executor: projection ────────────────────────────────────────── (defsuite "gql-exec-projection" (deftest "projects scalar fields" (let ((data {:internal-id 999 :email "alice@test.com" :age 30 :name "Alice"}) (sels (list (list (quote gql-field) "name" (list) (list) (list)) (list (quote gql-field) "email" (list) (list) (list))))) (let ((result (gql-project data sels {}))) (assert= "Alice" (get result :name)) (assert= "alice@test.com" (get result :email)) (assert= nil (get result :age)) (assert= nil (get result :internal-id))))) (deftest "projects nested fields" (let ((data {:user {:secret "hidden" :email "bob@test.com" :name "Bob"}}) (sels (list (list (quote gql-field) "user" (list) (list) (list (list (quote gql-field) "name" (list) (list) (list))))))) (let ((result (gql-project data sels {}))) (assert= "Bob" (get (get result :user) :name)) (assert= nil (get (get result :user) :email))))) (deftest "projects over list results" (let ((data (list {:x 1 :name "A"} {:x 2 :name "B"})) (sels (list (list (quote gql-field) "name" (list) (list) (list))))) (let ((result (gql-project data sels {}))) (assert= 2 (length result)) (assert= "A" (get (first result) :name)) (assert= nil (get (first result) :x))))) (deftest "handles nil data" (assert= nil (gql-project nil (list) {}))) (deftest "empty selections returns full data" (let ((data {:age 30 :name "Alice"})) (assert= data (gql-project data (list) {}))))) ;; ── Executor: full execution ────────────────────────────────────── (defsuite "gql-exec-execute" (deftest "executes with custom resolver" (let ((doc (gql-parse "{ user(id: 1) { name email } }")) (resolver (fn (field-name args op-type) (if (= field-name "user") {:email "alice@test.com" :age 30 :name "Alice"} nil)))) (let ((result (gql-execute doc {} resolver))) (assert= "Alice" (get (get (get result :data) :user) :name)) (assert= "alice@test.com" (get (get (get result :data) :user) :email)) (assert= nil (get (get (get result :data) :user) :age))))) (deftest "passes arguments to resolver" (let ((doc (gql-parse "{ post(slug: \"hello\") { title } }")) (resolver (fn (field-name args op-type) (if (and (= field-name "post") (= (get args :slug) "hello")) {:title "Hello World"} {:title "Not Found"})))) (let ((result (gql-execute doc {} resolver))) (assert= "Hello World" (get (get (get result :data) :post) :title))))) (deftest "reports operation type to resolver" (let ((doc (gql-parse "mutation { deletePost(id: 1) { success } }")) (seen-type nil) (resolver (fn (field-name args op-type) (set! seen-type op-type) {:success true}))) (gql-execute doc {} resolver) (assert= (quote gql-mutation) seen-type))) (deftest "substitutes variables" (let ((doc (gql-parse "query($id: Int) { user(id: $id) { name } }")) (resolver (fn (field-name args op-type) {:name (str "User-" (get args :id))}))) (let ((result (gql-execute doc {:id 42} resolver))) (assert= "User-42" (get (get (get result :data) :user) :name))))) (deftest "handles multiple root fields" (let ((doc (gql-parse "{ user { name } settings { theme } }")) (resolver (fn (field-name args op-type) (cond ((= field-name "user") {:name "Alice"}) ((= field-name "settings") {:theme "dark"}) (true nil))))) (let ((result (gql-execute doc {} resolver))) (assert= "Alice" (get (get (get result :data) :user) :name)) (assert= "dark" (get (get (get result :data) :settings) :theme))))) (deftest "handles aliased fields" (let ((doc (gql-parse "{ me: user(id: 1) { name } them: user(id: 2) { name } }")) (resolver (fn (field-name args op-type) {:name (str "User-" (get args :id))}))) (let ((result (gql-execute doc {} resolver))) (assert= "User-1" (get (get (get result :data) :me) :name)) (assert= "User-2" (get (get (get result :data) :them) :name))))) (deftest "returns error when no operation" (let ((doc (gql-parse "fragment F on User { name }"))) (let ((result (gql-execute doc {} (fn (f a t) nil)))) (assert (not (nil? (get result :errors)))))))) ;; ── Executor: fragments ─────────────────────────────────────────── (defsuite "gql-exec-fragments" (deftest "resolves fragment spread in projection" (let ((fragments {:UserFields (list (quote gql-fragment) "UserFields" "User" (list) (list (list (quote gql-field) "name" (list) (list) (list)) (list (quote gql-field) "email" (list) (list) (list))))}) (data {:email "a@test.com" :age 30 :name "Alice"}) (sels (list (list (quote gql-fragment-spread) "UserFields" (list))))) (let ((result (gql-project data sels fragments))) (assert= "Alice" (get result :name)) (assert= "a@test.com" (get result :email)) (assert= nil (get result :age))))) (deftest "collects fragments from document" (let ((doc (gql-parse "query { user { ...F } } fragment F on User { name }"))) (let ((frags (gql-collect-fragments doc))) (assert (not (nil? (get frags :F)))) (assert= "User" (gql-frag-type (get frags :F))))))) ;; ── Executor: introspection helpers ─────────────────────────────── (defsuite "gql-exec-introspection" (deftest "extracts operation names" (let ((doc (gql-parse "query GetUser { user { name } } mutation CreatePost { create { id } }"))) (let ((names (gql-operation-names doc))) (assert= 2 (length names)) (assert (some (fn (n) (= n "GetUser")) names)) (assert (some (fn (n) (= n "CreatePost")) names))))) (deftest "extracts variable definitions" (let ((doc (gql-parse "query($id: ID!, $name: String) { user { name } }"))) (let ((vars (gql-extract-variables doc))) (assert= 2 (length vars)) (assert= "id" (nth (first vars) 1)) (assert= "name" (nth (nth vars 1) 1)))))) ;; ── Hyperscript integration: parser ─────────────────────────────── (defsuite "hs-fetch-gql-parse" (deftest "fetch gql with braces parses to fetch-gql AST" (let ((ast (hs-compile "fetch gql { user { name } }"))) (assert= (quote fetch-gql) (first ast)) (assert (string-contains? (nth ast 1) "user")) (assert (string-contains? (nth ast 1) "name")) (assert= nil (nth ast 2)))) (deftest "fetch gql with from URL" (let ((ast (hs-compile "fetch gql { posts { title } } from \"/api/graphql\""))) (assert= (quote fetch-gql) (first ast)) (assert (string-contains? (nth ast 1) "posts")) (assert= "/api/graphql" (nth ast 2)))) (deftest "fetch gql query keyword" (let ((ast (hs-compile "fetch gql query { user { name } }"))) (assert= (quote fetch-gql) (first ast)) (assert (string-contains? (nth ast 1) "query")) (assert (string-contains? (nth ast 1) "user")))) (deftest "fetch gql mutation keyword" (let ((ast (hs-compile "fetch gql mutation { createPost { id } }"))) (assert= (quote fetch-gql) (first ast)) (assert (string-contains? (nth ast 1) "mutation")) (assert (string-contains? (nth ast 1) "createPost")))) (deftest "fetch gql with nested braces" (let ((ast (hs-compile "fetch gql { user(id: 1) { posts { title } } }"))) (assert= (quote fetch-gql) (first ast)) (assert (string-contains? (nth ast 1) "posts")) (assert (string-contains? (nth ast 1) "title")))) (deftest "regular fetch still works" (let ((ast (hs-compile "fetch \"/api/data\" as json"))) (assert= (quote fetch) (first ast)) (assert= "/api/data" (nth ast 1)) (assert= "json" (nth ast 2))))) ;; ── Hyperscript integration: compiler ───────────────────────────── (defsuite "hs-fetch-gql-compile" (deftest "compiles fetch gql to hs-fetch-gql call" (let ((sx (hs-to-sx-from-source "fetch gql { user { name } }"))) (assert= (quote hs-fetch-gql) (first sx)) (assert (string? (nth sx 1))) (assert= nil (nth sx 2)))) (deftest "compiles fetch gql with from URL" (let ((sx (hs-to-sx-from-source "fetch gql { posts { title } } from \"/api\""))) (assert= (quote hs-fetch-gql) (first sx)) (assert= "/api" (nth sx 2)))))