mirror of
https://github.com/Airsequel/AirGQL.git
synced 2025-07-23 00:24:03 +03:00
545 lines
16 KiB
Haskell
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
|