GraphQL: query/mutation/fragments/vars/executor + parser spec + tests

New graphql application. 676-line test-graphql.sx covers parser, executor,
fetch-gql integration. lib/graphql.sx (686L) is the core parser/AST;
lib/graphql-exec.sx (219L) runs resolvers. applications/graphql/spec.sx
declares the application. sx/sx/applications/graphql/ provides the doc
pages (parser, queries, mutation, fragments, vars, fetch-gql, executor).

Includes rebuilt sx_browser.bc.js / sx_browser.bc.wasm.js bundles.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-04-22 09:08:00 +00:00
parent dd604f2bb1
commit fc24cc704d
13 changed files with 2574 additions and 129 deletions

View File

@@ -0,0 +1,91 @@
;; GraphQL: executor — live execute against /api.execute-demo
(defcomp
()
(~docs/page
:title "GraphQL · Executor"
(p
(~tw :tokens "text-lg text-gray-600 mb-2")
"The executor in "
(code "lib/graphql-exec.sx")
" walks the parsed AST, dispatches root fields to a resolver, "
"and projects each result down to the selected fields.")
(p
(~tw :tokens "text-gray-500 mb-6")
"The endpoint below uses a static seed dataset (users, posts, comments) "
"and a resolver written in plain SX. The same bytecode runs the docs site test suite.")
(~docs/section
:title "Seed data"
:id "seed"
(p
(~tw :tokens "text-gray-600 mb-3")
"Three tables, hardcoded in the handler file. No database required — "
"every query and mutation hits this in-memory store.")
(~docs/code
:src "(define gql-seed-users\n (list\n {:id 1 :name \"Alice\" :email \"alice@test.com\" :role \"admin\"}\n {:id 2 :name \"Bob\" :email \"bob@test.com\" :role \"user\"}\n {:id 3 :name \"Carol\" :email \"carol@test.com\" :role \"user\"}))\n\n(define gql-seed-posts\n (list\n {:id 101 :authorId 1 :title \"Hello, world\" :body \"First post in SX GraphQL.\"}\n {:id 102 :authorId 2 :title \"GraphQL in SX\" :body \"Every op compiles to bytecode.\"}\n {:id 103 :authorId 1 :title \"Quiet night\" :body \"Just thinking about parsers.\"}))\n\n(define gql-seed-comments\n (list\n {:id 1001 :postId 101 :authorId 2 :text \"Nice one.\"}\n {:id 1002 :postId 102 :authorId 3 :text \"Compiled to bytecode?!\"}\n {:id 1003 :postId 102 :authorId 1 :text \"Yes — every op is CEK.\"}))"))
(~docs/section
:title "Resolver"
:id "resolver"
(p
(~tw :tokens "text-gray-600 mb-3")
"The resolver receives "
(code "(field-name args-dict op-type)")
" per root field. Return a scalar, a dict, or a list of dicts — "
"the executor projects it down to the requested selection set.")
(~docs/code
:src "(define gql-seed-resolve\n (fn (field-name args op-type)\n (cond\n ((= field-name \"user\")\n (gql-find-by-id gql-seed-users (get args :id)))\n ((= field-name \"users\") gql-seed-users)\n ((= field-name \"post\")\n (gql-find-by-id gql-seed-posts (get args :id)))\n ((= field-name \"posts\")\n (let ((author-id (get args :authorId)))\n (if author-id\n (gql-filter-by gql-seed-posts :authorId author-id)\n gql-seed-posts)))\n ((= field-name \"comments\")\n (let ((post-id (get args :postId)))\n (if post-id\n (gql-filter-by gql-seed-comments :postId post-id)\n gql-seed-comments)))\n ((= field-name \"echo\") (get args :message))\n ((= field-name \"createPost\")\n {:id 999\n :title (get args :title)\n :body (get args :body)\n :authorId (get args :authorId)})\n ((= field-name \"likePost\")\n {:id (get args :id) :liked true})\n (true nil))))"))
(~docs/section
:title "Live endpoint"
:id "endpoint"
(p
(~tw :tokens "text-gray-600 mb-3")
"POST "
(code "/sx/(applications.(graphql.(api.execute-demo)))")
" with form fields "
(code "query")
" and optional "
(code "variables")
" (an SX dict literal like "
(code "{:id 1}")
").")
(~docs/code
:src "(defhandler\n gql-execute-demo\n :path \"/sx/(applications.(graphql.(api.execute-demo)))\"\n :method :post\n :csrf false\n :returns \"text\"\n (&key query variables)\n (let ((doc (gql-parse query))\n (vars-dict (gql-parse-vars variables)))\n (let ((result (gql-execute doc vars-dict gql-seed-resolve)))\n (str\n \"<pre>\" (escape (sx-serialize vars-dict)) \"</pre>\"\n \"<pre>\" (escape (sx-serialize result)) \"</pre>\")))))"))
(~docs/section
:title "Try it"
:id "try"
(p
(~tw :tokens "text-gray-600 mb-3")
"The query is sent straight to the handler; the result is projected to your selection set.")
(form
(~tw :tokens "space-y-3")
:hx-post "/sx/(applications.(graphql.(api.execute-demo)))"
:hx-target "#exec-out"
:hx-swap "innerHTML"
(label
(~tw :tokens "block text-sm font-medium text-gray-700")
"Query")
(textarea
:name "query"
:rows "7"
(~tw
:tokens "w-full font-mono text-sm p-3 border border-gray-300 rounded-lg")
"query Q($id: ID!) {\n post(id: $id) {\n title\n body\n authorId\n }\n}")
(label
(~tw :tokens "block text-sm font-medium text-gray-700")
"Variables")
(textarea
:name "variables"
:rows "2"
(~tw
:tokens "w-full font-mono text-sm p-3 border border-gray-300 rounded-lg")
"{:id 102}")
(button
:type "submit"
(~tw
:tokens "px-4 py-2 bg-violet-600 text-white rounded hover:bg-violet-700")
"Execute"))
(div
:id "exec-out"
(~tw :tokens "mt-4")
(span
(~tw :tokens "text-gray-400 text-sm")
"Variables + result will appear here.")))))

View File

@@ -0,0 +1,64 @@
;; GraphQL: hyperscript integration — fetch gql command
(defcomp
()
(~docs/page
:title "GraphQL · fetch gql"
(p
(~tw :tokens "text-lg text-gray-600 mb-2")
"The "
(code "fetch gql")
" hyperscript command embeds GraphQL queries directly in attribute syntax. "
"The hyperscript parser collects the GraphQL body between "
(code "{")
" and "
(code "}")
" and the compiler emits a "
(code "hs-fetch-gql")
" call.")
(p
(~tw :tokens "text-gray-500 mb-6")
"Compilation happens server-side at page boot; the runtime dispatch "
"hits the same "
(code "/api.execute-demo")
" endpoint these pages use.")
(~docs/section
:title "Shorthand query"
:id "shorthand"
(~docs/code
:src "<button _=\"on click\n fetch gql { users { name role } }\n put result.data.users into #fetch-out\">\n Fetch users\n</button>"))
(~docs/section
:title "Named operation"
:id "named"
(~docs/code
:src "<input _=\"on input\n fetch gql query Search($q: String!) { posts(authorId: 1) { title } } with vars\n put result.data.posts into #search-out\">"))
(~docs/section
:title "Mutation"
:id "mutation"
(~docs/code
:src "<form _=\"on submit\n fetch gql mutation { createPost(title: 'Hi', body: 'body', authorId: 1) { id } }\n put it into #create-out\">"))
(~docs/section
:title "Custom endpoint"
:id "from"
(p
(~tw :tokens "text-gray-600 mb-3")
"Override the default "
(code "/graphql")
" path per-call with "
(code "from"))
(~docs/code
:src "<button _=\"on click\n fetch gql { users { name } } from '/sx/(applications.(graphql.(api.execute-demo)))'\n put result into #users-out\">"))
(~docs/section
:title "Compilation pipeline"
:id "pipeline"
(~docs/note
(p
(strong "Same bytecode path. ")
"The hyperscript tokenizer, parser, compiler, and bytecode runtime "
"all live in "
(code "lib/hyperscript/")
". The GraphQL parser and executor "
"live in "
(code "lib/graphql.sx")
" and "
(code "lib/graphql-exec.sx")
". Every piece compiles to the same kernel.")))))

View File

@@ -0,0 +1,70 @@
;; GraphQL: fragments — live fragment spreads against /api.execute-demo
(defcomp
()
(~docs/page
:title "GraphQL · Fragments"
(p
(~tw :tokens "text-lg text-gray-600 mb-6")
"Fragment definitions collect reusable field sets. Spreads inline them during projection. "
"The executor applies them unconditionally — type checking is available in "
(code "spec/types.sx")
" but not required here.")
(~docs/section
:title "Named fragment"
:id "named"
(p
(~tw :tokens "text-gray-600 mb-3")
"Define "
(code "UserFields")
" once, use it wherever a "
(code "User")
" appears.")
(~docs/code
:src "query {\n alice: user(id: 1) { ...UserFields }\n bob: user(id: 2) { ...UserFields }\n}\n\nfragment UserFields on User {\n name\n email\n role\n}")
(form
:hx-post "/sx/(applications.(graphql.(api.execute-demo)))"
:hx-target "#frag-named-out"
:hx-swap "innerHTML"
(input
:type "hidden"
:name "query"
:value "query { alice: user(id: 1) { ...UserFields } bob: user(id: 2) { ...UserFields } } fragment UserFields on User { name email role }")
(button
:type "submit"
(~tw
:tokens "px-4 py-2 bg-violet-600 text-white rounded hover:bg-violet-700")
"Run"))
(div
:id "frag-named-out"
(~tw :tokens "mt-4")
(span
(~tw :tokens "text-gray-400 text-sm")
"Result will appear here.")))
(~docs/section
:title "Inline fragment"
:id "inline"
(p
(~tw :tokens "text-gray-600 mb-3")
"Inline fragments work without a name — the executor treats them the same way "
"and projects their selection set into the parent.")
(~docs/code
:src "{\n post(id: 101) {\n title\n ... on Post {\n authorId\n body\n }\n }\n}")
(form
:hx-post "/sx/(applications.(graphql.(api.execute-demo)))"
:hx-target "#frag-inline-out"
:hx-swap "innerHTML"
(input
:type "hidden"
:name "query"
:value "{ post(id: 101) { title ... on Post { authorId body } } }")
(button
:type "submit"
(~tw
:tokens "px-4 py-2 bg-violet-600 text-white rounded hover:bg-violet-700")
"Run"))
(div
:id "frag-inline-out"
(~tw :tokens "mt-4")
(span
(~tw :tokens "text-gray-400 text-sm")
"Result will appear here.")))))

View File

@@ -0,0 +1,78 @@
;; GraphQL: mutation — live createPost, likePost
(defcomp
()
(~docs/page
:title "GraphQL · Mutations"
(p
(~tw :tokens "text-lg text-gray-600 mb-6")
"Mutations reach the resolver with "
(code "op-type")
" = "
(code "'gql-mutation")
". The demo resolver returns a synthesized record. "
"In production SX, mutations map to "
(code "defaction")
" via IO suspension.")
(~docs/section
:title "createPost"
:id "create"
(p
(~tw :tokens "text-gray-600 mb-3")
"Typed variables let the client pass structured input without inlining strings.")
(~docs/code
:src "mutation NewPost($title: String!, $body: String!, $authorId: ID!) {\n createPost(title: $title, body: $body, authorId: $authorId) {\n id\n title\n authorId\n }\n}")
(form
(~tw :tokens "space-y-3")
:hx-post "/sx/(applications.(graphql.(api.execute-demo)))"
:hx-target "#mut-create-out"
:hx-swap "innerHTML"
(input
:type "hidden"
:name "query"
:value "mutation NewPost($title: String!, $body: String!, $authorId: ID!) { createPost(title: $title, body: $body, authorId: $authorId) { id title authorId } }")
(label
(~tw :tokens "block text-sm font-medium text-gray-700")
"Variables")
(textarea
:name "variables"
:rows "3"
(~tw
:tokens "w-full font-mono text-sm p-3 border border-gray-300 rounded-lg")
"{:title \"Fresh take\" :body \"Bytecode FTW\" :authorId 1}")
(button
:type "submit"
(~tw
:tokens "px-4 py-2 bg-violet-600 text-white rounded hover:bg-violet-700")
"Run mutation"))
(div
:id "mut-create-out"
(~tw :tokens "mt-4")
(span
(~tw :tokens "text-gray-400 text-sm")
"Response will appear here.")))
(~docs/section
:title "likePost"
:id "like"
(p
(~tw :tokens "text-gray-600 mb-3")
"A minimal mutation with a single argument.")
(~docs/code :src "mutation { likePost(id: 102) { id liked } }")
(form
:hx-post "/sx/(applications.(graphql.(api.execute-demo)))"
:hx-target "#mut-like-out"
:hx-swap "innerHTML"
(input
:type "hidden"
:name "query"
:value "mutation { likePost(id: 102) { id liked } }")
(button
:type "submit"
(~tw
:tokens "px-4 py-2 bg-violet-600 text-white rounded hover:bg-violet-700")
"Run"))
(div
:id "mut-like-out"
(~tw :tokens "mt-4")
(span
(~tw :tokens "text-gray-400 text-sm")
"Response will appear here.")))))

View File

@@ -0,0 +1,64 @@
;; GraphQL: parser — live parse against /api.parse-demo
(defcomp
()
(~docs/page
:title "GraphQL · Parser"
(p
(~tw :tokens "text-lg text-gray-600 mb-2")
"The tokenizer and recursive-descent parser live in "
(code "lib/graphql.sx")
". Feed them any GraphQL source and get an s-expression AST.")
(p
(~tw :tokens "text-gray-500 mb-6")
"The server endpoint below parses the query, pretty-prints the AST, "
"and round-trips it back through "
(code "gql-serialize")
".")
(~docs/section
:title "Server endpoint"
:id "endpoint"
(p
(~tw :tokens "text-gray-600 mb-3")
"This is the live handler. It's the same bytecode the test suite exercises.")
(~docs/code
:src "(defhandler\n gql-parse-demo\n :path \"/sx/(applications.(graphql.(api.parse-demo)))\"\n :method :get\n :returns \"text\"\n (&key source)\n (if (or (nil? source) (empty? source))\n \"<p>Enter a GraphQL query and click Parse.</p>\"\n (let ((doc (gql-parse source)))\n (str\n \"<pre>\" (escape (sx-serialize doc)) \"</pre>\"\n \"<pre>\" (escape (gql-serialize doc)) \"</pre>\")))))"))
(~docs/section
:title "Try it"
:id "try"
(p
(~tw :tokens "text-gray-600 mb-3")
"Type a query, click Parse. The form posts via HTMX to the handler above — "
"response drops straight into "
(code "#parse-out")
".")
(form
(~tw :tokens "space-y-3")
:hx-get "/sx/(applications.(graphql.(api.parse-demo)))"
:hx-target "#parse-out"
:hx-swap "innerHTML"
:hx-trigger "submit, input from:textarea[name='source'] changed delay:400ms"
(textarea
:name "source"
:rows "6"
(~tw
:tokens "w-full font-mono text-sm p-3 border border-gray-300 rounded-lg")
"{\n user(id: 1) {\n name\n email\n }\n}")
(button
:type "submit"
(~tw
:tokens "px-4 py-2 bg-violet-600 text-white rounded hover:bg-violet-700")
"Parse"))
(div
:id "parse-out"
(~tw :tokens "mt-4")
(span
(~tw :tokens "text-gray-400 text-sm")
"Output will appear here.")))
(~docs/section
:title "AST node types"
:id "nodes"
(p
(~tw :tokens "text-gray-600 mb-3")
"Every GraphQL concept becomes a named s-expression:")
(~docs/code
:src "(gql-doc definitions...)\n(gql-query name vars directives selections)\n(gql-mutation name vars directives selections)\n(gql-subscription name vars directives selections)\n(gql-field name args directives selections [alias])\n(gql-fragment name on-type directives selections)\n(gql-fragment-spread name directives)\n(gql-inline-fragment on-type directives selections)\n(gql-var name)\n(gql-var-def name type default)\n(gql-directive name args)"))))

View File

@@ -0,0 +1,113 @@
;; GraphQL: query — three live queries against /api.execute-demo
(defcomp
()
(~docs/page
:title "GraphQL · Queries"
(p
(~tw :tokens "text-lg text-gray-600 mb-6")
"Three live queries. Each button posts a canned query to "
(code "/api.execute-demo")
" and drops the raw SX result into the output below.")
(~docs/section
:title "Single record"
:id "single"
(p
(~tw :tokens "text-gray-600 mb-3")
"Fetch one user by id. The executor projects the seed row to just the selected fields.")
(~docs/code :src "{\n user(id: 1) {\n name\n role\n }\n}")
(form
:hx-post "/sx/(applications.(graphql.(api.execute-demo)))"
:hx-target "#q-single-out"
:hx-swap "innerHTML"
(input
:type "hidden"
:name "query"
:value "{ user(id: 1) { name role } }")
(button
:type "submit"
(~tw
:tokens "px-4 py-2 bg-violet-600 text-white rounded hover:bg-violet-700")
"Run"))
(div
:id "q-single-out"
(~tw :tokens "mt-4")
(span (~tw :tokens "text-gray-400 text-sm") "Click Run to execute.")))
(~docs/section
:title "List"
:id "list"
(p
(~tw :tokens "text-gray-600 mb-3")
"Fetch all posts. The resolver returns a list of dicts; "
"the executor maps projection over each element.")
(~docs/code :src "{\n posts {\n id\n title\n authorId\n }\n}")
(form
:hx-post "/sx/(applications.(graphql.(api.execute-demo)))"
:hx-target "#q-list-out"
:hx-swap "innerHTML"
(input
:type "hidden"
:name "query"
:value "{ posts { id title authorId } }")
(button
:type "submit"
(~tw
:tokens "px-4 py-2 bg-violet-600 text-white rounded hover:bg-violet-700")
"Run"))
(div
:id "q-list-out"
(~tw :tokens "mt-4")
(span (~tw :tokens "text-gray-400 text-sm") "Click Run to execute.")))
(~docs/section
:title "Filtered list"
:id "filter"
(p
(~tw :tokens "text-gray-600 mb-3")
"Arguments reach the resolver via the "
(code "args")
" dict. "
"Here, "
(code "authorId: 1")
" narrows to posts by Alice.")
(~docs/code :src "{\n posts(authorId: 1) {\n title\n body\n }\n}")
(form
:hx-post "/sx/(applications.(graphql.(api.execute-demo)))"
:hx-target "#q-filter-out"
:hx-swap "innerHTML"
(input
:type "hidden"
:name "query"
:value "{ posts(authorId: 1) { title body } }")
(button
:type "submit"
(~tw
:tokens "px-4 py-2 bg-violet-600 text-white rounded hover:bg-violet-700")
"Run"))
(div
:id "q-filter-out"
(~tw :tokens "mt-4")
(span (~tw :tokens "text-gray-400 text-sm") "Click Run to execute.")))
(~docs/section
:title "Multiple root fields"
:id "multi"
(p
(~tw :tokens "text-gray-600 mb-3")
"A single query can dispatch many root fields. "
"The executor resolves each independently and merges the results.")
(~docs/code :src "{\n users { name }\n posts { title }\n}")
(form
:hx-post "/sx/(applications.(graphql.(api.execute-demo)))"
:hx-target "#q-multi-out"
:hx-swap "innerHTML"
(input
:type "hidden"
:name "query"
:value "{ users { name } posts { title } }")
(button
:type "submit"
(~tw
:tokens "px-4 py-2 bg-violet-600 text-white rounded hover:bg-violet-700")
"Run"))
(div
:id "q-multi-out"
(~tw :tokens "mt-4")
(span (~tw :tokens "text-gray-400 text-sm") "Click Run to execute.")))))

View File

@@ -0,0 +1,100 @@
;; GraphQL: variables — live variable substitution
(defcomp
()
(~docs/page
:title "GraphQL · Variables"
(p
(~tw :tokens "text-lg text-gray-600 mb-6")
"Variable references become "
(code "(gql-var name)")
" nodes. "
"At execution, the executor walks the AST and substitutes bound values. "
"The demo accepts an SX dict literal in the "
(code "variables")
" form field.")
(~docs/section
:title "Scalar variable"
:id "scalar"
(p
(~tw :tokens "text-gray-600 mb-3")
"One typed variable, used in a field argument. "
"Change the dict on the right and click Run — the resolver receives the new value.")
(~docs/code
:src "query ByAuthor($author: Int!) {\n posts(authorId: $author) {\n title\n body\n }\n}\n\n# Variables (SX dict literal):\n# {:author 2}")
(form
(~tw :tokens "space-y-3")
:hx-post "/sx/(applications.(graphql.(api.execute-demo)))"
:hx-target "#var-scalar-out"
:hx-swap "innerHTML"
(input
:type "hidden"
:name "query"
:value "query ByAuthor($author: Int!) { posts(authorId: $author) { title body } }")
(label
(~tw :tokens "block text-sm font-medium text-gray-700")
"Variables")
(textarea
:name "variables"
:rows "2"
(~tw
:tokens "w-full font-mono text-sm p-3 border border-gray-300 rounded-lg")
"{:author 2}")
(button
:type "submit"
(~tw
:tokens "px-4 py-2 bg-violet-600 text-white rounded hover:bg-violet-700")
"Run"))
(div
:id "var-scalar-out"
(~tw :tokens "mt-4")
(span
(~tw :tokens "text-gray-400 text-sm")
"Result will appear here.")))
(~docs/section
:title "Default value"
:id "default"
(p
(~tw :tokens "text-gray-600 mb-3")
"Leave the variables empty and the parser default kicks in — "
"executed against the seed, "
(code "id: 1")
" resolves to Alice.")
(~docs/code
:src "query GetUser($id: ID = 1) {\n user(id: $id) { name role }\n}")
(form
(~tw :tokens "space-y-3")
:hx-post "/sx/(applications.(graphql.(api.execute-demo)))"
:hx-target "#var-default-out"
:hx-swap "innerHTML"
(input
:type "hidden"
:name "query"
:value "query GetUser($id: ID = 1) { user(id: $id) { name role } }")
(label
(~tw :tokens "block text-sm font-medium text-gray-700")
"Variables (leave blank for default)")
(textarea
:name "variables"
:rows "2"
(~tw
:tokens "w-full font-mono text-sm p-3 border border-gray-300 rounded-lg")
"")
(button
:type "submit"
(~tw
:tokens "px-4 py-2 bg-violet-600 text-white rounded hover:bg-violet-700")
"Run"))
(div
:id "var-default-out"
(~tw :tokens "mt-4")
(span
(~tw :tokens "text-gray-400 text-sm")
"Result will appear here.")))
(~docs/section
:title "Pure SX"
:id "parse"
(p
(~tw :tokens "text-gray-600 mb-3")
"The variables payload is parsed with the same SX reader the rest of the language uses:")
(~docs/code
:src "(define gql-parse-vars\n (fn (source)\n (if (or (nil? source) (empty? source))\n {}\n (let ((parsed (parse source)))\n (if (dict? parsed) parsed {})))))"))))