erd 0.1.3.0 → 0.2.0.0
raw patch · 13 files changed
+1053/−652 lines, 13 filesdep +QuickCheckdep +hspecdep +raw-strings-qqdep ~basenew-uploader
Dependencies added: QuickCheck, hspec, raw-strings-qq, tasty, tasty-hunit
Dependency ranges changed: base
Files
- app/Main.hs +118/−0
- changelog.md +16/−0
- erd.cabal +43/−11
- src/Config.hs +0/−135
- src/ER.hs +0/−193
- src/Erd/Config.hs +164/−0
- src/Erd/ER.hs +187/−0
- src/Erd/Parse.hs +78/−0
- src/Main.hs +0/−105
- src/Parse.hs +0/−208
- src/Text/Parsec/Erd/Parser.hs +163/−0
- test/Spec.hs +9/−0
- test/Test/Text/Parsec/Erd/Parser.hs +275/−0
+ app/Main.hs view
@@ -0,0 +1,118 @@+{-# LANGUAGE OverloadedStrings #-}+module Main (main) where++import Control.Applicative ((<|>))+import Control.Monad (forM_, guard)+import qualified Data.ByteString as SB+import Data.Maybe (fromMaybe)+import qualified Data.Text.Lazy as L+import System.Exit (exitFailure)+import System.IO (hClose, hPutStrLn, stderr)+import Text.Printf (printf)++import Data.GraphViz+import qualified Data.GraphViz.Attributes.Colors.X11 as C+import qualified Data.GraphViz.Attributes.Complete as A+import qualified Data.GraphViz.Attributes.HTML as H+import qualified Data.GraphViz.Types.Generalised as G+import Data.GraphViz.Types.Monadic+import Data.GraphViz.Commands (isGraphvizInstalled)++import qualified Erd.ER as ER++import Erd.Config+import Erd.ER+import Erd.Parse++main :: IO ()+main = do+ checkRequirements -- application may terminate here+ conf <- configIO+ er' <- uncurry loadER (cin conf)+ case er' of+ Left err -> do+ hPutStrLn stderr err+ exitFailure+ Right er -> let dotted = dotER conf er+ toFile h = SB.hGetContents h >>= SB.hPut (snd $ cout conf)+ fmt = fromMaybe Pdf (outfmt conf)+ in graphvizWithHandle Dot dotted fmt toFile+ hClose (snd $ cin conf)+ hClose (snd $ cout conf)++-- | Converts an entire ER-diagram from an ER file into a GraphViz graph.+dotER :: Config -> ER -> G.DotGraph L.Text+dotER conf er = graph' $ do+ graphAttrs (graphTitle $ title er)+ graphAttrs [ A.RankDir A.FromLeft+ , A.Splines (edgeType conf)+ ]+ nodeAttrs [shape PlainText] -- recommended for HTML labels+ edgeAttrs [ A.Color [A.toWC $ A.toColor C.Gray50] -- easier to read labels+ , A.MinLen 2 -- give some breathing room+ , A.Style [A.SItem A.Dashed []] -- easier to read labels, maybe?+ ]+ forM_ (entities er) $ \e ->+ node (name e) [toLabel (htmlEntity e)]+ forM_ (rels er) $ \r -> do+ let opts = roptions r+ let rlab = A.HtmlLabel . H.Text . htmlFont opts . L.pack . show+ let (l1, l2) = (A.TailLabel $ rlab $ card1 r, A.HeadLabel $ rlab $ card2 r)+ let label = A.Label $ A.HtmlLabel $ H.Text $ withLabelFmt " %s " opts []+ edge (entity1 r) (entity2 r) [label, l1, l2]++-- | Converts a single entity to an HTML label.+htmlEntity :: Entity -> H.Label+htmlEntity e = H.Table H.HTable+ { H.tableFontAttrs = Just $ optionsTo optToFont $ eoptions e+ , H.tableAttrs = optionsTo optToHtml (eoptions e)+ , H.tableRows = rows+ }+ where rows = headerRow : map htmlAttr (attribs e)+ headerRow = H.Cells [H.LabelCell [] $ H.Text text]+ text = withLabelFmt " [%s]" (hoptions e) $ bold hname+ hname = htmlFont (hoptions e) (name e)+ bold s = [H.Format H.Bold s]++-- | Converts a single attribute to an HTML table row.+htmlAttr :: ER.Attribute -> H.Row+htmlAttr a = H.Cells [cell]+ where cell = H.LabelCell cellAttrs (H.Text $ withLabelFmt " [%s]" opts name)+ name = fkfmt $ pkfmt $ htmlFont opts (field a)+ pkfmt s = if pk a then [H.Format H.Underline s] else s+ fkfmt s = if fk a then [H.Format H.Italics s] else s+ cellAttrs = H.Align H.HLeft : optionsTo optToHtml opts+ opts = aoptions a++-- | Formats HTML text with a label. The format string given should be+-- in `Data.Text.printf` style. (Only font options are used from the options+-- given.)+withLabelFmt :: String -> Options -> H.Text -> H.Text+withLabelFmt fmt opts s =+ case optionsTo optToLabel opts of+ (x:_) -> s ++ htmlFont opts (L.pack $ printf fmt $ L.unpack x)+ _ -> s++-- | Formats an arbitrary string with the options given (using only font+-- attributes).+htmlFont :: Options -> L.Text -> H.Text+htmlFont opts s = [H.Font (optionsTo optToFont opts) [H.Str s]]++-- | Extracts and formats a graph title from the options given.+-- The options should be title options from an ER value.+-- If a title does not exist, an empty list is returned and `graphAttrs attrs`+-- should be a no-op.+graphTitle :: Options -> [A.Attribute]+graphTitle topts =+ let glabel = optionsTo optToLabel topts+ in if null glabel then [] else+ [ A.LabelJust A.JLeft+ , A.LabelLoc A.VTop+ , A.Label $ A.HtmlLabel $ H.Text $ htmlFont topts (head glabel)+ ]++checkRequirements :: IO ()+checkRequirements = (isGraphvizInstalled >>= guard) <|> quitWithoutGraphviz msg+ where+ msg = "GraphViz is not installed on your system.\n" +++ "Please install it first, https://github.com/BurntSushi/erd"
+ changelog.md view
@@ -0,0 +1,16 @@+0.2.0.0++* Adding test suite. (PR #46)++* Adding TravisCI script. (PR #46)++* Not falling to *bottom* when GraphViz does not exists on the host system. (PR #45)++* Supporting to build with *stack* and/or *cabal*. (PR #43, PR #18)++* Adding optional -e command-line argument to determine the type of edges+ between nodes/tables. (PR #13)++* Allow spaces in table identifiers, quoted with backticks. (PR #9)++0.1.3.0
erd.cabal view
@@ -1,8 +1,8 @@--- Initial erd.cabal generated by cabal init. For further documentation, +-- Initial erd.cabal generated by cabal init. For further documentation, -- see http://haskell.org/cabal/users-guide/ name: erd-version: 0.1.3.0+version: 0.2.0.0 homepage: https://github.com/BurntSushi/erd license: PublicDomain license-file: UNLICENSE@@ -13,15 +13,15 @@ cabal-version: >=1.8 synopsis: An entity-relationship diagram generator from a plain text description.-description: - erd transforms a plain text description of a relational database schema to a +description:+ erd transforms a plain text description of a relational database schema to a graphical representation of that schema. It is intended that the graph make use of common conventions when depicting entity-relationship diagrams, including modeling the cardinality of relationships between entities. . A quick example that transforms an `er` file to a PDF: .- > $ curl 'https://raw.github.com/BurntSushi/erd/master/examples/simple.er' > simple.er+ > $ curl 'https://raw.githubusercontent.com/BurntSushi/erd/master/examples/simple.er' > simple.er > $ cat simple.er > # Entities are declared in '[' ... ']'. All attributes after the entity header > # up until the end of the file (or the next entity declaration) correspond@@ -31,13 +31,13 @@ > height > weight > +birth_location_id- > + > > [Location] > *id > city > state > country- > + > > # Each relationship must be between exactly two entities, which need not > # be distinct. Each entity in the relationship has exactly one of four > # possible cardinalities:@@ -54,22 +54,54 @@ . <<http://burntsushi.net/stuff/erd-example-simple.png>> .- See the <https://github.com/BurntSushi/erd#readme README.md> file for more + See the <https://github.com/BurntSushi/erd#readme README.md> file for more examples and instructions on how to write ER files. +extra-source-files:+ changelog.md+ source-repository head type: git location: git://github.com/BurntSushi/erd.git executable erd- hs-source-dirs: src+ hs-source-dirs: app, src+ other-modules:+ Erd.Config+ Erd.ER+ Erd.Parse+ Text.Parsec.Erd.Parser main-is: Main.hs ghc-options: -Wall -fno-warn-name-shadowing -fno-warn-unused-do-bind- other-modules: Config, ER, Parse build-depends:- base == 4.7.*+ base >=4.5 && <5+ , graphviz == 2999.*+ , text == 1.*+ , parsec == 3.1.*+ , containers == 0.5.*+ , bytestring == 0.10.*++test-suite spec+ type: exitcode-stdio-1.0+ main-is: Spec.hs+ ghc-options: -Wall -fno-warn-name-shadowing -fno-warn-unused-do-bind+ hs-source-dirs:+ test, src+ other-modules:+ Erd.Config+ Erd.ER+ Erd.Parse+ Text.Parsec.Erd.Parser+ Test.Text.Parsec.Erd.Parser+ build-depends:+ QuickCheck+ , hspec+ , base >=4.5 && <5 , graphviz == 2999.* , text == 1.* , parsec == 3.1.* , containers == 0.5.* , bytestring == 0.10.*+ , tasty >= 0.11+ , tasty-hunit >= 0.9+ , raw-strings-qq >= 1.1
− src/Config.hs
@@ -1,135 +0,0 @@-module Config- ( Config(..)- , configIO- )-where--import Data.Char (isSpace)-import Data.List (dropWhileEnd, intercalate)-import qualified Data.Map as M-import Data.Maybe (isNothing)-import qualified System.Console.GetOpt as O-import System.Environment (getArgs)-import System.Exit (exitFailure)-import System.IO (Handle, IOMode(..), stdin, stdout, stderr, openFile)-import Text.Printf (HPrintfType, hPrintf, printf)--import qualified Data.GraphViz.Commands as G---- | Config represents all information from command line flags.-data Config =- Config { cin :: (String, Handle)- , cout :: (String, Handle)- , outfmt :: Maybe G.GraphvizOutput- }--defaultConfig :: Config-defaultConfig =- Config { cin = ("<stdin>", stdin)- , cout = ("<stdout>", stdout)- , outfmt = Nothing- }---- | Creates a new Config value from command line options.--- If an output path is given and `--fmt` is omitted, then a format--- will be inferred from the output path extension.--- Failing all of that, PDF is used by default.-configIO :: IO Config-configIO = do- args <- getArgs- case O.getOpt O.Permute opts args of- (flags, [], []) -> do- conf <- foldl (\c app -> app c) (return defaultConfig) flags- let outpath = fst (cout conf)- return $- if isNothing (outfmt conf) && outpath /= "<stdout>" then- conf { outfmt = toGraphFmt $ takeExtension outpath }- else- conf- (_, _, errs@(_:_)) -> do- ef "Error(s) parsing flags:\n\t%s\n" $- intercalate "\n\t" $ map strip errs- exitFailure- (_, _, []) -> do- ef "erd does not have any positional arguments.\n\n"- usageExit--opts :: [O.OptDescr (IO Config -> IO Config)]-opts =- [ O.Option "i" ["input"]- (O.ReqArg (\fpath cIO -> do- c <- cIO- i <- openFile fpath ReadMode- return $ c { cin = (fpath, i) }- )- "FILE")- ("When set, input will be read from the given file.\n"- ++ "Otherwise, stdin will be used.")- , O.Option "o" ["output"]- (O.ReqArg (\fpath cIO -> do- c <- cIO- o <- openFile fpath WriteMode- return $ c { cout = (fpath, o) }- )- "FILE")- ("When set, output will be written to the given file.\n"- ++ "Otherwise, stdout will be used.\n"- ++ "If given and if --fmt is omitted, then the format will be\n"- ++ "guessed from the file extension.")- , O.Option "h" ["help"]- (O.NoArg $ const usageExit)- "Show this usage message."- , O.Option "f" ["fmt"]- (O.ReqArg (\fmt cIO -> do- c <- cIO- let mfmt = toGraphFmt fmt- case mfmt of- Nothing -> do- ef "'%s' is not a valid output format." fmt- exitFailure- Just gfmt ->- return $ c { outfmt = Just gfmt }- )- "FMT")- (printf "Force the output format to one of:\n%s"- (intercalate ", " $ M.keys fmts))- ]---- | A subset of formats supported from GraphViz.-fmts :: M.Map String (Maybe G.GraphvizOutput)-fmts = M.fromList- [ ("pdf", Just G.Pdf)- , ("svg", Just G.Svg)- , ("eps", Just G.Eps)- , ("bmp", Just G.Bmp)- , ("jpg", Just G.Jpeg)- , ("png", Just G.Png)- , ("gif", Just G.Gif)- , ("tiff", Just G.Tiff)- , ("dot", Just G.Canon)- , ("ps", Just G.Ps)- , ("ps2", Just G.Ps2)- , ("plain", Just G.Plain)- ]---- | takeExtension returns the last extension from a file path, or the--- empty string if no extension was found. e.g., the extension of--- "wat.pdf" is "pdf".-takeExtension :: String -> String-takeExtension s = if null rest then "" else reverse ext- where (ext, rest) = span (/= '.') $ reverse s--toGraphFmt :: String -> Maybe G.GraphvizOutput-toGraphFmt ext = M.findWithDefault Nothing ext fmts--usageExit :: IO a-usageExit = usage >> exitFailure--usage :: IO ()-usage = ef "%s\n" $ O.usageInfo "Usage: erd [flags]" opts--ef :: HPrintfType r => String -> r-ef = hPrintf stderr--strip :: String -> String-strip = dropWhile isSpace . dropWhileEnd isSpace
− src/ER.hs
@@ -1,193 +0,0 @@-{-# LANGUAGE OverloadedStrings #-}-module ER- ( ER(..)- , Entity(..)- , Attribute(..)- , Options, mergeOpts, optionsTo- , Option(..), optionByName, optToFont, optToHtml, optToLabel- , Relation(..) , Cardinality(..), cardByName- , defaultTitleOpts, defaultEntityOpts, defaultHeaderOpts, defaultRelOpts- )-where--import qualified Data.Map as M-import Data.Maybe (mapMaybe)-import Data.Text.Lazy-import Data.Word (Word8)-import Text.Printf (printf)--import Data.GraphViz.Parsing (ParseDot, parse, runParser)-import qualified Data.GraphViz.Attributes.HTML as H-import Data.GraphViz.Attributes.Colors (Color)---- | Represents a single schema.-data ER = ER { entities :: [Entity]- , rels :: [Relation]- , title :: Options- }- deriving Show---- | Represents a single entity in a schema.-data Entity = Entity { name :: Text- , attribs :: [Attribute]- , hoptions :: Options- , eoptions :: Options- }- deriving Show--instance Eq Entity where- e1 == e2 = name e1 == name e2--instance Ord Entity where- e1 `compare` e2 = name e1 `compare` name e2---- | Represents a single attribute in a particular entity.-data Attribute = Attribute { field :: Text- , pk :: Bool- , fk :: Bool- , aoptions :: Options- }- deriving Show--instance Eq Attribute where- a1 == a2 = field a1 == field a2--instance Ord Attribute where- a1 `compare` a2 = field a1 `compare` field a2---- | Represents any number of options for an item in an ER diagram.--- An item may be the graph title, an entity, an entity header or a--- relationship between entities. Keys are options as specified in ER files.------ Note that a set of options may include a label for any item.-type Options = M.Map String Option---- | Given two sets of options, merge the second into first, where elements--- in the first take precedence.-mergeOpts :: Options -> Options -> Options-mergeOpts opts1 opts2 = opts1 `M.union` opts2---- | Given a set of options and a selector function, return the list of--- only those options which matched. Examples of the selector function are--- `optToFont`, `optToHtml` and `optToLabel`.-optionsTo :: (Option -> Maybe a) -> Options -> [a]-optionsTo f = mapMaybe f . M.elems---- | A restricted subset of options in GraphViz that can be configured in--- an ER file.-data Option = Label String- | BgColor Color- | Color Color- | FontFace Text- | FontSize Double- | Border Word8- | BorderColor Color- | CellSpacing Word8- | CellBorder Word8- | CellPadding Word8- deriving Show---- | Given an option name and a string representation of its value,--- `optionByName` will attempt to parse the string as a value corresponding--- to the option. If the option doesn't exist or there was a problem parsing--- the value, an error is returned.-optionByName :: String -> String -> Either String Option-optionByName "label" = Right . Label-optionByName "color" = optionParse Color-optionByName "bgcolor" = optionParse BgColor-optionByName "size" = optionParse FontSize-optionByName "font" = optionParse FontFace-optionByName "border" = optionParse Border-optionByName "border-color" = optionParse BorderColor-optionByName "cellspacing" = optionParse CellSpacing-optionByName "cellborder" = optionParse CellBorder-optionByName "cellpadding" = optionParse CellPadding-optionByName unk = const (Left $ printf "Option '%s' does not exist." unk)---- | A wrapper around the GraphViz's parser for any particular option.-optionParse :: ParseDot a => (a -> Option) -> String -> Either String Option-optionParse con s =- case fst $ runParser parse quoted of- Left err -> Left (printf "%s (bad value '%s')" err s)- Right a -> Right (con a)- where quoted = "\"" `append` pack s `append` "\""---- | Selects an option if and only if it corresponds to a font attribute.-optToFont :: Option -> Maybe H.Attribute-optToFont (Color c) = Just $ H.Color c-optToFont (FontFace s) = Just $ H.Face s-optToFont (FontSize d) = Just $ H.PointSize d-optToFont _ = Nothing---- | Selects an option if and only if it corresponds to an HTML attribute.--- In particular, for tables or table cells.-optToHtml :: Option -> Maybe H.Attribute-optToHtml (BgColor c) = Just $ H.BGColor c-optToHtml (Border w) = Just $ H.Border w-optToHtml (BorderColor c) = Just $ H.Color c-optToHtml (CellSpacing w) = Just $ H.CellSpacing w-optToHtml (CellBorder w) = Just $ H.CellBorder w-optToHtml (CellPadding w) = Just $ H.CellPadding w-optToHtml _ = Nothing---- | Selects an option if and only if it corresponds to a label.-optToLabel :: Option -> Maybe Text-optToLabel (Label s) = Just $ pack s-optToLabel _ = Nothing---- | Represents a relationship between exactly two entities. After parsing,--- each `rel` is guaranteed to correspond to an entity defined in the same--- ER file.------ Each relationship has one of four cardinalities specified for both entities.--- Those cardinalities are: 0 or 1, exactly 1, 0 or more and 1 or more.-data Relation = Relation { entity1, entity2 :: Text- , card1, card2 :: Cardinality- , roptions :: Options- }- deriving Show--data Cardinality = ZeroOne- | One- | ZeroPlus- | OnePlus--instance Show Cardinality where- show ZeroOne = "{0,1}"- show One = "1"- show ZeroPlus = "0..N"- show OnePlus ="1..N"---- | Maps a string representation to a particular relationship cardinality.-cardByName :: Char -> Maybe Cardinality-cardByName '?' = Just ZeroOne-cardByName '1' = Just One-cardByName '*' = Just ZeroPlus-cardByName '+' = Just OnePlus-cardByName _ = Nothing---- | Hard-coded default options for all graph titles.-defaultTitleOpts :: Options-defaultTitleOpts = M.fromList- [ ("size", FontSize 30)- ]---- | Hard-coded default options for all entity headers.-defaultHeaderOpts :: Options-defaultHeaderOpts = M.fromList- [ ("size", ER.FontSize 16)- ]---- | Hard-coded default options for all entities.-defaultEntityOpts :: Options-defaultEntityOpts = M.fromList- [ ("border", Border 0)- , ("cellborder", CellBorder 1)- , ("cellspacing", CellSpacing 0)- , ("cellpadding", CellPadding 4)- , ("font", FontFace "Helvetica")- ]---- | Hard-coded default options for all relationships.-defaultRelOpts :: Options-defaultRelOpts = M.empty
+ src/Erd/Config.hs view
@@ -0,0 +1,164 @@+module Erd.Config+ ( Config(..)+ , configIO+ )+where++import Data.Char (isSpace)+import Data.List (dropWhileEnd, intercalate)+import qualified Data.Map as M+import Data.Maybe (isNothing)+import qualified System.Console.GetOpt as O+import System.Environment (getArgs)+import System.Exit (exitFailure)+import System.IO (Handle, IOMode(..), stdin, stdout, stderr, openFile)+import Text.Printf (HPrintfType, hPrintf, printf)++import qualified Data.GraphViz.Commands as G+import qualified Data.GraphViz.Attributes.Complete as A++-- | Config represents all information from command line flags.+data Config =+ Config { cin :: (String, Handle)+ , cout :: (String, Handle)+ , outfmt :: Maybe G.GraphvizOutput+ , edgeType :: A.EdgeType+ }++defaultConfig :: Config+defaultConfig =+ Config { cin = ("<stdin>", stdin)+ , cout = ("<stdout>", stdout)+ , outfmt = Nothing+ , edgeType = A.SplineEdges+ }++-- | Creates a new Config value from command line options.+-- If an output path is given and `--fmt` is omitted, then a format+-- will be inferred from the output path extension.+-- Failing all of that, PDF is used by default.+configIO :: IO Config+configIO = do+ args <- getArgs+ case O.getOpt O.Permute opts args of+ (flags, [], []) -> do+ conf <- foldl (\c app -> app c) (return defaultConfig) flags+ let outpath = fst (cout conf)+ return $+ if isNothing (outfmt conf) && outpath /= "<stdout>" then+ conf { outfmt = toGraphFmt $ takeExtension outpath }+ else+ conf+ (_, _, errs@(_:_)) -> do+ ef "Error(s) parsing flags:\n\t%s\n" $+ intercalate "\n\t" $ map strip errs+ exitFailure+ (_, _, []) -> do+ ef "erd does not have any positional arguments.\n\n"+ usageExit++opts :: [O.OptDescr (IO Config -> IO Config)]+opts =+ [ O.Option "i" ["input"]+ (O.ReqArg (\fpath cIO -> do+ c <- cIO+ i <- openFile fpath ReadMode+ return $ c { cin = (fpath, i) }+ )+ "FILE")+ ("When set, input will be read from the given file.\n"+ ++ "Otherwise, stdin will be used.")+ , O.Option "o" ["output"]+ (O.ReqArg (\fpath cIO -> do+ c <- cIO+ o <- openFile fpath WriteMode+ return $ c { cout = (fpath, o) }+ )+ "FILE")+ ("When set, output will be written to the given file.\n"+ ++ "Otherwise, stdout will be used.\n"+ ++ "If given and if --fmt is omitted, then the format will be\n"+ ++ "guessed from the file extension.")+ , O.Option "h" ["help"]+ (O.NoArg $ const usageExit)+ "Show this usage message."+ , O.Option "f" ["fmt"]+ (O.ReqArg (\fmt cIO -> do+ c <- cIO+ let mfmt = toGraphFmt fmt+ case mfmt of+ Nothing -> do+ ef "'%s' is not a valid output format." fmt+ exitFailure+ Just gfmt ->+ return $ c { outfmt = Just gfmt }+ )+ "FMT")+ (printf "Force the output format to one of:\n%s"+ (intercalate ", " $ M.keys fmts))+ , O.Option "e" ["edge"]+ (O.ReqArg (\edge cIO -> do+ c <- cIO+ let edgeG = toEdgeG edge+ case edgeG of+ Nothing -> do+ ef "'%s' is not a valid type of edge." edge+ exitFailure+ Just edgeType ->+ return $ c { edgeType = edgeType }+ )+ "EDGE")+ (printf "Select one type of edge:\n%s"+ (intercalate ", " $ M.keys edges))+ ]++-- | A subset of formats supported from GraphViz.+fmts :: M.Map String (Maybe G.GraphvizOutput)+fmts = M.fromList+ [ ("pdf", Just G.Pdf)+ , ("svg", Just G.Svg)+ , ("eps", Just G.Eps)+ , ("bmp", Just G.Bmp)+ , ("jpg", Just G.Jpeg)+ , ("png", Just G.Png)+ , ("gif", Just G.Gif)+ , ("tiff", Just G.Tiff)+ , ("dot", Just G.Canon)+ , ("ps", Just G.Ps)+ , ("ps2", Just G.Ps2)+ , ("plain", Just G.Plain)+ ]++edges :: M.Map String (Maybe A.EdgeType)+edges = M.fromList+ [ ("spline", Just A.SplineEdges)+ , ("ortho", Just A.Ortho)+ , ("noedge", Just A.NoEdges)+ , ("poly", Just A.PolyLine)+ , ("compound", Just A.CompoundEdge)+ ]++-- | takeExtension returns the last extension from a file path, or the+-- empty string if no extension was found. e.g., the extension of+-- "wat.pdf" is "pdf".+takeExtension :: String -> String+takeExtension s = if null rest then "" else reverse ext+ where (ext, rest) = span (/= '.') $ reverse s++toGraphFmt :: String -> Maybe G.GraphvizOutput+toGraphFmt ext = M.findWithDefault Nothing ext fmts++toEdgeG :: String -> Maybe A.EdgeType+toEdgeG edge = M.findWithDefault Nothing edge edges++usageExit :: IO a+usageExit = usage >> exitFailure++usage :: IO ()+usage = ef "%s\n" $ O.usageInfo "Usage: erd [flags]" opts++ef :: HPrintfType r => String -> r+ef = hPrintf stderr++strip :: String -> String+strip = dropWhile isSpace . dropWhileEnd isSpace
+ src/Erd/ER.hs view
@@ -0,0 +1,187 @@+{-# LANGUAGE OverloadedStrings #-}+module Erd.ER+ ( ER(..)+ , Entity(..)+ , Attribute(..)+ , Options, mergeOpts, optionsTo+ , Option(..), optionByName, optToFont, optToHtml, optToLabel+ , Relation(..) , Cardinality(..), cardByName+ , defaultTitleOpts, defaultEntityOpts, defaultHeaderOpts, defaultRelOpts+ )+where++import qualified Data.Map as M+import Data.Maybe (mapMaybe)+import Data.Text.Lazy+import Data.Word (Word8)+import Text.Printf (printf)++import Data.GraphViz.Parsing (ParseDot, parse, runParser)+import qualified Data.GraphViz.Attributes.HTML as H+import Data.GraphViz.Attributes.Colors (Color)++-- | Represents a single schema.+data ER = ER { entities :: [Entity]+ , rels :: [Relation]+ , title :: Options+ }+ deriving (Show, Eq)++-- | Represents a single entity in a schema.+data Entity = Entity { name :: Text+ , attribs :: [Attribute]+ , hoptions :: Options+ , eoptions :: Options+ }+ deriving (Show, Eq)++instance Ord Entity where+ e1 `compare` e2 = name e1 `compare` name e2++-- | Represents a single attribute in a particular entity.+data Attribute = Attribute { field :: Text+ , pk :: Bool+ , fk :: Bool+ , aoptions :: Options+ }+ deriving (Show, Eq)++instance Ord Attribute where+ a1 `compare` a2 = field a1 `compare` field a2++-- | Represents any number of options for an item in an ER diagram.+-- An item may be the graph title, an entity, an entity header or a+-- relationship between entities. Keys are options as specified in ER files.+--+-- Note that a set of options may include a label for any item.+type Options = M.Map String Option++-- | Given two sets of options, merge the second into first, where elements+-- in the first take precedence.+mergeOpts :: Options -> Options -> Options+mergeOpts opts1 opts2 = opts1 `M.union` opts2++-- | Given a set of options and a selector function, return the list of+-- only those options which matched. Examples of the selector function are+-- `optToFont`, `optToHtml` and `optToLabel`.+optionsTo :: (Option -> Maybe a) -> Options -> [a]+optionsTo f = mapMaybe f . M.elems++-- | A restricted subset of options in GraphViz that can be configured in+-- an ER file.+data Option = Label String+ | BgColor Color+ | Color Color+ | FontFace Text+ | FontSize Double+ | Border Word8+ | BorderColor Color+ | CellSpacing Word8+ | CellBorder Word8+ | CellPadding Word8+ deriving (Show, Eq)++-- | Given an option name and a string representation of its value,+-- `optionByName` will attempt to parse the string as a value corresponding+-- to the option. If the option doesn't exist or there was a problem parsing+-- the value, an error is returned.+optionByName :: String -> String -> Either String Option+optionByName "label" = Right . Label+optionByName "color" = optionParse Color+optionByName "bgcolor" = optionParse BgColor+optionByName "size" = optionParse FontSize+optionByName "font" = optionParse FontFace+optionByName "border" = optionParse Border+optionByName "border-color" = optionParse BorderColor+optionByName "cellspacing" = optionParse CellSpacing+optionByName "cellborder" = optionParse CellBorder+optionByName "cellpadding" = optionParse CellPadding+optionByName unk = const (Left $ printf "Option '%s' does not exist." unk)++-- | A wrapper around the GraphViz's parser for any particular option.+optionParse :: ParseDot a => (a -> Option) -> String -> Either String Option+optionParse con s =+ case fst $ runParser parse quoted of+ Left err -> Left (printf "%s (bad value '%s')" err s)+ Right a -> Right (con a)+ where quoted = "\"" `append` pack s `append` "\""++-- | Selects an option if and only if it corresponds to a font attribute.+optToFont :: Option -> Maybe H.Attribute+optToFont (Color c) = Just $ H.Color c+optToFont (FontFace s) = Just $ H.Face s+optToFont (FontSize d) = Just $ H.PointSize d+optToFont _ = Nothing++-- | Selects an option if and only if it corresponds to an HTML attribute.+-- In particular, for tables or table cells.+optToHtml :: Option -> Maybe H.Attribute+optToHtml (BgColor c) = Just $ H.BGColor c+optToHtml (Border w) = Just $ H.Border w+optToHtml (BorderColor c) = Just $ H.Color c+optToHtml (CellSpacing w) = Just $ H.CellSpacing w+optToHtml (CellBorder w) = Just $ H.CellBorder w+optToHtml (CellPadding w) = Just $ H.CellPadding w+optToHtml _ = Nothing++-- | Selects an option if and only if it corresponds to a label.+optToLabel :: Option -> Maybe Text+optToLabel (Label s) = Just $ pack s+optToLabel _ = Nothing++-- | Represents a relationship between exactly two entities. After parsing,+-- each `rel` is guaranteed to correspond to an entity defined in the same+-- ER file.+--+-- Each relationship has one of four cardinalities specified for both entities.+-- Those cardinalities are: 0 or 1, exactly 1, 0 or more and 1 or more.+data Relation = Relation { entity1, entity2 :: Text+ , card1, card2 :: Cardinality+ , roptions :: Options+ }+ deriving (Show, Eq)++data Cardinality = ZeroOne+ | One+ | ZeroPlus+ | OnePlus deriving (Eq)++instance Show Cardinality where+ show ZeroOne = "{0,1}"+ show One = "1"+ show ZeroPlus = "0..N"+ show OnePlus ="1..N"++-- | Maps a string representation to a particular relationship cardinality.+cardByName :: Char -> Maybe Cardinality+cardByName '?' = Just ZeroOne+cardByName '1' = Just One+cardByName '*' = Just ZeroPlus+cardByName '+' = Just OnePlus+cardByName _ = Nothing++-- | Hard-coded default options for all graph titles.+defaultTitleOpts :: Options+defaultTitleOpts = M.fromList+ [ ("size", FontSize 30)+ ]++-- | Hard-coded default options for all entity headers.+defaultHeaderOpts :: Options+defaultHeaderOpts = M.fromList+ [ ("size", FontSize 16)+ ]++-- | Hard-coded default options for all entities.+defaultEntityOpts :: Options+defaultEntityOpts = M.fromList+ [ ("border", Border 0)+ , ("cellborder", CellBorder 1)+ , ("cellspacing", CellSpacing 0)+ , ("cellpadding", CellPadding 4)+ , ("font", FontFace "Helvetica")+ ]++-- | Hard-coded default options for all relationships.+defaultRelOpts :: Options+defaultRelOpts = M.empty
+ src/Erd/Parse.hs view
@@ -0,0 +1,78 @@+{-# LANGUAGE OverloadedStrings #-}+module Erd.Parse+ ( loadER+ )+where++import Control.Monad (when)+import Data.List (find)+import Data.Maybe+import Data.Text.Lazy hiding (find, map, reverse)+import Data.Text.Lazy.IO+import System.IO (Handle)+import Text.Parsec+import Text.Printf (printf)+import Text.Parsec.Erd.Parser (AST(..), GlobalOptions(..), document)++import Erd.ER++loadER :: String -> Handle -> IO (Either String ER)+loadER fpath f = do+ s <- hGetContents f+ case parse (do { (opts, ast) <- document; return $ toER opts ast}) fpath s of+ Left err -> return $ Left $ show err+ Right err@(Left _) -> return err+ Right (Right er) -> return $ Right er++-- | Converts a list of syntactic categories in an entity-relationship+-- description to an ER representation. If there was a problem with the+-- conversion, an error is reported. This includes checking that each+-- relationship contains only valid entity names.+--+-- This preserves the ordering of the syntactic elements in the original+-- description.+toER :: GlobalOptions -> [AST] -> Either String ER+toER gopts = toER' (ER [] [] title)+ where title = gtoptions gopts `mergeOpts` defaultTitleOpts++ toER' :: ER -> [AST] -> Either String ER+ toER' er [] = Right (reversed er) >>= validRels+ toER' (ER { entities = [] }) (A a:_) =+ let name = show (field a)+ in Left $ printf "Attribute '%s' comes before first entity." name+ toER' er@(ER { entities = e':es }) (A a:xs) = do+ let e = e' { attribs = a:attribs e' }+ toER' (er { entities = e:es }) xs+ toER' er@(ER { entities = es }) (E e:xs) = do+ let opts = eoptions e+ `mergeOpts` geoptions gopts+ `mergeOpts` defaultEntityOpts+ let hopts = eoptions e+ `mergeOpts` ghoptions gopts+ `mergeOpts` defaultHeaderOpts+ toER' (er { entities = e { eoptions = opts, hoptions = hopts }:es}) xs+ toER' er@(ER { rels = rs }) (R r:xs) = do+ let opts = roptions r+ `mergeOpts` groptions gopts+ `mergeOpts` defaultRelOpts+ toER' (er { rels = r { roptions = opts }:rs }) xs++ reversed :: ER -> ER+ reversed er@(ER { entities = es, rels = rs }) =+ let es' = map (\e -> e { attribs = reverse (attribs e) }) es+ in er { entities = reverse es', rels = reverse rs }++ validRels :: ER -> Either String ER+ validRels er = validRels' (rels er) er++ validRels' :: [Relation] -> ER -> Either String ER+ validRels' [] er = return er+ validRels' (r:_) er = do+ let r1 = find (\e -> name e == entity1 r) (entities er)+ let r2 = find (\e -> name e == entity2 r) (entities er)+ let err getter = Left+ $ printf "Unknown entity '%s' in relationship."+ $ unpack $ getter r+ when (isNothing r1) (err entity1)+ when (isNothing r2) (err entity2)+ return er
− src/Main.hs
@@ -1,105 +0,0 @@-{-# LANGUAGE OverloadedStrings #-}-module Main (main) where--import Control.Monad (forM_)-import qualified Data.ByteString as SB-import Data.Maybe (fromMaybe)-import qualified Data.Text.Lazy as L-import System.Exit (exitFailure)-import System.IO (hClose, hPutStrLn, stderr)-import Text.Printf (printf)--import Data.GraphViz-import qualified Data.GraphViz.Attributes.Colors.X11 as C-import qualified Data.GraphViz.Attributes.Complete as A-import qualified Data.GraphViz.Attributes.HTML as H-import qualified Data.GraphViz.Types.Generalised as G-import Data.GraphViz.Types.Monadic--import Config-import ER-import Parse--main :: IO ()-main = do- conf <- configIO- er' <- uncurry loadER (cin conf)- case er' of- Left err -> do- hPutStrLn stderr err- exitFailure- Right er -> let dotted = dotER er- toFile h = SB.hGetContents h >>= SB.hPut (snd $ cout conf)- fmt = fromMaybe Pdf (outfmt conf)- in graphvizWithHandle Dot dotted fmt toFile- hClose (snd $ cin conf)- hClose (snd $ cout conf)---- | Converts an entire ER-diagram from an ER file into a GraphViz graph.-dotER :: ER -> G.DotGraph L.Text-dotER er = graph' $ do- graphAttrs (graphTitle $ title er)- graphAttrs [A.RankDir A.FromLeft]- nodeAttrs [shape PlainText] -- recommended for HTML labels- edgeAttrs [ A.Color [A.toWC $ A.toColor C.Gray50] -- easier to read labels- , A.MinLen 2 -- give some breathing room- , A.Style [A.SItem A.Dashed []] -- easier to read labels, maybe?- ]- forM_ (entities er) $ \e ->- node (name e) [toLabel (htmlEntity e)]- forM_ (rels er) $ \r -> do- let opts = roptions r- let rlab = A.HtmlLabel . H.Text . htmlFont opts . L.pack . show- let (l1, l2) = (A.TailLabel $ rlab $ card1 r, A.HeadLabel $ rlab $ card2 r)- let label = A.Label $ A.HtmlLabel $ H.Text $ withLabelFmt " %s " opts []- edge (entity1 r) (entity2 r) [label, l1, l2]---- | Converts a single entity to an HTML label.-htmlEntity :: Entity -> H.Label-htmlEntity e = H.Table H.HTable- { H.tableFontAttrs = Just $ optionsTo optToFont $ eoptions e- , H.tableAttrs = optionsTo optToHtml (eoptions e)- , H.tableRows = rows- }- where rows = headerRow : map htmlAttr (attribs e)- headerRow = H.Cells [H.LabelCell [] $ H.Text text]- text = withLabelFmt " [%s]" (hoptions e) $ bold hname- hname = htmlFont (hoptions e) (name e)- bold s = [H.Format H.Bold s]---- | Converts a single attribute to an HTML table row.-htmlAttr :: ER.Attribute -> H.Row-htmlAttr a = H.Cells [cell]- where cell = H.LabelCell cellAttrs (H.Text $ withLabelFmt " [%s]" opts name)- name = fkfmt $ pkfmt $ htmlFont opts (field a)- pkfmt s = if pk a then [H.Format H.Underline s] else s- fkfmt s = if fk a then [H.Format H.Italics s] else s- cellAttrs = H.Align H.HLeft : optionsTo optToHtml opts- opts = aoptions a---- | Formats HTML text with a label. The format string given should be--- in `Data.Text.printf` style. (Only font options are used from the options--- given.)-withLabelFmt :: String -> Options -> H.Text -> H.Text-withLabelFmt fmt opts s =- case optionsTo optToLabel opts of- (x:_) -> s ++ htmlFont opts (L.pack $ printf fmt $ L.unpack x)- _ -> s---- | Formats an arbitrary string with the options given (using only font--- attributes).-htmlFont :: Options -> L.Text -> H.Text-htmlFont opts s = [H.Font (optionsTo optToFont opts) [H.Str s]]---- | Extracts and formats a graph title from the options given.--- The options should be title options from an ER value.--- If a title does not exist, an empty list is returned and `graphAttrs attrs`--- should be a no-op.-graphTitle :: Options -> [A.Attribute]-graphTitle topts =- let glabel = optionsTo optToLabel topts- in if null glabel then [] else- [ A.LabelJust A.JLeft- , A.LabelLoc A.VTop- , A.Label $ A.HtmlLabel $ H.Text $ htmlFont topts (head glabel)- ]
− src/Parse.hs
@@ -1,208 +0,0 @@-{-# LANGUAGE OverloadedStrings #-}-module Parse- ( loadER- )-where--import Prelude hiding (null)--import Control.Monad (liftM2, when, void)-import Data.Char (isAlphaNum, isSpace)-import Data.List (find)-import qualified Data.Map as M-import Data.Maybe-import Data.Text.Lazy hiding (find, map, reverse)-import Data.Text.Lazy.IO-import System.IO (Handle)-import Text.Parsec-import Text.Parsec.Text.Lazy-import Text.Printf (printf)--import ER--data AST = E Entity- | A Attribute- | R Relation- deriving Show--data GlobalOptions = GlobalOptions { gtoptions :: Options- , ghoptions :: Options- , geoptions :: Options- , groptions :: Options- }- deriving Show--emptyGlobalOptions :: GlobalOptions-emptyGlobalOptions = GlobalOptions M.empty M.empty M.empty M.empty--loadER :: String -> Handle -> IO (Either String ER)-loadER fpath f = do- s <- hGetContents f- case parse (do { (opts, ast) <- document; return $ toER opts ast}) fpath s of- Left err -> return $ Left $ show err- Right err@(Left _) -> return err- Right (Right er) -> return $ Right er---- | Converts a list of syntactic categories in an entity-relationship--- description to an ER representation. If there was a problem with the--- conversion, an error is reported. This includes checking that each--- relationship contains only valid entity names.------ This preserves the ordering of the syntactic elements in the original--- description.-toER :: GlobalOptions -> [AST] -> Either String ER-toER gopts = toER' (ER [] [] title)- where title = gtoptions gopts `mergeOpts` defaultTitleOpts-- toER' :: ER -> [AST] -> Either String ER- toER' er [] = Right (reversed er) >>= validRels- toER' (ER { entities = [] }) (A a:_) =- let name = show (field a)- in Left $ printf "Attribute '%s' comes before first entity." name- toER' er@(ER { entities = e':es }) (A a:xs) = do- let e = e' { attribs = a:attribs e' }- toER' (er { entities = e:es }) xs- toER' er@(ER { entities = es }) (E e:xs) = do- let opts = eoptions e- `mergeOpts` geoptions gopts- `mergeOpts` defaultEntityOpts- let hopts = eoptions e- `mergeOpts` ghoptions gopts- `mergeOpts` defaultHeaderOpts- toER' (er { entities = e { eoptions = opts, hoptions = hopts }:es}) xs- toER' er@(ER { rels = rs }) (R r:xs) = do- let opts = roptions r- `mergeOpts` groptions gopts- `mergeOpts` defaultRelOpts- toER' (er { rels = r { roptions = opts }:rs }) xs-- reversed :: ER -> ER- reversed er@(ER { entities = es, rels = rs }) =- let es' = map (\e -> e { attribs = reverse (attribs e) }) es- in er { entities = reverse es', rels = reverse rs }-- validRels :: ER -> Either String ER- validRels er = validRels' (rels er) er-- validRels' :: [Relation] -> ER -> Either String ER- validRels' [] er = return er- validRels' (r:_) er = do- let r1 = find (\e -> name e == entity1 r) (entities er)- let r2 = find (\e -> name e == entity2 r) (entities er)- let err getter = Left- $ printf "Unknown entity '%s' in relationship."- $ unpack $ getter r- when (isNothing r1) (err entity1)- when (isNothing r2) (err entity2)- return er--document :: Parser (GlobalOptions, [AST])-document = do skipMany (comment <|> blanks)- opts <- globalOptions emptyGlobalOptions- ast <- fmap catMaybes $ manyTill top eof- return (opts, ast)- where top = (entity <?> "entity declaration")- <|> (try rel <?> "relationship") -- must come before attr- <|> (try attr <?> "attribute")- <|> (comment <?> "comment")- <|> blanks- blanks = many1 (space <?> "whitespace") >> return Nothing--entity :: Parser (Maybe AST)-entity = do n <- between (char '[') (char ']') ident- spacesNoNew- opts <- options- eolComment- return $ Just $ E Entity { name = n, attribs = [],- hoptions = opts, eoptions = opts }--attr :: Parser (Maybe AST)-attr = do- keys <- many $ oneOf "*+ \t"- let (ispk, isfk) = ('*' `elem` keys, '+' `elem` keys)- n <- ident- opts <- options- eolComment- return- $ Just- $ A Attribute { field = n, pk = ispk, fk = isfk, aoptions = opts }--rel :: Parser (Maybe AST)-rel = do- let ops = "?1*+"- e1 <- ident- op1 <- oneOf ops- string "--"- op2 <- oneOf ops- e2 <- ident- opts <- options-- let getCard op =- case cardByName op of- Just t -> return t- Nothing -> unexpected (printf "Cardinality '%s' does not exist." op)- t1 <- getCard op1- t2 <- getCard op2- return $ Just $ R Relation { entity1 = e1, entity2 = e2- , card1 = t1, card2 = t2, roptions = opts }--globalOptions :: GlobalOptions -> Parser GlobalOptions-globalOptions gopts =- option gopts $ try $ do- n <- ident - opts <- options- case n of- "title" -> emptiness >> globalOptions (gopts { gtoptions = opts})- "header" -> emptiness >> globalOptions (gopts { ghoptions = opts})- "entity" -> emptiness >> globalOptions (gopts { geoptions = opts})- "relationship" -> emptiness >> globalOptions (gopts { groptions = opts})- _ -> fail "not a valid directive"--options :: Parser (M.Map String Option)-options =- option M.empty- $ fmap M.fromList- $ try- $ between (char '{' >> emptiness) (emptiness >> char '}')- $ opt `sepEndBy` (emptiness >> char ',' >> emptiness)--opt :: Parser (String, Option)-opt = do- name <- liftM2 (:) letter (manyTill (letter <|> char '-') (char ':'))- <?> "option name"- emptiness- value <- between (char '"') (char '"') (many $ noneOf "\"")- <?> "option value"- case optionByName name value of- Left err -> fail err- Right o' -> emptiness >> return (name, o')--comment :: Parser (Maybe AST)-comment = do- char '#'- manyTill anyChar $ try eol- return Nothing--ident :: Parser Text-ident = do- spacesNoNew- let p = satisfy (\c -> c == '_' || isAlphaNum c)- <?> "letter, digit or underscore"- n <- fmap pack (many1 p)- spacesNoNew- return n--emptiness :: Parser ()-emptiness = skipMany (void (many1 space) <|> eolComment)--eolComment :: Parser ()-eolComment = spacesNoNew >> (eol <|> void comment)--spacesNoNew :: Parser ()-spacesNoNew = skipMany $ satisfy $ \c -> c /= '\n' && c /= '\r' && isSpace c--eol :: Parser ()-eol = eof <|> do- c <- oneOf "\n\r"- when (c == '\r') $ optional $ char '\n'-
+ src/Text/Parsec/Erd/Parser.hs view
@@ -0,0 +1,163 @@+{-# LANGUAGE OverloadedStrings #-}+module Text.Parsec.Erd.Parser+ (+ AST(..),+ GlobalOptions(..),+ document,+ globalOptions,+ entity,+ rel,+ attr,+ comment,+ )+where++import Control.Monad (liftM2, when, void)+import Data.Char (isAlphaNum, isSpace, isControl)+import qualified Data.Map as M+import Data.Maybe+import Data.Text.Lazy+import Text.Parsec+import Text.Parsec.Text.Lazy+import Text.Printf (printf)++import Erd.ER++data AST = E Entity+ | A Attribute+ | R Relation+ deriving (Show, Eq)++data GlobalOptions = GlobalOptions { gtoptions :: Options+ , ghoptions :: Options+ , geoptions :: Options+ , groptions :: Options+ }+ deriving (Show, Eq)++emptyGlobalOptions :: GlobalOptions+emptyGlobalOptions = GlobalOptions M.empty M.empty M.empty M.empty++document :: Parser (GlobalOptions, [AST])+document = do skipMany (comment <|> blanks)+ opts <- globalOptions emptyGlobalOptions+ ast <- catMaybes <$> manyTill top eof+ return (opts, ast)+ where top = (entity <?> "entity declaration")+ <|> (try rel <?> "relationship") -- must come before attr+ <|> (try attr <?> "attribute")+ <|> (comment <?> "comment")+ <|> blanks+ blanks = many1 (space <?> "whitespace") >> return Nothing++entity :: Parser (Maybe AST)+entity = do n <- between (char '[') (char ']') ident+ spacesNoNew+ opts <- options+ eolComment+ return $ Just $ E Entity { name = n, attribs = [],+ hoptions = opts, eoptions = opts }++attr :: Parser (Maybe AST)+attr = do+ keys <- many $ oneOf "*+ \t"+ let (ispk, isfk) = ('*' `elem` keys, '+' `elem` keys)+ n <- ident+ opts <- options+ eolComment+ return+ $ Just+ $ A Attribute { field = n, pk = ispk, fk = isfk, aoptions = opts }++rel :: Parser (Maybe AST)+rel = do+ let ops = "?1*+"+ e1 <- ident+ op1 <- oneOf ops+ string "--"+ op2 <- oneOf ops+ e2 <- ident+ opts <- options++ let getCard op =+ case cardByName op of+ Just t -> return t+ Nothing -> unexpected (printf "Cardinality '%s' does not exist." op)+ t1 <- getCard op1+ t2 <- getCard op2+ return $ Just $ R Relation { entity1 = e1, entity2 = e2+ , card1 = t1, card2 = t2, roptions = opts }++globalOptions :: GlobalOptions -> Parser GlobalOptions+globalOptions gopts =+ option gopts $ try $ do+ n <- ident + opts <- options+ case n of+ "title" -> emptiness >> globalOptions (gopts { gtoptions = opts})+ "header" -> emptiness >> globalOptions (gopts { ghoptions = opts})+ "entity" -> emptiness >> globalOptions (gopts { geoptions = opts})+ "relationship" -> emptiness >> globalOptions (gopts { groptions = opts})+ _ -> fail "not a valid directive"++options :: Parser (M.Map String Option)+options =+ option M.empty+ $ fmap M.fromList+ $ try+ $ between (char '{' >> emptiness) (emptiness >> char '}')+ $ opt `sepEndBy` (emptiness >> char ',' >> emptiness)++opt :: Parser (String, Option)+opt = do+ name <- liftM2 (:) letter (manyTill (letter <|> char '-') (char ':'))+ <?> "option name"+ emptiness+ value <- between (char '"') (char '"') (many $ noneOf "\"")+ <?> "option value"+ case optionByName name value of+ Left err -> fail err+ Right o' -> emptiness >> return (name, o')++comment :: Parser (Maybe AST)+comment = do+ char '#'+ manyTill anyChar $ try eol+ return Nothing++ident :: Parser Text+ident = do+ spacesNoNew+ n <- identQuoted <|> identNoSpace+ spacesNoNew+ return n++identQuoted :: Parser Text+identQuoted = do+ quote <- oneOf "'\"`"+ let p = satisfy (\c -> c /= quote && not (isControl c) )+ <?> "any character except " ++ [quote] ++ " or control characters"+ n <- fmap pack (many1 p)+ char quote+ return n++identNoSpace :: Parser Text+identNoSpace = do+ let p = satisfy (\c -> c == '_' || isAlphaNum c)+ <?> "letter, digit or underscore"+ fmap pack (many1 p)++emptiness :: Parser ()+emptiness = skipMany (void (many1 space) <|> eolComment)++eolComment :: Parser ()+eolComment = spacesNoNew >> (eol <|> void comment)++spacesNoNew :: Parser ()+spacesNoNew = skipMany $ satisfy $ \c -> c /= '\n' && c /= '\r' && isSpace c++eol :: Parser ()+eol = eof <|> do+ c <- oneOf "\n\r"+ when (c == '\r') $ optional $ char '\n'+
+ test/Spec.hs view
@@ -0,0 +1,9 @@+module Main (main) where+import Test.Tasty+import Test.Text.Parsec.Erd.Parser (testEr)++main :: IO ()+main = defaultMain $ testGroup "Erd Tests" tests++tests :: [TestTree]+tests = [testEr]
+ test/Test/Text/Parsec/Erd/Parser.hs view
@@ -0,0 +1,275 @@+{-# LANGUAGE OverloadedStrings #-}+{-# LANGUAGE QuasiQuotes #-}+module Test.Text.Parsec.Erd.Parser+ (testEr)+ where+import Test.Tasty+import Test.Tasty.HUnit+import Text.RawString.QQ (r)+import Data.Text (Text)+import Data.Text.Lazy (fromStrict)+import Text.Parsec (parse)+import qualified Data.Map as M+import Data.Map (fromList)+import Erd.ER+import Text.Parsec.Erd.Parser (document, AST(..), GlobalOptions(..))+import Data.GraphViz.Attributes.Colors (Color(..))++parseDoc :: Text -> (GlobalOptions, [AST]) -> Assertion+parseDoc input expect= Right expect `shouldBe` parse document "" (fromStrict input) where+ shouldBe = assertEqual ""++testEr :: TestTree+testEr = testGroup "Parse Er" [+ testCase "Parse Simple case" $ parseDoc simpleText simpleResult,+ testCase "Parse nfldb case" $ parseDoc nfldbText nfldbResult+ ]+++simpleText :: Text+simpleText = [r|+# Entities are declared in '[' ... ']'. All attributes after the entity header+# up until the end of the file (or the next entity declaration) correspond+# to this entity.+[Person]+*name+height+weight+`birth date`++birth_place_id++[`Birth Place`]+*id+`birth city`+'birth state'+"birth country"++# Each relationship must be between exactly two entities, which need not+# be distinct. Each entity in the relationship has exactly one of four+# possible cardinalities:+#+# Cardinality Syntax+# 0 or 1 ?+# exactly 1 1+# 0 or more *+# 1 or more ++Person *--1 `Birth Place`+|]++simpleResult :: (GlobalOptions, [AST])+simpleResult = (opts, asts) where+ opts = GlobalOptions M.empty M.empty M.empty M.empty+ asts = [+ E (Entity {name = "Person", attribs = [], hoptions = fromList [], eoptions = fromList []}),+ A (Attribute {field = "name", pk = True, fk = False, aoptions = fromList []}),+ A (Attribute {field = "height", pk = False, fk = False, aoptions = fromList []}),+ A (Attribute {field = "weight", pk = False, fk = False, aoptions = fromList []}),+ A (Attribute {field = "birth date", pk = False, fk = False, aoptions = fromList []}),+ A (Attribute {field = "birth_place_id", pk = False, fk = True, aoptions = fromList []}),+ E (Entity {name = "Birth Place", attribs = [], hoptions = fromList [], eoptions = fromList []}),+ A (Attribute {field = "id", pk = True, fk = False, aoptions = fromList []}),+ A (Attribute {field = "birth city", pk = False, fk = False, aoptions = fromList []}),+ A (Attribute {field = "birth state", pk = False, fk = False, aoptions = fromList []}),+ A (Attribute {field = "birth country", pk = False, fk = False, aoptions = fromList []}),+ R (Relation {entity1 = "Person", entity2 = "Birth Place", card1 = ZeroPlus , card2 = One, roptions = fromList []})]++nfldbText :: Text+nfldbText = [r|+title {label: "nfldb Entity-Relationship diagram (condensed)", size: "20"}++# Nice colors from Erwiz:+# red #fcecec+# blue #ececfc+# green #d0e0d0+# yellow #fbfbdb+# orange #eee0a0++# Entities++[player] {bgcolor: "#d0e0d0"}+ *player_id {label: "varchar, not null"}+ full_name {label: "varchar, null"}+ team {label: "varchar, not null"}+ position {label: "player_pos, not null"}+ status {label: "player_status, not null"}++[team] {bgcolor: "#d0e0d0"}+ *team_id {label: "varchar, not null"}+ city {label: "varchar, not null"}+ name {label: "varchar, not null"}++[game] {bgcolor: "#ececfc"}+ *gsis_id {label: "gameid, not null"}+ start_time {label: "utctime, not null"}+ week {label: "usmallint, not null"}+ season_year {label: "usmallint, not null"}+ season_type {label: "season_phase, not null"}+ finished {label: "boolean, not null"}+ home_team {label: "varchar, not null"}+ home_score {label: "usmallint, not null"}+ away_team {label: "varchar, not null"}+ away_score {label: "usmallint, not null"}++[drive] {bgcolor: "#ececfc"}+ *+gsis_id {label: "gameid, not null"}+ *drive_id {label: "usmallint, not null"}+ start_field {label: "field_pos, null"}+ start_time {label: "game_time, not null"}+ end_field {label: "field_pos, null"}+ end_time {label: "game_time, not null"}+ pos_team {label: "varchar, not null"}+ pos_time {label: "pos_period, null"}++[play] {bgcolor: "#ececfc"}+ *+gsis_id {label: "gameid, not null"}+ *+drive_id {label: "usmallint, not null"}+ *play_id {label: "usmallint, not null"}+ time {label: "game_time, not null"}+ pos_team {label: "varchar, not null"}+ yardline {label: "field_pos, null"}+ down {label: "smallint, null"}+ yards_to_go {label: "smallint, null"}++[play_player] {bgcolor: "#ececfc"}+ *+gsis_id {label: "gameid, not null"}+ *+drive_id {label: "usmallint, not null"}+ *+play_id {label: "usmallint, not null"}+ *+player_id {label: "varchar, not null"}+ team {label: "varchar, not null"}++[meta] {bgcolor: "#fcecec"}+ version {label: "smallint, null"}+ season_type {label: "season_phase, null"}+ season_year {label: "usmallint, null"}+ week {label: "usmallint, null"}++# Relationships++player *--1 team+game *--1 team {label: "home"}+game *--1 team {label: "away"}+drive *--1 team+play *--1 team+play_player *--1 team++game 1--* drive+game 1--* play+game 1--* play_player++drive 1--* play+drive 1--* play_player++play 1--* play_player++player 1--* play_player+|]+++data ChunckAST = CE [Entity] | CA [Attribute] | CR [Relation] deriving (Eq)+toAST :: ChunckAST -> [AST]+toAST (CE x) = map E x+toAST (CA x) = map A x+toAST (CR x) = map R x++nfldbResult :: (GlobalOptions, [AST])+nfldbResult = (opts, asts) where+ opts = GlobalOptions {gtoptions = fromList [("label",Label "nfldb Entity-Relationship diagram (condensed)"),("size",FontSize 20.0)], ghoptions = fromList [], geoptions = fromList [], groptions = fromList []}+ asts = concatMap toAST $ entities:(attributes ++ [relations])+ entities = CE [+ Entity {name = "player", attribs = [],+ hoptions = fromList [("bgcolor",BgColor (RGB {red = 208, green = 224, blue = 208}))],+ eoptions = fromList [("bgcolor",BgColor (RGB {red = 208, green = 224, blue = 208}))]+ }+ ]+ attributes = [+ CA [+ Attribute {field = "player_id", pk = True, fk = False, aoptions = fromList [("label",Label "varchar, not null")]},+ Attribute {field = "full_name", pk = False, fk = False, aoptions = fromList [("label",Label "varchar, null")]},+ Attribute {field = "team", pk = False, fk = False, aoptions = fromList [("label",Label "varchar, not null")]},+ Attribute {field = "position", pk = False, fk = False, aoptions = fromList [("label",Label "player_pos, not null")]},+ Attribute {field = "status", pk = False, fk = False, aoptions = fromList [("label",Label "player_status, not null")]}+ ],+ CE [+ Entity {name = "team", attribs = [], hoptions = fromList [("bgcolor",BgColor (RGB {red = 208, green = 224, blue = 208}))], eoptions = fromList [("bgcolor",BgColor (RGB {red = 208, green = 224, blue = 208}))]}+ ],+ CA [+ Attribute {field = "team_id", pk = True, fk = False, aoptions = fromList [("label",Label "varchar, not null")]},+ Attribute {field = "city", pk = False, fk = False, aoptions = fromList [("label",Label "varchar, not null")]},+ Attribute {field = "name", pk = False, fk = False, aoptions = fromList [("label",Label "varchar, not null")]}+ ],+ CE [+ Entity {name = "game", attribs = [], hoptions = fromList [("bgcolor",BgColor (RGB {red = 236, green = 236, blue = 252}))], eoptions = fromList [("bgcolor",BgColor (RGB {red = 236, green = 236, blue = 252}))]}+ ],+ CA [+ Attribute {field = "gsis_id", pk = True, fk = False, aoptions = fromList [("label",Label "gameid, not null")]},+ Attribute {field = "start_time", pk = False, fk = False, aoptions = fromList [("label",Label "utctime, not null")]},+ Attribute {field = "week", pk = False, fk = False, aoptions = fromList [("label",Label "usmallint, not null")]},+ Attribute {field = "season_year", pk = False, fk = False, aoptions = fromList [("label",Label "usmallint, not null")]},+ Attribute {field = "season_type", pk = False, fk = False, aoptions = fromList [("label",Label "season_phase, not null")]},+ Attribute {field = "finished", pk = False, fk = False, aoptions = fromList [("label",Label "boolean, not null")]},+ Attribute {field = "home_team", pk = False, fk = False, aoptions = fromList [("label",Label "varchar, not null")]},+ Attribute {field = "home_score", pk = False, fk = False, aoptions = fromList [("label",Label "usmallint, not null")]},+ Attribute {field = "away_team", pk = False, fk = False, aoptions = fromList [("label",Label "varchar, not null")]},+ Attribute {field = "away_score", pk = False, fk = False, aoptions = fromList [("label",Label "usmallint, not null")]}+ ],+ CE [+ Entity {name = "drive", attribs = [], hoptions = fromList [("bgcolor",BgColor (RGB {red = 236, green = 236, blue = 252}))], eoptions = fromList [("bgcolor",BgColor (RGB {red = 236, green = 236, blue = 252}))]}+ ],+ CA [+ Attribute {field = "gsis_id", pk = True, fk = True, aoptions = fromList [("label",Label "gameid, not null")]},+ Attribute {field = "drive_id", pk = True, fk = False, aoptions = fromList [("label",Label "usmallint, not null")]},+ Attribute {field = "start_field", pk = False, fk = False, aoptions = fromList [("label",Label "field_pos, null")]},+ Attribute {field = "start_time", pk = False, fk = False, aoptions = fromList [("label",Label "game_time, not null")]},+ Attribute {field = "end_field", pk = False, fk = False, aoptions = fromList [("label",Label "field_pos, null")]},+ Attribute {field = "end_time", pk = False, fk = False, aoptions = fromList [("label",Label "game_time, not null")]},+ Attribute {field = "pos_team", pk = False, fk = False, aoptions = fromList [("label",Label "varchar, not null")]},+ Attribute {field = "pos_time", pk = False, fk = False, aoptions = fromList [("label",Label "pos_period, null")]}+ ],+ CE [+ Entity {name = "play", attribs = [], hoptions = fromList [("bgcolor",BgColor (RGB {red = 236, green = 236, blue = 252}))], eoptions = fromList [("bgcolor",BgColor (RGB {red = 236, green = 236, blue = 252}))]}+ ],+ CA [+ Attribute {field = "gsis_id", pk = True, fk = True, aoptions = fromList [("label",Label "gameid, not null")]},+ Attribute {field = "drive_id", pk = True, fk = True, aoptions = fromList [("label",Label "usmallint, not null")]},+ Attribute {field = "play_id", pk = True, fk = False, aoptions = fromList [("label",Label "usmallint, not null")]},+ Attribute {field = "time", pk = False, fk = False, aoptions = fromList [("label",Label "game_time, not null")]},+ Attribute {field = "pos_team", pk = False, fk = False, aoptions = fromList [("label",Label "varchar, not null")]},+ Attribute {field = "yardline", pk = False, fk = False, aoptions = fromList [("label",Label "field_pos, null")]},+ Attribute {field = "down", pk = False, fk = False, aoptions = fromList [("label",Label "smallint, null")]},+ Attribute {field = "yards_to_go", pk = False, fk = False, aoptions = fromList [("label",Label "smallint, null")]}+ ],+ CE [+ Entity {name = "play_player", attribs = [], hoptions = fromList [("bgcolor",BgColor (RGB {red = 236, green = 236, blue = 252}))], eoptions = fromList [("bgcolor",BgColor (RGB {red = 236, green = 236, blue = 252}))]}+ ],+ CA [+ Attribute {field = "gsis_id", pk = True, fk = True, aoptions = fromList [("label",Label "gameid, not null")]},+ Attribute {field = "drive_id", pk = True, fk = True, aoptions = fromList [("label",Label "usmallint, not null")]},+ Attribute {field = "play_id", pk = True, fk = True, aoptions = fromList [("label",Label "usmallint, not null")]},+ Attribute {field = "player_id", pk = True, fk = True, aoptions = fromList [("label",Label "varchar, not null")]},+ Attribute {field = "team", pk = False, fk = False, aoptions = fromList [("label",Label "varchar, not null")]}+ ],+ CE [+ Entity {name = "meta", attribs = [], hoptions = fromList [("bgcolor",BgColor (RGB {red = 252, green = 236, blue = 236}))], eoptions = fromList [("bgcolor",BgColor (RGB {red = 252, green = 236, blue = 236}))]}+ ],+ CA [+ Attribute {field = "version", pk = False, fk = False, aoptions = fromList [("label",Label "smallint, null")]},+ Attribute {field = "season_type", pk = False, fk = False, aoptions = fromList [("label",Label "season_phase, null")]},+ Attribute {field = "season_year", pk = False, fk = False, aoptions = fromList [("label",Label "usmallint, null")]},+ Attribute {field = "week", pk = False, fk = False, aoptions = fromList [("label",Label "usmallint, null")]}+ ]+ ]+ relations = CR [+ Relation {entity1 = "player", entity2 = "team", card1 = ZeroPlus, card2 = One, roptions = fromList []},+ Relation {entity1 = "game", entity2 = "team", card1 = ZeroPlus, card2 = One, roptions = fromList [("label",Label "home")]},+ Relation {entity1 = "game", entity2 = "team", card1 = ZeroPlus, card2 = One, roptions = fromList [("label",Label "away")]},+ Relation {entity1 = "drive", entity2 = "team", card1 = ZeroPlus, card2 = One, roptions = fromList []},+ Relation {entity1 = "play", entity2 = "team", card1 = ZeroPlus, card2 = One, roptions = fromList []},+ Relation {entity1 = "play_player", entity2 = "team", card1 = ZeroPlus, card2 = One, roptions = fromList []},+ Relation {entity1 = "game", entity2 = "drive", card1 = One, card2 = ZeroPlus, roptions = fromList []},+ Relation {entity1 = "game", entity2 = "play", card1 = One, card2 = ZeroPlus, roptions = fromList []},+ Relation {entity1 = "game", entity2 = "play_player", card1 = One, card2 = ZeroPlus, roptions = fromList []},+ Relation {entity1 = "drive", entity2 = "play", card1 = One, card2 = ZeroPlus, roptions = fromList []},+ Relation {entity1 = "drive", entity2 = "play_player", card1 = One, card2 = ZeroPlus, roptions = fromList []},+ Relation {entity1 = "play", entity2 = "play_player", card1 = One, card2 = ZeroPlus, roptions = fromList []},+ Relation {entity1 = "player", entity2 = "play_player", card1 = One, card2 = ZeroPlus, roptions = fromList []}+ ]