From 9171aea0174d516367c73a67e6201e968de8455d Mon Sep 17 00:00:00 2001 From: Utkarash Singh Date: Thu, 30 Apr 2026 16:41:31 +0100 Subject: [PATCH 1/2] tests: add introspection edge case coverage and clarify mixed query behaviour in docs (refs #628) --- docs/configuration.md | 23 ++++- test/expected/introspection.out | 143 ++++++++++++++++++++++++++++++++ test/sql/introspection.sql | 83 ++++++++++++++++++ 3 files changed, 248 insertions(+), 1 deletion(-) diff --git a/docs/configuration.md b/docs/configuration.md index 5e313054..e3adcd50 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -281,7 +281,7 @@ Same for the the Mutation type's field listing, `Blog`'s mutation fields appear, #### Non-introspection Queries -Non-introspection queries are not affected by disabling introspection. `accountCollection`, `insertIntoAccountCollection`, etc. continue to resolve normally as long as the role has the underlying SQL privileges: +Non-introspection queries run in isolation are not affected by disabling introspection. `accountCollection`, `insertIntoAccountCollection`, etc. continue to resolve normally as long as the role has the underlying SQL privileges: === "Query" @@ -312,6 +312,27 @@ Non-introspection queries are not affected by disabling introspection. `accountC } ``` +!!! warning "Mixing introspection and data fields in the same query" + + If a query contains both an introspection field (`__schema`, `__type`) and a data + field, and introspection is disabled, the **entire `data` response becomes `null`** — + not just the introspection field. The data field executes successfully but its result + is discarded because the query produced an error. + + ```graphql + { + __schema { types { name } } + blogCollection { edges { node { id } } } + } + ``` + + ```json + { + "data": null, + "errors": [{ "message": "Unknown field \"__schema\" on type Query" }] + } + ``` + The four combinations across the two schemas behave as follows: | `public` | `private` | `__schema` / `__type` available | `Blog` visible | `Account` visible | Non-introspection queries | diff --git a/test/expected/introspection.out b/test/expected/introspection.out index e9dde9b8..087a06e9 100644 --- a/test/expected/introspection.out +++ b/test/expected/introspection.out @@ -546,4 +546,147 @@ begin; } (1 row) + rollback to savepoint a; + -- off/on: public=OFF, private=ON + -- introspection_enabled() uses any(), so introspection is available when + -- private opts in. public types must still be hidden. + create schema private; + comment on schema public is e'@graphql({"inflect_names": true, "introspection": false})'; + comment on schema private is e'@graphql({"inflect_names": true, "introspection": true})'; + create table public.blog(id serial primary key, content text not null); + create table private.account(id serial primary key, email text not null); + insert into public.blog(id, content) values (1, 'hello, world'); + insert into private.account(id, email) values (1, 'alice@example.com'); + set local search_path = public, private; + -- public schema type returns null (public=OFF) + select jsonb_pretty( + graphql.resolve($$ + { __type(name: "Blog") { kind name } } + $$) + ); + jsonb_pretty +------------------------ + { + + "data": { + + "__type": null+ + } + + } +(1 row) + + -- private schema type returns data (private=ON) + select jsonb_pretty( + graphql.resolve($$ + { __type(name: "Account") { kind name } } + $$) + ); + jsonb_pretty +------------------------------- + { + + "data": { + + "__type": { + + "kind": "OBJECT",+ + "name": "Account"+ + } + + } + + } +(1 row) + + -- __schema lists private types but not public types + select jsonb_pretty( + graphql.resolve($$ + { __schema { mutationType { fields { name } } } } + $$) + ); + jsonb_pretty +--------------------------------------------------------------- + { + + "data": { + + "__schema": { + + "mutationType": { + + "fields": [ + + { + + "name": "deleteFromAccountCollection"+ + }, + + { + + "name": "insertIntoAccountCollection"+ + }, + + { + + "name": "updateAccountCollection" + + } + + ] + + } + + } + + } + + } +(1 row) + + rollback to savepoint a; + -- named fragment spread containing introspection fields + -- fragment expansion happens before the field_map lookup so the same + -- "Unknown field" error must fire as for an inline introspection query + create table public.blog(id serial primary key, content text not null); + set local search_path = public; + select jsonb_pretty( + graphql.resolve($$ + fragment IntrospectSchema on Query { __schema { types { kind name } } } + { ...IntrospectSchema } + $$) + ); + jsonb_pretty +------------------------------------------------------------------- + { + + "data": null, + + "errors": [ + + { + + "message": "Unknown field \"__schema\" on type Query"+ + } + + ] + + } +(1 row) + + select jsonb_pretty( + graphql.resolve($$ + fragment IntrospectType on Query { __type(name: "blog") { kind name } } + { ...IntrospectType } + $$) + ); + jsonb_pretty +----------------------------------------------------------------- + { + + "data": null, + + "errors": [ + + { + + "message": "Unknown field \"__type\" on type Query"+ + } + + ] + + } +(1 row) + + rollback to savepoint a; + -- mixed query: introspection field + data field with introspection disabled + -- when any field errors the entire data object becomes null, so + -- blogCollection result is lost even though it executed successfully + create table public.blog(id serial primary key, content text not null); + insert into public.blog(id, content) values (1, 'hello, world'); + set local search_path = public; + select jsonb_pretty( + graphql.resolve($$ + { + __schema { types { name } } + blogCollection { edges { node { id content } } } + } + $$) + ); + jsonb_pretty +------------------------------------------------------------------- + { + + "data": null, + + "errors": [ + + { + + "message": "Unknown field \"__schema\" on type Query"+ + } + + ] + + } +(1 row) + rollback; diff --git a/test/sql/introspection.sql b/test/sql/introspection.sql index 4f21512e..ff287dfa 100644 --- a/test/sql/introspection.sql +++ b/test/sql/introspection.sql @@ -148,4 +148,87 @@ begin; { __schema { types { kind name } } } $$) ); + + rollback to savepoint a; + + -- off/on: public=OFF, private=ON + -- introspection_enabled() uses any(), so introspection is available when + -- private opts in. public types must still be hidden. + + create schema private; + + comment on schema public is e'@graphql({"inflect_names": true, "introspection": false})'; + comment on schema private is e'@graphql({"inflect_names": true, "introspection": true})'; + + create table public.blog(id serial primary key, content text not null); + create table private.account(id serial primary key, email text not null); + + insert into public.blog(id, content) values (1, 'hello, world'); + insert into private.account(id, email) values (1, 'alice@example.com'); + + set local search_path = public, private; + + -- public schema type returns null (public=OFF) + select jsonb_pretty( + graphql.resolve($$ + { __type(name: "Blog") { kind name } } + $$) + ); + + -- private schema type returns data (private=ON) + select jsonb_pretty( + graphql.resolve($$ + { __type(name: "Account") { kind name } } + $$) + ); + + -- __schema lists private types but not public types + select jsonb_pretty( + graphql.resolve($$ + { __schema { mutationType { fields { name } } } } + $$) + ); + + rollback to savepoint a; + + -- named fragment spread containing introspection fields + -- fragment expansion happens before the field_map lookup so the same + -- "Unknown field" error must fire as for an inline introspection query + + create table public.blog(id serial primary key, content text not null); + set local search_path = public; + + select jsonb_pretty( + graphql.resolve($$ + fragment IntrospectSchema on Query { __schema { types { kind name } } } + { ...IntrospectSchema } + $$) + ); + + select jsonb_pretty( + graphql.resolve($$ + fragment IntrospectType on Query { __type(name: "blog") { kind name } } + { ...IntrospectType } + $$) + ); + + rollback to savepoint a; + + -- mixed query: introspection field + data field with introspection disabled + -- when any field errors the entire data object becomes null, so + -- blogCollection result is lost even though it executed successfully + + create table public.blog(id serial primary key, content text not null); + insert into public.blog(id, content) values (1, 'hello, world'); + set local search_path = public; + + select jsonb_pretty( + graphql.resolve($$ + { + __schema { types { name } } + blogCollection { edges { node { id content } } } + } + $$) + ); + rollback; From d71b8a46fced8cef6464fa5b4368cd6573f04077 Mon Sep 17 00:00:00 2001 From: Raminder Singh Date: Fri, 1 May 2026 10:14:28 +0530 Subject: [PATCH 2/2] fix: a bug in which mixed introspection & data fields return incorrect result --- docs/configuration.md | 110 ++++++-- src/resolve.rs | 11 +- test/expected/introspection.out | 459 +++++++++++++++++++++++++++++++- test/sql/introspection.sql | 90 ++++++- 4 files changed, 641 insertions(+), 29 deletions(-) diff --git a/docs/configuration.md b/docs/configuration.md index e3adcd50..e48fe1b5 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -279,22 +279,17 @@ Same for the the Mutation type's field listing, `Blog`'s mutation fields appear, } ``` -#### Non-introspection Queries +**Mixed Field Queries** -Non-introspection queries run in isolation are not affected by disabling introspection. `accountCollection`, `insertIntoAccountCollection`, etc. continue to resolve normally as long as the role has the underlying SQL privileges: +If a query contains both an introspection field (`__schema`, `__type`) and a data +field, and introspection is disabled, only the introspection fields return an error, the data field is resolved correctly: === "Query" ```graphql { - accountCollection { - edges { - node { - id - email - } - } - } + __schema { types { name } } + blogCollection { edges { node { id } } } } ``` @@ -303,33 +298,106 @@ Non-introspection queries run in isolation are not affected by disabling introsp ```json { "data": { + "blogCollection": { + "edges": [ + { + "node": { + "id": 1, + "content": "hello, world" + } + } + ] + } + }, + "errors": [ + { + "message": "Unknown field \"__schema\" on type Query" + } + ] + } + ``` + +If there are two schemas with introspection enabled only on one schema, there is no error on the instrospection fields but entities from the introspection disabled schema are filtered out: + +=== "Query" + + ```graphql + { + __schema { types { name } } + accountCollection { edges { node { id email } } } + blogCollection { edges { node { id content } } } + } + ``` + +=== "Response" + + ```json + { + "data": { + "__schema": { + "queryType": { + "fields": [ + { "name": "blogCollection" }, + { "name": "node" } + // no accountCollection + ] + } + }, + "blogCollection": { + "edges": [ + { + "node": { + "id": 1, + "content": "hello, world" + } + } + ] + }, "accountCollection": { "edges": [ - { "node": { "id": 1, "email": "alice@example.com" } } + { + "node": { + "id": 1, + "email": "alice@example.com" + } + } ] } - } + }, + // no errors } ``` -!!! warning "Mixing introspection and data fields in the same query" +#### Non-introspection Queries + +Non-introspection queries are not affected by disabling introspection. `accountCollection`, `insertIntoAccountCollection`, etc. continue to resolve normally as long as the role has the underlying SQL privileges: - If a query contains both an introspection field (`__schema`, `__type`) and a data - field, and introspection is disabled, the **entire `data` response becomes `null`** — - not just the introspection field. The data field executes successfully but its result - is discarded because the query produced an error. +=== "Query" ```graphql { - __schema { types { name } } - blogCollection { edges { node { id } } } + accountCollection { + edges { + node { + id + email + } + } + } } ``` +=== "Response" + ```json { - "data": null, - "errors": [{ "message": "Unknown field \"__schema\" on type Query" }] + "data": { + "accountCollection": { + "edges": [ + { "node": { "id": 1, "email": "alice@example.com" } } + ] + } + } } ``` diff --git a/src/resolve.rs b/src/resolve.rs index 5ee58c88..219e012c 100644 --- a/src/resolve.rs +++ b/src/resolve.rs @@ -362,10 +362,15 @@ where }, } } + let any_field_succeeded = res_data + .as_object() + .map(|o| !o.is_empty()) + .unwrap_or(false); GraphQLResponse { - data: match res_errors.len() { - 0 => Omit::Present(res_data), - _ => Omit::Present(serde_json::Value::Null), + data: if res_errors.is_empty() || any_field_succeeded { + Omit::Present(res_data) + } else { + Omit::Present(serde_json::Value::Null) }, errors: match res_errors.len() { 0 => Omit::Omitted, diff --git a/test/expected/introspection.out b/test/expected/introspection.out index 087a06e9..5b6e5395 100644 --- a/test/expected/introspection.out +++ b/test/expected/introspection.out @@ -664,11 +664,12 @@ begin; rollback to savepoint a; -- mixed query: introspection field + data field with introspection disabled - -- when any field errors the entire data object becomes null, so - -- blogCollection result is lost even though it executed successfully + -- the introspection field errors but the data field still resolves; only + -- __schema/__type entries are dropped, blogCollection is preserved create table public.blog(id serial primary key, content text not null); insert into public.blog(id, content) values (1, 'hello, world'); set local search_path = public; + -- just __schema field with a data field select jsonb_pretty( graphql.resolve($$ { @@ -680,7 +681,18 @@ begin; jsonb_pretty ------------------------------------------------------------------- { + - "data": null, + + "data": { + + "blogCollection": { + + "edges": [ + + { + + "node": { + + "id": 1, + + "content": "hello, world" + + } + + } + + ] + + } + + }, + "errors": [ + { + "message": "Unknown field \"__schema\" on type Query"+ @@ -689,4 +701,445 @@ begin; } (1 row) + -- just __type field with a data field + select jsonb_pretty( + graphql.resolve($$ + { + __type(name: "Blog") { kind name } + blogCollection { edges { node { id content } } } + } + $$) + ); + jsonb_pretty +----------------------------------------------------------------- + { + + "data": { + + "blogCollection": { + + "edges": [ + + { + + "node": { + + "id": 1, + + "content": "hello, world" + + } + + } + + ] + + } + + }, + + "errors": [ + + { + + "message": "Unknown field \"__type\" on type Query"+ + } + + ] + + } +(1 row) + + -- both __schema and __type fields with a data field + select jsonb_pretty( + graphql.resolve($$ + { + __schema { types { name } } + __type(name: "Blog") { kind name } + blogCollection { edges { node { id content } } } + } + $$) + ); + jsonb_pretty +------------------------------------------------------------------- + { + + "data": { + + "blogCollection": { + + "edges": [ + + { + + "node": { + + "id": 1, + + "content": "hello, world" + + } + + } + + ] + + } + + }, + + "errors": [ + + { + + "message": "Unknown field \"__schema\" on type Query"+ + }, + + { + + "message": "Unknown field \"__type\" on type Query" + + } + + ] + + } +(1 row) + + rollback to savepoint a; + -- mixed query: introspection + data fields, one schema enabled, other disabled. + -- introspection succeeds with filtered results (only entities from the enabled + -- schema appear) and the data field resolves; no errors expected. + create schema private; + comment on schema public is e'@graphql({"inflect_names": true, "introspection": true})'; + comment on schema private is e'@graphql({"inflect_names": true, "introspection": false})'; + create table public.blog(id serial primary key, content text not null); + create table private.account(id serial primary key, email text not null); + insert into public.blog(id, content) values (1, 'hello, world'); + insert into private.account(id, email) values (1, 'alice@example.com'); + set local search_path = public, private; + -- __schema lists Blog types but not Account types, and accountCollection and blogCollection still resolve + select jsonb_pretty( + graphql.resolve($$ + { + __schema { types { name } } + accountCollection { edges { node { id email } } } + blogCollection { edges { node { id content } } } + } + $$) + ); + jsonb_pretty +------------------------------------------------------ + { + + "data": { + + "__schema": { + + "types": [ + + { + + "name": "BigFloat" + + }, + + { + + "name": "BigFloatFilter" + + }, + + { + + "name": "BigFloatListFilter" + + }, + + { + + "name": "BigInt" + + }, + + { + + "name": "BigIntFilter" + + }, + + { + + "name": "BigIntListFilter" + + }, + + { + + "name": "Blog" + + }, + + { + + "name": "BlogConnection" + + }, + + { + + "name": "BlogDeleteResponse" + + }, + + { + + "name": "BlogEdge" + + }, + + { + + "name": "BlogFilter" + + }, + + { + + "name": "BlogInsertInput" + + }, + + { + + "name": "BlogInsertResponse" + + }, + + { + + "name": "BlogOrderBy" + + }, + + { + + "name": "BlogUpdateInput" + + }, + + { + + "name": "BlogUpdateResponse" + + }, + + { + + "name": "Boolean" + + }, + + { + + "name": "BooleanFilter" + + }, + + { + + "name": "BooleanListFilter" + + }, + + { + + "name": "Cursor" + + }, + + { + + "name": "Date" + + }, + + { + + "name": "DateFilter" + + }, + + { + + "name": "DateListFilter" + + }, + + { + + "name": "Datetime" + + }, + + { + + "name": "DatetimeFilter" + + }, + + { + + "name": "DatetimeListFilter" + + }, + + { + + "name": "FilterIs" + + }, + + { + + "name": "Float" + + }, + + { + + "name": "FloatFilter" + + }, + + { + + "name": "FloatListFilter" + + }, + + { + + "name": "ID" + + }, + + { + + "name": "IDFilter" + + }, + + { + + "name": "Int" + + }, + + { + + "name": "IntFilter" + + }, + + { + + "name": "IntListFilter" + + }, + + { + + "name": "JSON" + + }, + + { + + "name": "Mutation" + + }, + + { + + "name": "Node" + + }, + + { + + "name": "Opaque" + + }, + + { + + "name": "OpaqueFilter" + + }, + + { + + "name": "OrderByDirection" + + }, + + { + + "name": "PageInfo" + + }, + + { + + "name": "Query" + + }, + + { + + "name": "String" + + }, + + { + + "name": "StringFilter" + + }, + + { + + "name": "StringListFilter" + + }, + + { + + "name": "Time" + + }, + + { + + "name": "TimeFilter" + + }, + + { + + "name": "TimeListFilter" + + }, + + { + + "name": "UUID" + + }, + + { + + "name": "UUIDFilter" + + }, + + { + + "name": "UUIDListFilter" + + }, + + { + + "name": "__Directive" + + }, + + { + + "name": "__DirectiveLocation" + + }, + + { + + "name": "__EnumValue" + + }, + + { + + "name": "__Field" + + }, + + { + + "name": "__InputValue" + + }, + + { + + "name": "__Schema" + + }, + + { + + "name": "__Type" + + }, + + { + + "name": "__TypeKind" + + } + + ] + + }, + + "blogCollection": { + + "edges": [ + + { + + "node": { + + "id": 1, + + "content": "hello, world" + + } + + } + + ] + + }, + + "accountCollection": { + + "edges": [ + + { + + "node": { + + "id": 1, + + "email": "alice@example.com"+ + } + + } + + ] + + } + + } + + } +(1 row) + + -- __type resolves the public Blog type and accountCollection and blogCollection still resolve + select jsonb_pretty( + graphql.resolve($$ + { + __type(name: "Blog") { kind name } + accountCollection { edges { node { id email } } } + blogCollection { edges { node { id content } } } + } + $$) + ); + jsonb_pretty +------------------------------------------------------ + { + + "data": { + + "__type": { + + "kind": "OBJECT", + + "name": "Blog" + + }, + + "blogCollection": { + + "edges": [ + + { + + "node": { + + "id": 1, + + "content": "hello, world" + + } + + } + + ] + + }, + + "accountCollection": { + + "edges": [ + + { + + "node": { + + "id": 1, + + "email": "alice@example.com"+ + } + + } + + ] + + } + + } + + } +(1 row) + + -- __type returns null for the private Account type while accountCollection and blogCollection resolve + select jsonb_pretty( + graphql.resolve($$ + { + __type(name: "Account") { kind name } + accountCollection { edges { node { id email } } } + blogCollection { edges { node { id content } } } + } + $$) + ); + jsonb_pretty +------------------------------------------------------ + { + + "data": { + + "__type": null, + + "blogCollection": { + + "edges": [ + + { + + "node": { + + "id": 1, + + "content": "hello, world" + + } + + } + + ] + + }, + + "accountCollection": { + + "edges": [ + + { + + "node": { + + "id": 1, + + "email": "alice@example.com"+ + } + + } + + ] + + } + + } + + } +(1 row) + + -- __schema + __type + data field together: filtered introspection, no errors + select jsonb_pretty( + graphql.resolve($$ + { + __schema { mutationType { fields { name } } } + __type(name: "Blog") { kind name } + accountCollection { edges { node { id email } } } + blogCollection { edges { node { id content } } } + } + $$) + ); + jsonb_pretty +------------------------------------------------------------ + { + + "data": { + + "__type": { + + "kind": "OBJECT", + + "name": "Blog" + + }, + + "__schema": { + + "mutationType": { + + "fields": [ + + { + + "name": "deleteFromBlogCollection"+ + }, + + { + + "name": "insertIntoBlogCollection"+ + }, + + { + + "name": "updateBlogCollection" + + } + + ] + + } + + }, + + "blogCollection": { + + "edges": [ + + { + + "node": { + + "id": 1, + + "content": "hello, world" + + } + + } + + ] + + }, + + "accountCollection": { + + "edges": [ + + { + + "node": { + + "id": 1, + + "email": "alice@example.com" + + } + + } + + ] + + } + + } + + } +(1 row) + rollback; diff --git a/test/sql/introspection.sql b/test/sql/introspection.sql index ff287dfa..3ae84029 100644 --- a/test/sql/introspection.sql +++ b/test/sql/introspection.sql @@ -215,13 +215,14 @@ begin; rollback to savepoint a; -- mixed query: introspection field + data field with introspection disabled - -- when any field errors the entire data object becomes null, so - -- blogCollection result is lost even though it executed successfully + -- the introspection field errors but the data field still resolves; only + -- __schema/__type entries are dropped, blogCollection is preserved create table public.blog(id serial primary key, content text not null); insert into public.blog(id, content) values (1, 'hello, world'); set local search_path = public; + -- just __schema field with a data field select jsonb_pretty( graphql.resolve($$ { @@ -231,4 +232,89 @@ begin; $$) ); + -- just __type field with a data field + select jsonb_pretty( + graphql.resolve($$ + { + __type(name: "Blog") { kind name } + blogCollection { edges { node { id content } } } + } + $$) + ); + + -- both __schema and __type fields with a data field + select jsonb_pretty( + graphql.resolve($$ + { + __schema { types { name } } + __type(name: "Blog") { kind name } + blogCollection { edges { node { id content } } } + } + $$) + ); + + rollback to savepoint a; + + -- mixed query: introspection + data fields, one schema enabled, other disabled. + -- introspection succeeds with filtered results (only entities from the enabled + -- schema appear) and the data field resolves; no errors expected. + + create schema private; + + comment on schema public is e'@graphql({"inflect_names": true, "introspection": true})'; + comment on schema private is e'@graphql({"inflect_names": true, "introspection": false})'; + + create table public.blog(id serial primary key, content text not null); + create table private.account(id serial primary key, email text not null); + + insert into public.blog(id, content) values (1, 'hello, world'); + insert into private.account(id, email) values (1, 'alice@example.com'); + + set local search_path = public, private; + + -- __schema lists Blog types but not Account types, and accountCollection and blogCollection still resolve + select jsonb_pretty( + graphql.resolve($$ + { + __schema { types { name } } + accountCollection { edges { node { id email } } } + blogCollection { edges { node { id content } } } + } + $$) + ); + + -- __type resolves the public Blog type and accountCollection and blogCollection still resolve + select jsonb_pretty( + graphql.resolve($$ + { + __type(name: "Blog") { kind name } + accountCollection { edges { node { id email } } } + blogCollection { edges { node { id content } } } + } + $$) + ); + + -- __type returns null for the private Account type while accountCollection and blogCollection resolve + select jsonb_pretty( + graphql.resolve($$ + { + __type(name: "Account") { kind name } + accountCollection { edges { node { id email } } } + blogCollection { edges { node { id content } } } + } + $$) + ); + + -- __schema + __type + data field together: filtered introspection, no errors + select jsonb_pretty( + graphql.resolve($$ + { + __schema { mutationType { fields { name } } } + __type(name: "Blog") { kind name } + accountCollection { edges { node { id email } } } + blogCollection { edges { node { id content } } } + } + $$) + ); + rollback;