1
Fork 0
mirror of https://github.com/Airsequel/AirGQL.git synced 2025-07-23 00:24:03 +03:00
airgql/source/AirGQL/Introspection.hs

545 lines
16 KiB
Haskell

module AirGQL.Introspection (
typeNameResolver,
getSchemaResolver,
tableQueryField,
tableQueryByPKField,
tableInsertField,
tableUpdateField,
tableUpdateFieldByPk,
tableDeleteField,
tableDeleteFieldByPK,
)
where
import Protolude (
Applicative (pure),
Either (Left, Right),
IO,
Maybe (Just, Nothing),
Monoid (mempty),
Semigroup ((<>)),
Text,
($),
(&),
(<&>),
(==),
)
import Protolude qualified as P
import Data.HashMap.Strict as HashMap (HashMap, singleton)
import Language.GraphQL.Type.Out as Out (
Field (Field),
Resolver (ValueResolver),
Type (NonNullScalarType),
)
import AirGQL.Introspection.NamingConflict (
encodeOutsidePKNames,
encodeOutsideTableNames,
)
import AirGQL.Introspection.Resolver (makeType)
import AirGQL.Introspection.Types (IntrospectionType)
import AirGQL.Introspection.Types qualified as Type
import AirGQL.Lib (
AccessMode,
ColumnEntry,
GqlTypeName (full, root),
ObjectType (Table),
TableEntry (columns, name, object_type),
canInsert,
canRead,
canWrite,
column_name_gql,
datatype_gql,
getPKColumns,
isOmittable,
notnull,
)
import Control.Monad (MonadFail (fail))
import Data.List qualified as List
import Data.Text qualified as T
import DoubleXEncoding (doubleXEncodeGql)
import Language.GraphQL.Class (ToGraphQL (toGraphQL))
import Language.GraphQL.Type (string)
typeNameResolver :: HashMap Text (Resolver IO)
typeNameResolver =
HashMap.singleton
"__typename"
$ ValueResolver
(Out.Field Nothing (Out.NonNullScalarType string) mempty)
$ pure "Query"
columnTypeName :: ColumnEntry -> Text
columnTypeName entry =
case entry.datatype_gql of
Nothing -> "String"
Just type_ -> type_.full
columnType :: ColumnEntry -> IntrospectionType
columnType entry = case entry.datatype_gql of
Nothing -> Type.typeString
Just type_ ->
Type.scalar type_.root
-- NOTE: I wonder if the fact we're generating different descriptions
-- based off the field is ok.
& Type.withDescription
("Data type for column '" <> entry.column_name_gql <> "'")
orderingTermType :: Type.IntrospectionType
orderingTermType =
Type.enum
"OrderingTerm"
[ Type.enumValue "ASC" & Type.enumValueWithDescription "In ascending order"
, Type.enumValue "asc"
& Type.enumValueWithDescription "In ascending order"
& Type.deprecatedEnumValue "GraphQL spec recommends all caps for enums"
, Type.enumValue "DESC" & Type.enumValueWithDescription "In descending order"
, Type.enumValue "desc"
& Type.enumValueWithDescription "In descending order"
& Type.deprecatedEnumValue "GraphQL spec recommends all caps for enums"
]
& Type.withDescription "Ordering options when ordering by a column"
filterType :: TableEntry -> IntrospectionType
filterType table = do
let
fieldsWithComparisonExp =
table.columns <&> \columnEntry -> do
let colName = columnEntry.column_name_gql
let typeName = columnTypeName columnEntry
let type_ = columnType columnEntry
let comparisonField name ty =
[ Type.deprecatedInputValue "Unify naming with Hasura" $
Type.inputValue name ty
, Type.inputValue ("_" <> name) ty
]
Type.inputValue colName
$ Type.withDescription ("Compare to a(n) " <> typeName)
$ Type.inputObject
(typeName <> "Comparison")
$ P.fold
[ comparisonField "eq" type_
, comparisonField "neq" type_
, comparisonField "gt" type_
, comparisonField "gte" type_
, comparisonField "lt" type_
, comparisonField "lte" type_
, comparisonField "like" type_
, comparisonField "ilike" type_
, comparisonField "in" $ Type.list type_
, comparisonField "nin" $ Type.list type_
]
Type.inputObject
(doubleXEncodeGql table.name <> "_filter")
fieldsWithComparisonExp
& Type.withDescription "Select rows matching the provided filter object"
tableRowType :: TableEntry -> Type.IntrospectionType
tableRowType table = do
let fields =
table.columns <&> \columnEntry -> do
let colName = columnEntry.column_name_gql
let base = columnType columnEntry
let type_ =
if columnEntry.notnull
then Type.nonNull base
else base
Type.field colName type_
Type.object (doubleXEncodeGql table.name <> "_row") fields
& Type.withDescription
("Available columns for table \"" <> table.name <> "\"")
tableQueryField :: TableEntry -> Type.Field
tableQueryField table =
let
fieldsWithOrderingTerm =
table.columns <&> \columnEntry -> do
let colName = columnEntry.column_name_gql
Type.inputValue colName orderingTermType
orderType =
Type.inputObject
(doubleXEncodeGql table.name <> "_order_by")
fieldsWithOrderingTerm
& Type.withDescription
( "Ordering options when selecting data from \""
<> table.name
<> "\"."
)
in
Type.field
(doubleXEncodeGql table.name)
(Type.nonNull $ Type.list $ Type.nonNull $ tableRowType table)
& Type.fieldWithDescription ("Rows from the table \"" <> table.name <> "\"")
& Type.withArguments
( Type.inputValue "filter" (filterType table)
& Type.inputValueWithDescription "Filter to select specific rows"
& Type.renameInputValue "where" "Unify naming with Hasura"
)
& Type.withArguments
[ Type.inputValue "order_by" (Type.list orderType)
& Type.inputValueWithDescription "Columns used to sort the data"
, Type.inputValue "limit" Type.typeInt
& Type.inputValueWithDescription "Limit the number of returned rows"
, Type.inputValue "offset" Type.typeInt
& Type.inputValueWithDescription "The index to start returning rows from"
]
tablePKArguments :: TableEntry -> Maybe [Type.InputValue]
tablePKArguments table = do
pks <- getPKColumns table
pure $
pks <&> \column -> do
let name = doubleXEncodeGql column.column_name_gql
Type.inputValue name $ Type.nonNull $ columnType column
tableQueryByPKField :: [TableEntry] -> TableEntry -> Maybe Type.Field
tableQueryByPKField tables table = do
pkArguments <- tablePKArguments table
pure $
Type.field
(encodeOutsideTableNames tables $ doubleXEncodeGql table.name <> "_by_pk")
(tableRowType table)
& Type.fieldWithDescription
( "Rows from the table \""
<> table.name
<> "\", accessible by their primary key"
)
& Type.withArguments pkArguments
mutationResponseType :: AccessMode -> TableEntry -> Type.IntrospectionType
mutationResponseType accessMode table = do
let tableName = doubleXEncodeGql table.name
let readonlyFields =
if canRead accessMode
then
pure
$ Type.field
"returning"
$ Type.nonNull
$ Type.list
$ Type.nonNull
$ tableRowType table
else []
Type.object
(tableName <> "_mutation_response")
( [ Type.field "affected_rows" (Type.nonNull Type.typeInt)
]
<> readonlyFields
)
& Type.withDescription
( "Mutation response for table \""
<> table.name
<> "\""
)
mutationByPkResponseType :: AccessMode -> TableEntry -> Type.IntrospectionType
mutationByPkResponseType accessMode table = do
let readonlyFields =
if canRead accessMode
then
pure $
Type.field "returning" $
tableRowType table
else []
Type.object
(doubleXEncodeGql table.name <> "_mutation_by_pk_response")
( [ Type.field "affected_rows" (Type.nonNull Type.typeInt)
]
<> readonlyFields
)
& Type.withDescription
( "Response for a PK-based mutation on table \""
<> table.name
<> "\""
)
tableInsertField :: AccessMode -> TableEntry -> Type.Field
tableInsertField accessMode table = do
let
tableName = doubleXEncodeGql table.name
fields =
table.columns <&> \columnEntry -> do
let colName = columnEntry.column_name_gql
let base = columnType columnEntry
let type_ =
if columnEntry.isOmittable
then base
else Type.nonNull base
Type.inputValue colName type_
insertRows =
Type.inputObject
(tableName <> "_insert_input")
fields
& Type.withDescription
("Input object for " <> table.name)
fieldEnumVariants =
table.columns <&> \columnEntry ->
Type.enumValue $ column_name_gql columnEntry
fieldEnumType =
Type.enum (tableName <> "_column") fieldEnumVariants
& Type.withDescription
"This enum contains a variant for each column in the table"
onConflict =
Type.inputObject
(tableName <> "_upsert_on_conflict")
[ Type.inputValue
"constraint"
(Type.nonNull $ Type.list $ Type.nonNull fieldEnumType)
& Type.inputValueWithDescription
"columns to handle conflicts of"
, Type.inputValue
"update_columns"
(Type.nonNull $ Type.list $ Type.nonNull fieldEnumType)
& Type.inputValueWithDescription
"columns to override on conflict"
, Type.inputValue "where" (filterType table)
& Type.inputValueWithDescription
"filter specifying which conflicting columns to update"
]
& Type.withDescription
( "Specifies how broken UNIQUE constraints for "
<> table.name
<> " should be handled"
)
Type.field
("insert_" <> tableName)
(Type.nonNull $ mutationResponseType accessMode table)
& Type.fieldWithDescription
("Insert new rows in table \"" <> table.name <> "\"")
& Type.withArguments
[ Type.inputValue
"objects"
(Type.nonNull $ Type.list $ Type.nonNull insertRows)
& Type.inputValueWithDescription "Rows to be inserted"
, Type.inputValue
"on_conflict"
(Type.list $ Type.nonNull onConflict)
& Type.inputValueWithDescription
"Specifies how to handle broken UNIQUE constraints"
]
tableSetInput :: TableEntry -> Type.IntrospectionType
tableSetInput table =
let
fields =
table.columns <&> \columnEntry -> do
let colName = columnEntry.column_name_gql
let base = columnType columnEntry
Type.inputValue colName base
in
Type.inputObject
(doubleXEncodeGql table.name <> "_set_input")
fields
& Type.withDescription
("Fields to set for " <> table.name)
tableUpdateField :: AccessMode -> TableEntry -> Type.Field
tableUpdateField accessMode table = do
let tableName = doubleXEncodeGql table.name
Type.field
("update_" <> tableName)
(Type.nonNull $ mutationResponseType accessMode table)
& Type.fieldWithDescription
("Update rows in table \"" <> table.name <> "\"")
& Type.withArguments
( Type.inputValue
"set"
(tableSetInput table)
& Type.inputValueWithDescription "Fields to be updated"
& Type.renameInputValue "_set" "Unify naming with Hasura"
)
& Type.withArguments
( Type.inputValue
"filter"
(filterType table)
& Type.inputValueWithDescription "Filter to select rows to be updated"
& Type.renameInputValue "where" "Unify naming with Hasura"
)
tableUpdateFieldByPk
:: AccessMode
-> [TableEntry]
-> TableEntry
-> Maybe Type.Field
tableUpdateFieldByPk accessMode tables table = do
pkArguments <- tablePKArguments table
pure $
Type.field
( "update_"
<> encodeOutsideTableNames
tables
( doubleXEncodeGql table.name
<> "_by_pk"
)
)
(Type.nonNull $ mutationByPkResponseType accessMode table)
& Type.fieldWithDescription
("Update row in table \"" <> table.name <> "\" by PK")
& Type.withArguments pkArguments
& Type.withArguments
( Type.inputValue
(encodeOutsidePKNames table "set")
(tableSetInput table)
& Type.inputValueWithDescription "Fields to be updated"
& Type.renameInputValue
(encodeOutsidePKNames table "_set")
"Unify naming with Hasura"
)
tableDeleteField :: AccessMode -> TableEntry -> Type.Field
tableDeleteField accessMode table = do
Type.field
("delete_" <> doubleXEncodeGql table.name)
(Type.nonNull $ mutationResponseType accessMode table)
& Type.fieldWithDescription
("Delete rows in table \"" <> table.name <> "\"")
& Type.withArguments
( Type.inputValue
"filter"
(filterType table)
& Type.inputValueWithDescription "Filter to select rows to be deleted"
& Type.renameInputValue "where" "Unify naming with Hasura"
)
tableDeleteFieldByPK
:: AccessMode
-> [TableEntry]
-> TableEntry
-> Maybe Type.Field
tableDeleteFieldByPK accessMode tables table = do
args <- tablePKArguments table
pure $
Type.field
( "delete_"
<> encodeOutsideTableNames
tables
(doubleXEncodeGql table.name <> "_by_pk")
)
(Type.nonNull $ mutationByPkResponseType accessMode table)
& Type.fieldWithDescription
("Delete row in table \"" <> table.name <> "\" by PK")
& Type.withArguments args
directives :: [Type.Directive]
directives =
[ Type.directive
"skip"
["INLINE_FRAGMENT", "FRAGMENT_SPREAD", "FIELD"]
[ Type.inputValue "if" (Type.nonNull Type.typeBool)
& Type.inputValueWithDescription "Skipped when true."
]
& Type.directiveWithDescription
"Directs the executor to skip this field or fragment \
\when the `if` argument is true."
, Type.directive
"include"
["INLINE_FRAGMENT", "FRAGMENT_SPREAD", "FIELD"]
[ Type.inputValue "if" (Type.nonNull Type.typeBool)
& Type.inputValueWithDescription "Included when true."
]
& Type.directiveWithDescription
"Directs the executor to include this field or fragment \
\only when the `if` argument is true."
]
getSchema
:: AccessMode
-> [TableEntry]
-> Type.Schema
getSchema accessMode tables = do
let
queryType = do
P.guard $ canRead accessMode
P.fold
[ tables <&> tableQueryField
, tables & P.mapMaybe (tableQueryByPKField tables)
]
tablesWithoutViews =
List.filter
(\table -> table.object_type == Table)
tables
insertMutations = do
P.guard $ canInsert accessMode
P.fold
[ tablesWithoutViews <&> tableInsertField accessMode
]
writeMutations = do
P.guard $ canWrite accessMode
P.fold
[ tablesWithoutViews <&> tableUpdateField accessMode
, tablesWithoutViews <&> tableDeleteField accessMode
, tablesWithoutViews
& P.mapMaybe (tableUpdateFieldByPk accessMode tables)
, tablesWithoutViews
& P.mapMaybe (tableDeleteFieldByPK accessMode tables)
]
mutationType = insertMutations <> writeMutations
Type.collectSchemaTypes $
Type.Schema
Nothing
[]
(Type.object "Query" queryType)
(Just $ Type.object "Mutation" mutationType)
directives
-- We make this toplevel, because putting it inside `getSchemaResolver`
-- means haskell will evaluate it each time, which leads to each execution
-- taking 2-3 additional seconds
schemaField :: Either Text (Out.Field IO)
schemaField = do
let field = Type.field "__schema" $ Type.nonNull Type.typeSchema
ty <- makeType field.type_
pure $ Out.Field field.description ty mempty
getSchemaResolver
:: AccessMode
-> [TableEntry]
-> IO (HashMap Text (Resolver IO))
getSchemaResolver accessMode tables = do
case schemaField of
Right field -> do
let schema = getSchema accessMode tables
let resolver = Out.ValueResolver field $ pure $ toGraphQL schema
pure $ HashMap.singleton "__schema" resolver
Left err -> fail $ T.unpack err