Safe Haskell | None |
---|---|
Language | Haskell2010 |
Type-safe DSL for Cypher
This module extends selectors from DSL
with extra type-level
information to make them more type-safe to use.
None of additional type information exists at runtime, so using this module does not degrade performance at all.
Synopsis
- class SelectorLike (a :: k -> Type) where
- type CanAddType (types :: k) :: Constraint
- type AddType (types :: k) (typ :: Type) = (result :: k) | result -> types typ
- type HasField (types :: k) (field :: Symbol) (typ :: Type) :: Constraint
- type HasField' (types :: k) (field :: Symbol) :: Constraint
- withIdentifier :: Text -> a types -> a types
- withLabel :: CanAddType types => KnownSymbol (GetTypeName (Rep typ)) => a types -> a (AddType types typ)
- withProp :: HasField types field typ => IsValue typ => (SymbolS field, typ) -> a types -> a types
- withParam :: HasField' types field => (SymbolS field, Text) -> a types -> a types
- type LabelConstraint (typ :: Type) = KnownSymbol (GetTypeName (Rep typ))
- lbl :: forall (typ :: Type) k (types :: k) (a :: k -> Type). SelectorLike a => CanAddType types => KnownSymbol (GetTypeName (Rep typ)) => a types -> a (AddType types typ)
- prop :: forall (field :: Symbol) k (a :: k -> Type) (types :: k) (typ :: Type). SelectorLike a => HasField types field typ => IsValue typ => (SymbolS field, typ) -> a types -> a types
- propMaybe :: forall (field :: Symbol) k (a :: k -> Type) (types :: k) (typ :: Type). SelectorLike a => HasField types field typ => IsValue typ => (SymbolS field, Maybe typ) -> a types -> a types
- param :: forall (field :: Symbol) k (a :: k -> Type) (types :: k). SelectorLike a => HasField' types field => (SymbolS field, Text) -> a types -> a types
- type (=:) (a :: k) (b :: l) = '(a, b)
- (=:) :: forall (field :: Symbol) (typ :: Type). SymbolS field -> typ -> (SymbolS field, typ)
- data NodeSelector (typ :: [Type])
- data RelSelector (typ :: Maybe Type)
- nodeSelector :: NodeSelector typ -> NodeSelector
- relSelector :: RelSelector typ -> RelSelector
- defN :: NodeSelector '[]
- defR :: RelSelector 'Nothing
- (.&) :: a -> (a -> b) -> b
- (!->:) :: RelSelector a -> NodeSelector b -> PathPart
- (!-:) :: RelSelector a -> NodeSelector b -> PathPart
- (-:) :: NodeSelector a -> PathPart -> PathSelector
- (<-:) :: NodeSelector a -> PathPart -> PathSelector
- p :: NodeSelector a -> PathSelector
- newtype CypherDSLParams (params :: [(Symbol, Type)]) (a :: Type) = CypherDSLParams (CypherDSL a)
- queryWithParams :: forall params m fun. MonadIO m => QueryWithParams params m fun => HasCallStack => CypherDSLParams params () -> fun
- class QueryWithParams (params :: [(Symbol, Type)]) (m :: Type -> Type) fun | params m -> fun where
- collectParams :: HasCallStack => CypherDSL () -> [(Text, Value)] -> fun
Selecting Nodes and Relations
There are types for Node and Relationship selectors: NodeSelector
and RelSelector
.
Both of them carry extra type-level information about labels assigned to Cypher variables.
Empty selectors may be constructed with defN
and defR
respectively. Selectors can be
extended with the following combinators:
withIdentifier
adds an identifier (variable name)lbl
adds a label represented by some Haskell typeprop
adds a new property, making sure that this property exists in one of the labels and has correct typeparam
adds a new property with named parameter ($foo
syntax in Cypher), making sure that this property exists in one of the labels
Typically selectors are chained by .&
starting from defN
or defR
like this:
>>>
toCypherN $ defN .& withIdentifier "binder" .& lbl @Binder .& prop (#uuid =: "123-456")
(binder:Binder{uuid:"123-456"})
Alternatively, OverloadedLabels
may be used to create an empty selector with an identifier:
>>>
toCypherN $ #binder .& lbl @Binder .& prop (#uuid =: "123-456")
(binder:Binder{uuid:"123-456"})
This syntax is more concise and makes it obvious what is going on. Thus, it is the preferred one.
The type used with lbl
should have Generic
instance.
Nodes may have multiple labels:
>>>
toCypherN $ defN .& lbl @Binder .& lbl @Foo
(:Foo:Binder)
But relations have at most one:
>>>
defR .& lbl @PLACE .& lbl @ELEMENT
... ... Can't add a new label to relationship selector that already has label PLACE! ...
Complex queries
These selectors are fully compatible with the Database.Bolt.Extras.DSL:
>>>
:{
toCypherQ $ do mergeF [ PS $ p $ #name .& lbl @Name .& prop (#name =: "CT42") ] mergeF [ PS $ p $ #user .& lbl @User .& prop (#user =: "123-456") ] createF [ PS $ p $ #lib .& lbl @Library .& lbl @BinderLibrary , PS $ #name -: defR .& lbl @NAME_OF !->: #lib , PS $ #user -: defR .& lbl @USER_CREATED .& prop (#timestamp =: 1572340394000) !->: #lib ] returnF ["lib"] :} MERGE (name:Name{name:"CT42"}) MERGE (user:User{user:"123-456"}) CREATE (lib:BinderLibrary:Library), (name)-[:NAME_OF]->(lib), (user)-[:USER_CREATED{timestamp:1572340394000}]->(lib) RETURN lib
Dropping types
It is possible to convert typed selectors to untyped ones from Database.Bolt.Extras.DSL using
nodeSelector
and relSelector
funcions.
Using with Graph api
This module is also interopable with Database.Bolt.Extras.Graph API. Here is an example of graph query using typed selectors.
>>>
import Database.Bolt.Extras.Graph
>>>
nToG = ngFromDSL . nodeSelector
>>>
rToG = rgFromDSL . relSelector
>>>
:{
formQueryG $ emptyGraph & addNode "binder" (nToG (defN .& lbl @Binder .& prop (#uuid =: "123-456")) & isReturned & withReturn allProps ) & addNode "user" (nToG (defN .& lbl @User .& prop (#user =: "098-765")) & isReturned & withReturn allProps ) & addRelation "user" "binder" (rToG (defR .& lbl @USER_CREATED) & isReturned & withReturn allProps ) :} MATCH (user)-[user0binder :USER_CREATED {}]->(binder) , (binder :Binder {uuid:"123-456"}) , (user :User {user:"098-765"}) WITH DISTINCT binder, user, user0binder RETURN { id: id(binder), labels: labels(binder), props: properties(binder) } as binder , { id: id(user), labels: labels(user), props: properties(user) } as user , { id: id(user0binder), label: type(user0binder), props: properties(user0binder) } as user0binder
Type safety
Obviosuly, if you try to use lbl @Foo
syntax with undefined type Foo
, GHC itself
will report the error.
Here are more interesting cases:
>>>
-- Properties are looked for in all labels
>>>
toCypherN $ defN .& lbl @Binder .& lbl @Foo .& prop (#foo =: 42) .& prop (#uuid =: "123-456")
(:Foo:Binder{uuid:"123-456",foo:42})
>>>
-- Adding a property to node without any labels
>>>
defN .& prop (#uuid =: "123-456")
... ... There is no field "uuid" in any of the records ... '[] ...
>>>
-- Adding a property that does not exist in the label
>>>
defN .& lbl @Binder .& prop (#foo =: 42)
... ... There is no field "foo" in any of the records ... '[Binder] ...
>>>
-- Adding a property with wrong type
>>>
defN .& lbl @Binder .& prop (#uuid =: 42)
... ... No instance for (Num Text) arising from the literal ‘42’ ...
Here we see that GHC undestands that the property should have type Text
and tries to unify it with
the type of literal 42
, which is Num a => a
.
>>>
-- Adding a property to relationship without a label
>>>
defR .& prop (#foo =: 42)
... ... Tried to set property "foo" on a relationship without label! ...
class SelectorLike (a :: k -> Type) where Source #
Class for Selectors that know type of their labels. This class is kind-polymorphic, so that instances may select a specific collection of labels they support.
NOTE: Due to the way GHC orders type variables for class methods, it's more convenient
to use lbl
and prop
synonyms defined below, and withLabel
and withProp
methods
should be considered an implementation detail.
type CanAddType (types :: k) :: Constraint Source #
This constraint checks that current collection of types supports adding one more.
type AddType (types :: k) (typ :: Type) = (result :: k) | result -> types typ Source #
This type family implements adding a new type (of label) to the collection.
Injectivity annotation is required to make type inference possible.
type HasField (types :: k) (field :: Symbol) (typ :: Type) :: Constraint Source #
This constraint checks that field with this name has correct type in the collection of labels.
type HasField' (types :: k) (field :: Symbol) :: Constraint Source #
This constraint checks that field with this name exists in the collection, with any type.
withIdentifier :: Text -> a types -> a types Source #
Set an identifier — Cypher variable name.
withLabel :: CanAddType types => KnownSymbol (GetTypeName (Rep typ)) => a types -> a (AddType types typ) Source #
Add a new label, if possible.
withProp :: HasField types field typ => IsValue typ => (SymbolS field, typ) -> a types -> a types Source #
Add a property with value, checking that such property exists.
withParam :: HasField' types field => (SymbolS field, Text) -> a types -> a types Source #
Add a property as named parameter ($foo
). Only checks that given property exists,
no matter its type.
Instances
SelectorLike NodeSelector Source # | |
Defined in Database.Bolt.Extras.DSL.Typed.Instances type CanAddType types Source # type AddType types typ = (result :: k) Source # withIdentifier :: forall (types :: k). Text -> NodeSelector types -> NodeSelector types Source # withLabel :: forall (types :: k) typ. (CanAddType types, KnownSymbol (GetTypeName (Rep typ))) => NodeSelector types -> NodeSelector (AddType types typ) Source # withProp :: forall (types :: k) (field :: Symbol) typ. (HasField types field typ, IsValue typ) => (SymbolS field, typ) -> NodeSelector types -> NodeSelector types Source # withParam :: forall (types :: k) (field :: Symbol). HasField' types field => (SymbolS field, Text) -> NodeSelector types -> NodeSelector types Source # | |
SelectorLike RelSelector Source # | |
Defined in Database.Bolt.Extras.DSL.Typed.Instances type CanAddType types Source # type AddType types typ = (result :: k) Source # withIdentifier :: forall (types :: k). Text -> RelSelector types -> RelSelector types Source # withLabel :: forall (types :: k) typ. (CanAddType types, KnownSymbol (GetTypeName (Rep typ))) => RelSelector types -> RelSelector (AddType types typ) Source # withProp :: forall (types :: k) (field :: Symbol) typ. (HasField types field typ, IsValue typ) => (SymbolS field, typ) -> RelSelector types -> RelSelector types Source # withParam :: forall (types :: k) (field :: Symbol). HasField' types field => (SymbolS field, Text) -> RelSelector types -> RelSelector types Source # |
type LabelConstraint (typ :: Type) = KnownSymbol (GetTypeName (Rep typ)) Source #
Constraint for types that may be used with lbl
.
lbl :: forall (typ :: Type) k (types :: k) (a :: k -> Type). SelectorLike a => CanAddType types => KnownSymbol (GetTypeName (Rep typ)) => a types -> a (AddType types typ) Source #
Synonym for withLabel
with label type variable as first one, enabling lbl @Foo
type
application syntax.
:: forall (field :: Symbol) k (a :: k -> Type) (types :: k) (typ :: Type). SelectorLike a | |
=> HasField types field typ | |
=> IsValue typ | |
=> (SymbolS field, typ) | Field name along with its value. This pair should be constructed with |
-> a types | |
-> a types |
Shorter synonym for withProp
.
Properties of type Maybe a
are treated as properties of type a
, since there is no difference
between the two in Cypher.
>>>
data Foo = Foo { foo :: Int, bar :: Maybe String } deriving Generic
>>>
toCypherN $ defN .& lbl @Foo .& prop (#foo =: 42)
(:Foo{foo:42})>>>
toCypherN $ defN .& lbl @Foo .& prop (#bar =: "hello")
(:Foo{bar:"hello"})
propMaybe :: forall (field :: Symbol) k (a :: k -> Type) (types :: k) (typ :: Type). SelectorLike a => HasField types field typ => IsValue typ => (SymbolS field, Maybe typ) -> a types -> a types Source #
A variant of prop
that accepts values in Maybe
. If given Nothing
, does nothing.
This works both for properties with Maybe
and without.
>>>
data Foo = Foo { foo :: Int, bar :: Maybe String } deriving Generic
>>>
toCypherN $ defN .& lbl @Foo .& propMaybe (#foo =: Just 42)
(:Foo{foo:42})>>>
toCypherN $ defN .& lbl @Foo .& propMaybe (#bar =: Nothing)
(:Foo)
param :: forall (field :: Symbol) k (a :: k -> Type) (types :: k). SelectorLike a => HasField' types field => (SymbolS field, Text) -> a types -> a types Source #
Shorter synonym for withParam
.
>>>
data Foo = Foo { foo :: Int, bar :: Maybe String } deriving Generic
>>>
toCypherN $ defN .& lbl @Foo .& param (#foo =: "foo")
(:Foo{foo:$foo})>>>
toCypherN $ defN .& lbl @Foo .& prop (#foo =: 42) .& param (#bar =: "bar")
(:Foo{foo:42,bar:$bar})>>>
toCypherN $ defN .& lbl @Foo .& param (#baz =: "baz")
... ... There is no field "baz" in any of the records ... '[Foo] ...
NOTE: this will add $
symbol to parameter name automatically.
type (=:) (a :: k) (b :: l) = '(a, b) Source #
Smart constructor for type-level tuples, to avoid writing '("foo", Int)
with extra tick.
(=:) :: forall (field :: Symbol) (typ :: Type). SymbolS field -> typ -> (SymbolS field, typ) Source #
Smart constructor for a pair of field name and its value. To be used with OverloadedLabels
:
#uuid =: "123"
data NodeSelector (typ :: [Type]) Source #
A wrapper around NodeSelector
with phantom type.
Node selectors remember arbitrary number of labels in a type-level list.
Instances
data RelSelector (typ :: Maybe Type) Source #
A wrapper around RelSelector
with phantom type.
Relationship selectors remember at most one label in a type-level Maybe
.
Instances
nodeSelector :: NodeSelector typ -> NodeSelector Source #
Convert to untyped NodeSelector
.
relSelector :: RelSelector typ -> RelSelector Source #
Convert to untyped RelSelector
.
defN :: NodeSelector '[] Source #
An empty NodeSelector
.
defR :: RelSelector 'Nothing Source #
An empty RelSelector
.
Building paths
This module is completely interopable with path selectors from Database.Bolt.Extras.DSL —
adding a NodeSelector
or RelSelector
to path simply drops all type information, converting it
into untyped variant.
Due to limitation of what symbols are allowed in operators and operator-like data constructors, this
module renames some of the path constructors. Precedence of the operators allow them to be combined
in the same expression with .&
and $
without any extra parentheses.
Here is an example of a path constructed this way:
>>>
toCypherP (#binder .& lbl @Binder .& prop (#uuid =: "123") -: defR .& lbl @ELEMENT !->: #el)
(binder:Binder{uuid:"123"})-[:ELEMENT]->(el)
(.&) :: a -> (a -> b) -> b infixl 3 Source #
This is the same as &
, but with higher precedence, so that it binds before
path combinators.
(!->:) :: RelSelector a -> NodeSelector b -> PathPart infixl 2 Source #
See :!->:
. This combinator forgets type-level information from the selectors.
(!-:) :: RelSelector a -> NodeSelector b -> PathPart infixl 2 Source #
See :!-:
. This combinator forgets type-level information from the selectors.
(-:) :: NodeSelector a -> PathPart -> PathSelector infixl 1 Source #
See -:
. This combinator forgets type-level information from the selectors.
(<-:) :: NodeSelector a -> PathPart -> PathSelector infixl 1 Source #
See <-:
. This combinator forgets type-level information from the selectors.
p :: NodeSelector a -> PathSelector Source #
See P
. This combinator forgets type-level information from the selectors.
Queries with parameters
There is an option to annotate queries (CypherDSL
) with parameters they accept,
like this:
fooQ :: CypherDSLParams '["foo" =: Int, "bar" =: Text] fooQ = CypherDSLParams $ do matchF [ PS $ p $ #n .& lbl @Foo .& param (#foo =: "foo") .& param (#bar =: "bar") returnF ["n"]
This will render to the following Cypher expression:
match (n: Foo {foo: $foo, bar: $bar}) return n
To make sure that all parameters are filled, use queryWithParams
function:
records <- queryWithParams fooQ (#foo =: 42) (#bar =: "Hello")
See below for more examples.
newtype CypherDSLParams (params :: [(Symbol, Type)]) (a :: Type) Source #
A wrapper around arbitrary CypherDSL
expression which stores type-level list of named
parameters ($foo
) with their types.
It is convenient to write signatures using (=:)
type synonym.
queryWithParams :: forall params m fun. MonadIO m => QueryWithParams params m fun => HasCallStack => CypherDSLParams params () -> fun Source #
Run a query (in the form of CypherDSLParams
). This is a function of variable number of arguments.
Actual number will be determined by type-level list params
.
A couple of examples:
>>>
dsl = CypherDSLParams (returnF []) :: CypherDSLParams '["foo" =: Int, "bar" =: Text] ()
>>>
:t queryWithParams dsl
queryWithParams dsl :: MonadIO m => (SymbolS "foo", Int) -> (SymbolS "bar", Text) -> BoltActionT m [Record]>>>
:t queryWithParams dsl (#foo =: 42)
queryWithParams dsl (#foo =: 42) :: MonadIO m => (SymbolS "bar", Text) -> BoltActionT m [Record]>>>
:t queryWithParams dsl (#foo =: 42) (#bar =: "Hello")
queryWithParams dsl (#foo =: 42) (#bar =: "Hello") :: MonadIO m => BoltActionT m [Record]>>>
:t queryWithParams dsl (#foo =: True)
... ... Couldn't match type ‘Int’ with ‘Bool’ ...>>>
:t queryWithParams dsl (#bar =: 42)
... ... Couldn't match type ‘"bar"’ with ‘"foo"’ ...
Implementation details
class QueryWithParams (params :: [(Symbol, Type)]) (m :: Type -> Type) fun | params m -> fun where Source #
This type class ensures safety of queries with parameters by checking in compile time that all parameters are supplied and have correct type.
Instances of this class will add more arguments to fun
, one for each element in params
.
This should be considered an implementation detail.
collectParams :: HasCallStack => CypherDSL () -> [(Text, Value)] -> fun Source #
Internal function that accumulates parameters from type-level list.
Instances
MonadIO m => QueryWithParams ('[] :: [(Symbol, Type)]) m (BoltActionT m [Record]) Source # | Base case: if there are no parameters, perform query with |
Defined in Database.Bolt.Extras.DSL.Typed.Parameters collectParams :: CypherDSL () -> [(Text, Value)] -> BoltActionT m [Record] Source # |