1
Fork 0
mirror of https://github.com/Airsequel/AirGQL.git synced 2025-08-21 22:06:57 +03:00

Implement querying by PK

- I realised by PK querying doesn't need arguments like filter, limit or
offset, so I removed the argument name collision logic I added in the
last commit
- I implemented the actual logic for querying by PK. The code is a lot
shorter due to the new introspection system
- I moved the normal queries to the new introspection system for
consistency.
- I moved the logic of "coerce columns without a datatype" away from the
resolver and into the "sql -> gql" converter as to be able to reuse the
default introspection resolver which simply looks up fields into the
parent resolver's result.
- I added some comments documenting the Introspection.Resolver stuff and
it's behaviour
This commit is contained in:
prescientmoon 2024-10-09 20:51:29 +02:00
commit 2ee5152d54
3 changed files with 244 additions and 251 deletions
source/AirGQL

View file

@ -75,7 +75,7 @@ import Language.GraphQL.Type as GQL (
EnumValue (EnumValue), EnumValue (EnumValue),
InputField (InputField), InputField (InputField),
Resolver (EventStreamResolver, ValueResolver), Resolver (EventStreamResolver, ValueResolver),
ScalarType, ScalarType (ScalarType),
Schema, Schema,
Value (Boolean, Enum, Float, Int, List, Null, Object, String), Value (Boolean, Enum, Float, Int, List, Null, Object, String),
boolean, boolean,
@ -101,12 +101,15 @@ import AirGQL.GQLWrapper (
inArgumentToArgument, inArgumentToArgument,
outFieldToField, outFieldToField,
) )
import AirGQL.Introspection (getSchemaResolver, typeNameResolver) import AirGQL.Introspection (getSchemaResolver, typeNameResolver)
import AirGQL.Introspection qualified as Introspection
import AirGQL.Introspection.Resolver qualified as Introspection
import AirGQL.Lib ( import AirGQL.Lib (
AccessMode (ReadAndWrite, ReadOnly, WriteOnly), AccessMode (ReadAndWrite, ReadOnly, WriteOnly),
ColumnEntry (column_name, datatype, datatype_gql), ColumnEntry (column_name, datatype, datatype_gql),
GqlTypeName (root), GqlTypeName (root),
TableEntry (name), TableEntry (columns, name),
column_name_gql, column_name_gql,
getColumns, getColumns,
) )
@ -120,10 +123,14 @@ import AirGQL.Types.SchemaConf (
) )
import AirGQL.Types.Utils (encodeToText) import AirGQL.Types.Utils (encodeToText)
import AirGQL.Utils (colToFileUrl, collectErrorList, quoteKeyword, quoteText) import AirGQL.Utils (colToFileUrl, collectErrorList, quoteKeyword, quoteText)
import Data.Either.Extra qualified as Either
typeNameToScalarType :: Maybe GqlTypeName -> ScalarType typeNameToScalarType :: Maybe GqlTypeName -> ScalarType
typeNameToScalarType Nothing = string typeNameToScalarType Nothing =
ScalarType
"any"
(Just "A type that could result to any kind of GQL scalar")
typeNameToScalarType (Just typeName) = typeNameToScalarType (Just typeName) =
case typeName.root of case typeName.root of
"Int" -> int "Int" -> int
@ -195,13 +202,7 @@ colNamesWithValResolver columnEntries =
Object obj -> Object obj ->
case obj & HashMap.lookup colEntry.column_name_gql of case obj & HashMap.lookup colEntry.column_name_gql of
Nothing -> String "Error: Field does not exist" Nothing -> String "Error: Field does not exist"
Just val -> Just val -> val
case colEntry.datatype of
-- Coerce value to nullable String
-- if no datatype is set.
-- This happens for columns in views.
"" -> gqlValueToNullableString val
_ -> val
_ -> String "Error: Value could not be retrieved" _ -> String "Error: Value could not be retrieved"
in in
( colEntry.column_name_gql ( colEntry.column_name_gql
@ -441,141 +442,15 @@ queryType connection accessMode dbId tables = do
documentation = documentation =
"Available queries for database \"" <> dbId <> "\"" "Available queries for database \"" <> dbId <> "\""
getOutField :: Text -> IO (Out.Field IO) getOutField :: TableEntry -> IO (Out.Field IO)
getOutField tableName = do getOutField table =
columnEntries <- liftIO $ getColumns dbId connection tableName case Introspection.makeField $ Introspection.tableQueryField table of
Left err -> P.throwIO $ userError $ T.unpack err
Right result -> pure result
let getDbEntries :: TableEntry -> Out.Resolve IO
colNamesWithOrderingTerm :: [(Text, InputField)] getDbEntries table = do
colNamesWithOrderingTerm =
columnEntries <&> \colEntry ->
( colEntry.column_name_gql
, InputField
(Just $ "Ordering term for " <> colEntry.column_name_gql)
( In.NamedEnumType $
EnumType
"OrderingTerm"
(Just "Ordering object for the column")
( HashMap.fromList
[ ("ASC", EnumValue (Just "ASC"))
, ("asc", EnumValue (Just "ASC"))
, ("DESC", EnumValue (Just "DESC"))
, ("desc", EnumValue (Just "DESC"))
]
)
)
Nothing -- Default value
)
typeNameField :: Text -> [(Text, Resolver IO)]
typeNameField nameOfTable =
let
typeNameOutField =
outFieldToField $
OutField
{ descriptionMb = Just $ "The type name of " <> nameOfTable
, fieldType = Out.NonNullScalarType string
, arguments = HashMap.empty
}
in
[
( "__typename"
, ValueResolver typeNameOutField $
pure $
String $
doubleXEncodeGql nameOfTable <> "_row"
)
]
pure $
outFieldToField $
OutField
{ descriptionMb = Just $ "Provides entries from " <> tableName
, fieldType =
Out.ListType $
Out.NamedObjectType $
Out.ObjectType
tableName
(Just "short desc")
[]
( HashMap.fromList $
colNamesWithValResolver columnEntries
<> typeNameField tableName
)
, arguments =
HashMap.fromList
[
( "filter"
, inArgumentToArgument $
InArgument
{ argDescMb = Just "Filter objects"
, argType =
NamedInputObjectType $
InputObjectType
(doubleXEncodeGql tableName <> "_filter")
( Just
"Filter objects for the specified columns"
)
(HashMap.fromList (colNamesWithFilterField tableName columnEntries))
, valueMb = Nothing
}
)
,
( "order_by"
, inArgumentToArgument $
InArgument
{ argDescMb = Just "Order by the specified columns"
, argType =
In.ListType $
In.NamedInputObjectType $
InputObjectType
(doubleXEncodeGql tableName <> "_order_by")
(Just "Options for ordering by columns")
(HashMap.fromList colNamesWithOrderingTerm)
, valueMb = Nothing
}
)
,
( "limit"
, inArgumentToArgument $
InArgument
{ argDescMb =
Just "Limit the number of returned rows."
, argType = In.NamedScalarType int
, valueMb = Nothing
}
)
,
( "offset"
, inArgumentToArgument $
InArgument
{ argDescMb =
Just
"Change the index rows \
\start being returned from"
, argType = In.NamedScalarType int
, valueMb = Nothing
}
)
]
}
-- -- TODO: Use for retrieving record by primary key
-- , arguments = HashMap.fromList $ columnEntries
-- <&> (\colEntry ->
-- ( colEntry.column_name_gql :: Text
-- , inArgumentToArgument $ InArgument
-- { argDescMb = Just "Retrieve object by primary key"
-- , argType = In.NamedScalarType $
-- typeNameToScalarType $ colEntry.datatype
-- , valueMb = Nothing
-- }
-- )
-- )
getDbEntries :: Text -> Out.Resolve IO
getDbEntries tableName = do
context <- ask context <- ask
colEntries <- liftIO $ getColumns dbId connection tableName
rows :: [[SQLData]] <- case context.arguments of rows :: [[SQLData]] <- case context.arguments of
Arguments args -> do Arguments args -> do
@ -647,7 +522,7 @@ queryType connection accessMode dbId tables = do
Query $ Query $
P.fold P.fold
[ "SELECT COUNT() FROM" [ "SELECT COUNT() FROM"
, quoteKeyword tableName , quoteKeyword table.name
, "\n" , "\n"
, getWhereClause filterElements , getWhereClause filterElements
] ]
@ -682,68 +557,105 @@ queryType connection accessMode dbId tables = do
, show numRows , show numRows
, " rows. " , " rows. "
, "Consider setting the `limit` argument on your query: `{ " , "Consider setting the `limit` argument on your query: `{ "
, T.unpack tableName , T.unpack table.name
, " (limit: 50) { ... } }`" , " (limit: 50) { ... } }`"
] ]
liftIO $ liftIO $
executeSqlQuery executeSqlQuery
connection connection
tableName table.name
colEntries table.columns
filterElements filterElements
orderElements orderElements
paginationMb paginationMb
rowsToList dbId tableName colEntries rows colErrorsToUserError $ rowsToGraphQL dbId table.name table.columns rows
getOutByPKField :: TableEntry -> IO (Maybe (Out.Field IO))
getOutByPKField tableEntry = do
let fieldMb = Introspection.tableQueryByPKField tableEntry
P.for fieldMb $ \field -> do
let fieldEither = Introspection.makeField field
case fieldEither of
Left err -> P.throwIO $ userError $ T.unpack err
Right result -> pure result
getDbEntriesByPK :: TableEntry -> Out.Resolve IO
getDbEntriesByPK tableEntry = do
context <- ask
let
Arguments args = context.arguments
filterElements = do
(key, value) <- HashMap.toList args
pure (key, Object $ HashMap.singleton "eq" value)
-- This query can return at most one row, so we don't worry checking for
-- COUNT() and asserting it's within the set limits.
queryResult <-
liftIO $
executeSqlQuery
connection
tableEntry.name
tableEntry.columns
filterElements
[]
Nothing
case P.head queryResult of
Nothing -> pure Null
Just row ->
colErrorsToUserError $
rowToGraphQL
dbId
tableEntry.name
tableEntry.columns
row
getResolvers :: IO (HashMap.HashMap Text (Resolver IO)) getResolvers :: IO (HashMap.HashMap Text (Resolver IO))
getResolvers = do getResolvers = do
let let
-- Exceptions must be converted to ResolverExceptions
-- to be picked up by GQL query executor
wrapResolver :: Out.Resolve IO -> Out.Resolve IO
wrapResolver resolver =
catchAll
resolver
(throw . ResolverException)
getTableTuple :: TableEntry -> IO (Text, Resolver IO) getTableTuple :: TableEntry -> IO (Text, Resolver IO)
getTableTuple table = do getTableTuple table = do
outField <- getOutField table.name outField <- getOutField table
pure pure
( doubleXEncodeGql table.name ( doubleXEncodeGql table.name
, ValueResolver , ValueResolver
outField outField
( -- Exceptions must be converted to ResolverExceptions $ wrapResolver
-- to be picked up by GQL query executor $ getDbEntries table
catchAll
(getDbEntries table.name)
(throw . ResolverException)
)
) )
getTableTuples :: IO [(Text, Resolver IO)] getTableByPKTuple :: TableEntry -> IO (Maybe (Text, Resolver IO))
getTableTuples = getTableByPKTuple table = do
P.for tables getTableTuple fieldMb <- getOutByPKField table
P.for fieldMb $ \outField -> do
pure
( doubleXEncodeGql $ table.name <> "_by_pk"
, ValueResolver
outField
$ wrapResolver
$ getDbEntriesByPK table
)
getTableTuples <&> HashMap.fromList queryMany <- P.for tables getTableTuple
queryByPKMbs <- P.for tables getTableByPKTuple
-- -- TODO: Add support for retriving record by ID let queryByPK = P.catMaybes queryByPKMbs
-- getResolversPrimaryKey :: IO (HashMap.HashMap Text (Resolver IO)) pure $ HashMap.fromList $ queryMany <> queryByPK
-- getResolversPrimaryKey = do
-- let
-- getTableTuple table = do
-- outField <- getOutField $ table.name
-- pure
-- ( table.name) <> "_by_pk"
-- , ValueResolver
-- outField
-- (getDbEntries $ table.name)
-- )
-- getTableTuples :: IO [(Text, Resolver IO)]
-- getTableTuples =
-- sequence $ tables <&> getTableTuple
-- getTableTuples <&> HashMap.fromList
resolvers <- getResolvers resolvers <- getResolvers
schemaResolver <- getSchemaResolver accessMode tables schemaResolver <- getSchemaResolver accessMode tables
-- resolversPrimaryKey <- getResolversPrimaryKey
let let
-- Resolve = ReaderT Context m Value -- Resolve = ReaderT Context m Value
wrapResolve resolve = do wrapResolve resolve = do
@ -923,8 +835,64 @@ getMutationResponse accessMode tableName columnEntries =
} }
rowsToList :: (MonadIO m) => Text -> Text -> [ColumnEntry] -> [[SQLData]] -> m Value rowToGraphQL :: Text -> Text -> [ColumnEntry] -> [SQLData] -> Either [(Text, Text)] Value
rowsToList dbId tableName columnEntries updatedRows = rowToGraphQL dbId tableName columnEntries row =
let
buildMetadataJson :: Text -> Text -> Text
buildMetadataJson colName rowid =
object ["url" .= colToFileUrl dbId tableName colName rowid]
& encodeToText
parseSqlData :: (ColumnEntry, SQLData) -> Either (Text, Text) (Text, Value)
parseSqlData (colEntry, colVal) =
if "BLOB" `T.isPrefixOf` colEntry.datatype
then
pure
( colEntry.column_name_gql
, case colVal of
SQLNull -> Null
SQLInteger id ->
String $
buildMetadataJson colEntry.column_name (show id)
SQLText id ->
String $
buildMetadataJson colEntry.column_name id
_ -> Null
)
else case sqlDataToGQLValue colEntry.datatype colVal of
Left err ->
Left
(colEntry.column_name_gql, err)
Right gqlData ->
Right
( colEntry.column_name_gql
, case colEntry.datatype of
-- Coerce value to nullable String
-- if no datatype is set.
-- This happens for columns in views.
"" -> gqlValueToNullableString gqlData
_ -> gqlData
)
in
-- => [(ColumnEntry, SQLData)]
P.zip columnEntries row
-- => [Either (Text, Text) (Text, Value)]
<&> parseSqlData
-- => Either [(Text, Text)] (Text, Value)
& collectErrorList
-- => Either [(Text, Text)] (HashMap Text Value)
<&> HashMap.fromList
-- => Either [(Text, Text)] Value
<&> Object
rowsToGraphQL
:: Text
-> Text
-> [ColumnEntry]
-> [[SQLData]]
-> Either [(Text, Text)] Value
rowsToGraphQL dbId tableName columnEntries updatedRows =
let let
buildMetadataJson :: Text -> Text -> Text buildMetadataJson :: Text -> Text -> Text
buildMetadataJson colName rowid = buildMetadataJson colName rowid =
@ -970,18 +938,26 @@ rowsToList dbId tableName columnEntries updatedRows =
) )
-- => Either [[(Text, Text)]] [Value] -- => Either [[(Text, Text)]] [Value]
& collectErrorList & collectErrorList
& \case -- => Either [(Text, Text)] [Value]
Right values -> pure $ List values & Either.mapLeft P.join
Left errors -> -- => Either [(Text, Text)] Value
let <&> List
errorLines =
P.join errors
<&> \(column, err) -> "On column " <> show column <> ": " <> err -- | Formats errors from `row(s)ToGraphQL` and throws them.
in colErrorsToUserError :: forall m a. (MonadIO m) => Either [(Text, Text)] a -> m a
P.throwIO $ colErrorsToUserError = \case
userError $ Right v -> pure v
T.unpack $ Left errors ->
"Multiple errors occurred:\n" <> P.unlines errorLines let
errorLines =
errors
<&> \(column, err) -> "On column " <> show column <> ": " <> err
in
P.throwIO $
userError $
T.unpack $
"Multiple errors occurred:\n" <> P.unlines errorLines
executeSqlMutation executeSqlMutation
@ -1403,7 +1379,9 @@ mutationType connection maxRowsPerTable accessMode dbId tables = do
pure (P.length sqlDataRows, returnedRows & P.concat) pure (P.length sqlDataRows, returnedRows & P.concat)
(numOfChanges, returnedRows) <- insertInDb context.arguments (numOfChanges, returnedRows) <- insertInDb context.arguments
returning <- rowsToList dbId tableName columnEntries returnedRows returning <-
colErrorsToUserError $
rowsToGraphQL dbId tableName columnEntries returnedRows
pure $ pure $
Object $ Object $
@ -1434,7 +1412,9 @@ mutationType connection maxRowsPerTable accessMode dbId tables = do
filterElements filterElements
_ -> pure (0, []) _ -> pure (0, [])
returning <- rowsToList dbId tableName columnEntries updatedRows returning <-
colErrorsToUserError $
rowsToGraphQL dbId tableName columnEntries updatedRows
pure $ pure $
Object $ Object $
@ -1491,7 +1471,9 @@ mutationType connection maxRowsPerTable accessMode dbId tables = do
_ -> pure (0, []) _ -> pure (0, [])
Nothing -> pure (0, []) Nothing -> pure (0, [])
returning <- rowsToList dbId tableName columnEntries deletedRows returning <-
colErrorsToUserError $
rowsToGraphQL dbId tableName columnEntries deletedRows
pure $ pure $
Object $ Object $

View file

@ -1,6 +1,8 @@
module AirGQL.Introspection ( module AirGQL.Introspection (
typeNameResolver, typeNameResolver,
getSchemaResolver, getSchemaResolver,
tableQueryField,
tableQueryByPKField,
) )
where where
@ -131,8 +133,8 @@ tableRowType table = do
Type.object (doubleXEncodeGql table.name <> "_row") fields Type.object (doubleXEncodeGql table.name <> "_row") fields
tableQueryCommonArgs :: TableEntry -> [Type.InputValue] tableQueryField :: TableEntry -> Type.Field
tableQueryCommonArgs table = tableQueryField table =
let let
fieldsWithOrderingTerm = fieldsWithOrderingTerm =
table.columns <&> \columnEntry -> do table.columns <&> \columnEntry -> do
@ -149,62 +151,47 @@ tableQueryCommonArgs table =
<> "\"." <> "\"."
) )
in in
[ Type.inputValue "filter" (filterType table) Type.field
& Type.inputValueWithDescription "Filter to select specific rows" (doubleXEncodeGql table.name)
, Type.inputValue "order_by" (Type.list orderType) (Type.nonNull $ Type.list $ Type.nonNull $ tableRowType table)
& Type.inputValueWithDescription "Columns used to sort the data" & Type.fieldWithDescription ("Rows from the table \"" <> table.name <> "\"")
, Type.inputValue "limit" Type.typeInt & Type.withArguments
& Type.inputValueWithDescription "Limit the number of returned rows" [ Type.inputValue "filter" (filterType table)
, Type.inputValue "offset" Type.typeInt & Type.inputValueWithDescription "Filter to select specific rows"
& Type.inputValueWithDescription "The index to start returning rows from" , 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"
]
tableQueryField :: TableEntry -> Type.Field tableQueryByPKField :: TableEntry -> Maybe Type.Field
tableQueryField table = tableQueryByPKField table = do
Type.field
(doubleXEncodeGql table.name)
(Type.nonNull $ Type.list $ Type.nonNull $ tableRowType table)
& Type.fieldWithDescription ("Rows from the table \"" <> table.name <> "\"")
& Type.withArguments (tableQueryCommonArgs table)
restrictedArgNames :: [Text]
restrictedArgNames = ["limit", "offset", "order_by", "filter"]
mkArgName :: Text -> Text
mkArgName name = do
let encoded = doubleXEncodeGql name
if P.elem encoded restrictedArgNames
then encoded <> "_"
else encoded
tableQueryByPk :: TableEntry -> Type.Field
tableQueryByPk table = do
let pks = List.filter (\col -> col.primary_key) table.columns let pks = List.filter (\col -> col.primary_key) table.columns
-- We filter out the rowid column, unless it is the only one -- We filter out the rowid column, unless it is the only one
let withoutRowid = case pks of withoutRowid <- case pks of
[first] | first.isRowid -> [first] [] -> Nothing
_ -> List.filter (\col -> P.not col.isRowid) pks [first] | first.isRowid -> Just [first]
_ -> Just $ List.filter (\col -> P.not col.isRowid) pks
let pkArguments = let pkArguments =
withoutRowid <&> \column -> do withoutRowid <&> \column -> do
let name = mkArgName column.column_name_gql let name = doubleXEncodeGql column.column_name_gql
Type.inputValue name $ Type.nonNull $ columnType column Type.inputValue name $ Type.nonNull $ columnType column
Type.field pure $
(doubleXEncodeGql table.name <> "_by_pk") Type.field
(tableRowType table) (doubleXEncodeGql table.name <> "_by_pk")
& Type.fieldWithDescription (tableRowType table)
( "Rows from the table \"" & Type.fieldWithDescription
<> table.name ( "Rows from the table \""
<> "\", accessible by their primary key" <> table.name
) <> "\", accessible by their primary key"
& Type.withArguments (tableQueryCommonArgs table) )
& Type.withArguments pkArguments & Type.withArguments pkArguments
mutationResponseType :: AccessMode -> TableEntry -> Type.IntrospectionType mutationResponseType :: AccessMode -> TableEntry -> Type.IntrospectionType
@ -363,7 +350,7 @@ getSchema accessMode tables = do
then then
P.fold P.fold
[ tables <&> tableQueryField [ tables <&> tableQueryField
, tables <&> tableQueryByPk , tables & P.mapMaybe tableQueryByPKField
] ]
else [] else []

View file

@ -1,4 +1,8 @@
module AirGQL.Introspection.Resolver (makeType, makeConstField) where module AirGQL.Introspection.Resolver (
makeType,
makeConstField,
makeField,
) where
import Protolude ( import Protolude (
Either (Left), Either (Left),
@ -7,7 +11,6 @@ import Protolude (
MonadReader (ask), MonadReader (ask),
Text, Text,
fromMaybe, fromMaybe,
mempty,
pure, pure,
show, show,
($), ($),
@ -29,6 +32,12 @@ import Language.GraphQL.Type.Out qualified as Out
type Result = Either Text type Result = Either Text
{-| Turns a type descriptor into a graphql output type, erroring out on input
types. Child resolvers look up their respective fields in the value produced by
their parent.
Lookups for `__Type` objects are memoized, and a maximum depth of 30 is enforced.
-}
makeType :: IType.IntrospectionType -> Result (Out.Type IO) makeType :: IType.IntrospectionType -> Result (Out.Type IO)
makeType = makeType =
let let
@ -91,8 +100,7 @@ makeType =
$ Out.NamedObjectType $ Out.NamedObjectType
$ Type.ObjectType $ Type.ObjectType
name name
-- ty.description ty.description
P.Nothing
[] []
$ HashMap.fromList $ HashMap.fromList
$ ("__typename", typenameResolver) : resolvers $ ("__typename", typenameResolver) : resolvers
@ -131,13 +139,29 @@ makeType =
makeTypeWithDepth 0 makeTypeWithDepth 0
{-| Turns a field descriptor into a graphql field. See the documentation
for `makeType` for details about the behaviour of child resolvers.
-}
makeField :: IType.Field -> Result (Out.Field IO)
makeField field = do
args <- P.for field.args $ \arg -> do
ty <- makeInType arg.type_
pure (arg.name, In.Argument arg.description ty arg.defaultValue)
ty <- makeType field.type_
pure $ Out.Field field.description ty $ HashMap.fromList args
-- | Create a resolver by calling which always returns a constant value.
makeConstField :: IType.Field -> Type.Value -> Result (Out.Resolver IO) makeConstField :: IType.Field -> Type.Value -> Result (Out.Resolver IO)
makeConstField field value = do makeConstField field value = do
ty <- makeType field.type_ gqlField <- makeField field
let gqlField = Out.Field field.description ty mempty
pure $ Out.ValueResolver gqlField $ pure value pure $ Out.ValueResolver gqlField $ pure value
{-| The input-type version of `makeOutType`. No maximum depth is enforced, nor
is any memoization used. This is the case because input types are usually pretty
shallow.
-}
makeInType :: IType.IntrospectionType -> Result In.Type makeInType :: IType.IntrospectionType -> Result In.Type
makeInType ty = do makeInType ty = do
case ty.kind of case ty.kind of