diff --git a/docs/configuration.md b/docs/configuration.md index 512ff7e0..414b7ad7 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -184,6 +184,25 @@ type Account { } ``` +#### Column's Not Null + +Use the `"not_null"` JSON key to mark a column as non-nullable. This is particularly useful for [views](views.md#not-null-columns) where PostgreSQL does not preserve `NOT NULL` constraints from underlying tables. + +```sql +create view "Person" as + select id, name from "Account"; + +comment on column "Person".id is +e'@graphql({"not_null": true})'; +``` + +results in: +```graphql +type Person { + id: Int! # would be "Int" without the directive +} +``` + #### Computed Field Use the `"name"` JSON key to override a [computed field's](computed_fields.md) name. diff --git a/docs/views.md b/docs/views.md index 663e5163..7cc22cef 100644 --- a/docs/views.md +++ b/docs/views.md @@ -26,11 +26,14 @@ tells pg_graphql to treat `"Person".id` as the primary key for the `Person` enti ```graphql type Person { nodeId: ID! - id: Int! - name: String! + id: Int + name: String } ``` +!!! note + View columns are nullable by default because PostgreSQL does not preserve `NOT NULL` constraints from underlying tables. See [Not Null Columns](#not-null-columns) to learn how to mark view columns as non-nullable. + !!! warning Values of the primary key column/s must be unique within the table. If they are not unique, you will experience inconsistent behavior with `ID!` types, sorting, and pagination. @@ -124,3 +127,51 @@ type EmailAddress { account: Account! } ``` + +## Not Null Columns + +PostgreSQL views do not preserve `NOT NULL` constraints from their underlying tables. This means view columns appear as nullable in the GraphQL schema even when the source table columns are `NOT NULL`. To mark a view column as non-nullable, use the `not_null` [comment directive](configuration.md#comment-directives) on the column: + +```json +{"not_null": true} +``` + +For example: + +```sql +create table "Account"( + id serial primary key, + name text not null +); + +create view "Person" as + select + id, + name + from + "Account"; + +comment on view "Person" is e'@graphql({"primary_key_columns": ["id"]})'; +comment on column "Person".id is e'@graphql({"not_null": true})'; +comment on column "Person".name is e'@graphql({"not_null": true})'; +``` + +Without the `not_null` directives, the GraphQL type would have nullable fields: + +```graphql +type Person { + nodeId: ID! + id: Int + name: String +} +``` + +With the `not_null` directives applied, the fields become non-nullable: + +```graphql +type Person { + nodeId: ID! + id: Int! + name: String! +} +``` diff --git a/sql/load_sql_context.sql b/sql/load_sql_context.sql index 5ef309c9..72e3a54f 100644 --- a/sql/load_sql_context.sql +++ b/sql/load_sql_context.sql @@ -346,7 +346,8 @@ select select jsonb_build_object( 'name', d.directive ->> 'name', - 'description', d.directive -> 'description' + 'description', d.directive -> 'description', + 'not_null', (d.directive ->> 'not_null')::boolean ) from directives d diff --git a/src/graphql.rs b/src/graphql.rs index 1d07eeb1..09020025 100644 --- a/src/graphql.rs +++ b/src/graphql.rs @@ -1976,7 +1976,7 @@ pub fn sql_column_to_graphql_type(col: &Column, schema: &Arc<__Schema>) -> Optio let maybe_type_w_list_mod = sql_type.to_graphql_type(col.max_characters, false, schema); match maybe_type_w_list_mod { None => None, - Some(type_with_list_mod) => match col.is_not_null { + Some(type_with_list_mod) => match col.is_not_null() { true => Some(__Type::NonNull(NonNullType { type_: Box::new(type_with_list_mod), })), @@ -1996,7 +1996,7 @@ impl NodeType { self.table .columns .iter() - .any(|c| &c.name == colname && c.is_not_null) + .any(|c| &c.name == colname && c.is_not_null()) && !fkey.referenced_table_meta.is_rls_enabled && !is_reverse_reference }) { diff --git a/src/sql_types.rs b/src/sql_types.rs index 1520f383..2cf4a520 100644 --- a/src/sql_types.rs +++ b/src/sql_types.rs @@ -26,6 +26,7 @@ pub struct ColumnPermissions { pub struct ColumnDirectives { pub name: Option, pub description: Option, + pub not_null: Option, } #[derive(Deserialize, Clone, Debug, Eq, PartialEq, Hash)] @@ -46,6 +47,14 @@ pub struct Column { pub directives: ColumnDirectives, } +impl Column { + /// Returns true if the column is not null, considering both the SQL constraint + /// and any directive override (useful for views where SQL doesn't preserve NOT NULL) + pub fn is_not_null(&self) -> bool { + self.directives.not_null.unwrap_or(self.is_not_null) + } +} + #[derive(Deserialize, Clone, Debug, Eq, PartialEq, Hash)] pub struct FunctionDirectives { pub name: Option, diff --git a/test/expected/not_null_directive.out b/test/expected/not_null_directive.out new file mode 100644 index 00000000..6e30b994 --- /dev/null +++ b/test/expected/not_null_directive.out @@ -0,0 +1,141 @@ +begin; + -- Create a table with NOT NULL columns + create table account( + id serial primary key, + name text not null + ); + -- Create a view from the table + create view person as + select id, name from account; + -- Add primary key directive so the view is exposed + comment on view person is e'@graphql({"primary_key_columns": ["id"]})'; + -- Check that view columns are nullable by default (no NOT NULL constraint preserved) + -- The "kind" should be a scalar type directly, not NON_NULL + select jsonb_pretty( + graphql.resolve($$ + { + __type(name: "Person") { + fields { + name + type { + kind + name + ofType { + kind + name + } + } + } + } + } + $$) + ); + jsonb_pretty +----------------------------------------------- + { + + "data": { + + "__type": { + + "fields": [ + + { + + "name": "nodeId", + + "type": { + + "kind": "NON_NULL", + + "name": null, + + "ofType": { + + "kind": "SCALAR",+ + "name": "ID" + + } + + } + + }, + + { + + "name": "id", + + "type": { + + "kind": "SCALAR", + + "name": "Int", + + "ofType": null + + } + + }, + + { + + "name": "name", + + "type": { + + "kind": "SCALAR", + + "name": "String", + + "ofType": null + + } + + } + + ] + + } + + } + + } +(1 row) + + -- Apply not_null directive to view columns + comment on column person.id is e'@graphql({"not_null": true})'; + comment on column person.name is e'@graphql({"not_null": true})'; + -- Check that view columns are now non-nullable + -- The "kind" should be NON_NULL with ofType containing the scalar type + select jsonb_pretty( + graphql.resolve($$ + { + __type(name: "Person") { + fields { + name + type { + kind + name + ofType { + kind + name + } + } + } + } + } + $$) + ); + jsonb_pretty +----------------------------------------------- + { + + "data": { + + "__type": { + + "fields": [ + + { + + "name": "nodeId", + + "type": { + + "kind": "NON_NULL", + + "name": null, + + "ofType": { + + "kind": "SCALAR",+ + "name": "ID" + + } + + } + + }, + + { + + "name": "id", + + "type": { + + "kind": "NON_NULL", + + "name": null, + + "ofType": { + + "kind": "SCALAR",+ + "name": "Int" + + } + + } + + }, + + { + + "name": "name", + + "type": { + + "kind": "NON_NULL", + + "name": null, + + "ofType": { + + "kind": "SCALAR",+ + "name": "String" + + } + + } + + } + + ] + + } + + } + + } +(1 row) + +rollback; diff --git a/test/sql/not_null_directive.sql b/test/sql/not_null_directive.sql new file mode 100644 index 00000000..b0df8c67 --- /dev/null +++ b/test/sql/not_null_directive.sql @@ -0,0 +1,63 @@ +begin; + -- Create a table with NOT NULL columns + create table account( + id serial primary key, + name text not null + ); + + -- Create a view from the table + create view person as + select id, name from account; + + -- Add primary key directive so the view is exposed + comment on view person is e'@graphql({"primary_key_columns": ["id"]})'; + + -- Check that view columns are nullable by default (no NOT NULL constraint preserved) + -- The "kind" should be a scalar type directly, not NON_NULL + select jsonb_pretty( + graphql.resolve($$ + { + __type(name: "Person") { + fields { + name + type { + kind + name + ofType { + kind + name + } + } + } + } + } + $$) + ); + + -- Apply not_null directive to view columns + comment on column person.id is e'@graphql({"not_null": true})'; + comment on column person.name is e'@graphql({"not_null": true})'; + + -- Check that view columns are now non-nullable + -- The "kind" should be NON_NULL with ofType containing the scalar type + select jsonb_pretty( + graphql.resolve($$ + { + __type(name: "Person") { + fields { + name + type { + kind + name + ofType { + kind + name + } + } + } + } + } + $$) + ); + +rollback;