typson-core 0.1.0.0 → 0.1.0.1
raw patch · 2 files changed
+347/−36 lines, 2 filesdep ~aesondep ~containersdep ~profunctorsPVP ok
version bump matches the API change (PVP)
Dependency ranges changed: aeson, containers, profunctors, text, unordered-containers, vector
API changes (from Hackage documentation)
Files
- README.md +303/−0
- typson-core.cabal +44/−36
+ README.md view
@@ -0,0 +1,303 @@+# Typson++## Introduction++Typson uses type level programming to enable writing JSON PostgreSQL queries+that are type safe. There are integrations available for the `esqueleto`,+`beam`, and `selda` libraries.++The ability to store and query arbitrary JSON in a database can be very+beneficial, however, writing these queries with raw SQL is particularly bug+prone because the correctness of the path keys, the nullability of fields, and+the resulting type must all be accounted for. Typson solves these issues by+describing the JSON structure of the data at the type level so that any errors+will manifest as compile time exceptions rather than runtime bugs.++Typson allows you to define a single schema value which then functions as+both an encoder and decoder for JSON representations. The actual encoding and+decoding is handled by the `aeson` library. This same schema is also what's used+to provide type safety for JSON SQL queries.++## Example++Now for a rather contrived demonstration of how the library works and what you+can do with it.++### Defining our types++Let's say we want to store information about various life forms. We have+different attributes depending on if the subject is a plant or an animal, which+we model with the `Classifier` sum type.++```hs+data LifeForm =+ LifeForm+ { classifier :: Classifier+ , name :: T.Text+ } deriving (Eq, Show)++data Classifier+ = Flora Plant+ | Fauna Animal+ deriving (Eq, Show)++data Plant =+ Plant+ { isAquatic :: Bool+ , bearsFruit :: Bool+ } deriving (Eq, Show)++data Animal =+ Animal+ { favoriteFoods :: [T.Text]+ , isGoodPet :: Bool+ } deriving (Eq, Show)+```++### Defining schemas++Now we'll define the schemas that Typson will use to type check our SQL queries.++```hs+{-# LANGUAGE TypeApplications #-}+{-# LANGUAGE PartialTypeSignatures #-}++import Typson++plantJ :: JsonSchema _ Plant+plantJ = object "Plant" $+ Plant+ <<$> field (key @"isAquatic") isAquatic prim+ <<*> field (key @"bearsFruit") bearsFruit prim++animalJ :: JsonSchema _ Animal+animalJ = object "Animal" $+ Animal+ <<$> field (key @"favoriteFoods") favoriteFoods (list prim)+ <<*> field (key @"isGoodPet") isGoodPet prim+```++A few things to note here:+- `JsonSchema` is just a rank-N type synonym for a set of constraints. It's+first argument is the type level data structure that defines the JSON structure.+It's not very useful or readable to have this parameter written out in the code+and the compiler will generate it for you, so it is recommended to use the+`PartialTypeSignatures` extension to hide it. The last argument is the type that+is being described.+- Record types are described using the `object` function and the fields are+defined using syntax that is akin to the standard Applicative form but with+`<<$>` instead of `<$>` and `<<*>` instead of `<*>`. This is to support the+type-level machinery, which the normal `Applicative` type class cannot do.+- There are several combinators for defining fields. `field` denotes a required+attribute which translates to a non-null field in the JSON.+- `list` wraps a schema for some type `a`, turning it into a schema for `[a]`.+This will allow us to query for specific indices of the list.+- The first argument of the field combinators is the key to be used in the JSON.+It is supplied using `key` which is just a synonym for `Proxy`. A type error+occurrs if a key appears more than once in the same object.+- The second argument is the field accesor of the record.+- The final argument is the schema associated with the type of the field. If the+field's type is some sort of primative that has predefined `ToJSON` and+`FromJSON` instances, then the `prim` schema should be used.++Now for the remaining two schemas. Of special interest is the schema for the+`Classifier` sum type.++```hs+classifierJ :: JsonSchema _ Classifier+classifierJ = union "Classifier" $+ classifierTags+ <<$> tag (key @"flora") Flora plantJ+ <<*> tag (key @"fauna") Fauna animalJ+ where+ classifierTags withPlant withAnimal = \case+ Flora plant -> withPlant plant+ Fauna animal -> withAnimal animal++lifeFormJ :: JsonSchema _ LifeForm+lifeFormJ = object "LifeForm" $+ LifeForm+ <<$> field (key @"classifier") classifier classifierJ+ <<*> field (key @"name") name prim+```++Here we see the somewhat peculiar `union` function which is how schemas for sum+types are defined. We define a function that takes handlers for the contents of+each of the sum's constructors which then pattern matches on the sum and applies+the corresponding handlers to the pattern matched values. The function is+applied over `tag` definitions, one for each branch of the sum, using the same+specialized `Applicative` syntax as before.++Also noteworthy is that we are now referencing other schemas in the last+argument of some of these `field` and `tag` definitions instead of `prim`.++### Writing `aeson` instances++You should always define `FromJSON` and `ToJSON` instances using the Typson+schema for a given type, which is as simple as using the `decodeObject` and+`encodeObject` functions from `typson-core`:++```hs+import Data.Aeson++instance FromJSON LifeForm where+ parseJSON = decodeObject lifeFormJ++instance ToJSON LifeForm where+ toJSON = encodeObject lifeFormJ++instance FromJSON Plant where+ parseJSON = decodeObject plantJ++instance ToJSON Plant where+ toJSON = encodeObject plantJ++instance FromJSON Animal where+ parseJSON = decodeObject animalJ++instance ToJSON Animal where+ toJSON = encodeObject animalJ++instance FromJSON Classifier where+ parseJSON = decodeObject classifierJ++instance ToJSON Classifier where+ toJSON = encodeObject classifierJ+```++### Setting up the database table++We'll be using the `esqueleto` library, which sits on top of `persistent`, to+demonstrate some database interactions. Integrations are also available for the+`beam` and `selda` libraries.++First we'll define a table that has a single `JSONB` column to hold the JSON of+our `LifeForm` type (in addition to the implicit 'id' column).++```hs+import Database.Esqueleto+import qualified Database.Persist.Postgresql as P+import qualified Database.Persist.TH as P++P.share [P.mkPersist P.sqlSettings, P.mkMigrate "migrateAll"] [P.persistLowerCase|+LifeFormEntity+ object (JSONB LifeForm)+ deriving Show Eq+|]++-- sample data+lifeFormEntities :: [LifeFormEntity]+lifeFormEntities+ = [ LifeFormEntity $ JSONB lion+ , LifeFormEntity $ JSONB dog+ , LifeFormEntity $ JSONB kelp+ , LifeFormEntity $ JSONB bananaTree+ ]++main :: IO ()+main = do+ Just connString <- lookupEnv "CONN_STRING"+ runStderrLoggingT . P.withPostgresqlConn (BS.pack connString) $ \be ->+ (`P.runSqlConn` be) $ do+ P.runMigration migrateAll++ void $ P.insertMany lifeFormEntities+```++The `main` function simply connects to the database, creates the table if it+doesn't exist, and populates it with some sample data (omitted here).++### Querying against a JSON column++Now that we've created our table and populated it with the sample data, we're+ready to write some type safe JSON queries!++```hs+import Data.Proxy+import Typson.Esqueleto++-- Select the names of all life forms.+selectNames :: P.SqlPersistT (LoggingT IO) [T.Text]+selectNames+ = fmap coerce -- unwrap newtypes+ . select . from+ $ \e ->+ pure . jsonPath (Proxy @"name") lifeFormJ+ $ e ^. LifeFormEntityObject++-- Select just the animals+selectAnimals :: P.SqlPersistT (LoggingT IO) [Animal]+selectAnimals+ = fmap (mapMaybe coerce)+ . select . from+ $ \e -> do+ pure . jsonPath (Proxy @("classifier" :-> "fauna")) lifeFormJ+ $ e ^. LifeFormEntityObject+```++The key component here is the `jsonPath` function, which takes these arguments:+- A `Proxy` for the path to the field that we want to select. The `:->` type+operator is used to construct a path from multiple keys, akin to how paths are+written in raw SQL queries using `->`.+- Our schema value+- The `esqueleto` identifier for the JSON column.++If we had typo'ed the keys or constructed an invalid path, we'd get a helpful+type error about it, such as++```+/code/example/src/Main.hs:88:5: error:+ • JSON key not present in Classifier: "fawna"+```++Typson is able to calculate what the resulting type should be given a path into+the object, so our query is well typed!++Here are some more example queries:++```hs+-- Selects all animals that make good pets+selectGoodPets :: P.SqlPersistT (LoggingT IO) [P.Entity LifeFormEntity]+selectGoodPets+ = select . from+ $ \e -> do+ let goodPetPath =+ jsonPath (Proxy @("classifier" :-> "fauna" :-> "isGoodPet")) lifeFormJ++ where_ $ goodPetPath (e ^. LifeFormEntityObject)+ ==. val (NullableJSONB (Just True))+ pure e++-- Selects the names of all the aquatic plants+selectAquaticPlantNames :: P.SqlPersistT (LoggingT IO) [T.Text]+selectAquaticPlantNames+ = fmap coerce -- unwrap newtypes+ . select . from+ $ \e -> do+ let isAquaticPath =+ jsonPath (Proxy @("classifier" :-> "flora" :-> "isAquatic")) lifeFormJ+ where_ $ isAquaticPath (e ^. LifeFormEntityObject)+ ==. val (NullableJSONB (Just True))+ pure $ jsonPath (Proxy @("name")) lifeFormJ (e ^. LifeFormEntityObject)++-- Select pairs of names to the 1st item in the list of favorite foods if the+-- entity is an animal.+selectFavoriteFood :: P.SqlPersistT (LoggingT IO) [(T.Text, Maybe T.Text)]+selectFavoriteFood+ = fmap coerce -- unwrap newtypes+ . select . from+ $ \e -> do+ let o = e ^. LifeFormEntityObject+ name' = jsonPath (Proxy @"name") lifeFormJ o+ favoriteFood =+ jsonPath (Proxy @("classifier" :-> "fauna" :-> "favoriteFoods" :-> 0)) lifeFormJ o+ pure (name', favoriteFood)+```++Here we have `where` clauses that reference specific JSON fields.+There is also an instance of using a numeric literal as part of the path to+query a particular index of an array field.++In conclusion, as long as the JSON in your database was produced using the same+schema that's being used for querying, then you can rest assured that your JSON+queries won't result in runtime bugs!
typson-core.cabal view
@@ -1,41 +1,49 @@-cabal-version: 1.12-name: typson-core-version: 0.1.0.0-license: BSD3-license-file: LICENSE-copyright: 2020 Aaron Allen-maintainer: aaronallen8455@gmail.com-author: Aaron Allen-homepage: https://github.com/aaronallen8455/typson#readme-bug-reports: https://github.com/aaronallen8455/typson/issues-synopsis: Type-safe PostgreSQL JSON Querying-description:- Please see the README on GitHub at <https://github.com/aaronallen8455/typson#readme>+cabal-version: 1.12 -category: Database-build-type: Simple-extra-source-files: ChangeLog.md+-- This file has been generated from package.yaml by hpack version 0.33.0.+--+-- see: https://github.com/sol/hpack+--+-- hash: 14c0e54e6bead0e054aefaa30c7e83d9ad1d686ae2f8cbd5b6cb850e0833bab0 +name: typson-core+version: 0.1.0.1+synopsis: Type-safe PostgreSQL JSON Querying+description: Please see the README on GitHub at <https://github.com/aaronallen8455/typson#readme>+category: Database+homepage: https://github.com/aaronallen8455/typson#readme+bug-reports: https://github.com/aaronallen8455/typson/issues+author: Aaron Allen+maintainer: aaronallen8455@gmail.com+copyright: 2020 Aaron Allen+license: BSD3+license-file: LICENSE+build-type: Simple+extra-source-files:+ ChangeLog.md+ README.md+ source-repository head- type: git- location: https://github.com/aaronallen8455/typson+ type: git+ location: https://github.com/aaronallen8455/typson library- exposed-modules:- Typson- Typson.JsonTree- Typson.Optics- Typson.Pathing-- hs-source-dirs: src- other-modules: Paths_typson_core- default-language: Haskell2010- ghc-options: -Wall- build-depends:- aeson >=1.4.7.1 && <1.5,- base >=4.7 && <5,- containers >=0.6.2.1 && <0.7,- profunctors >=5.5.2 && <5.6,- text >=1.2.4.0 && <1.3,- unordered-containers >=0.2.10.0 && <0.3,- vector >=0.12.1.2 && <0.13+ exposed-modules:+ Typson+ Typson.JsonTree+ Typson.Optics+ Typson.Pathing+ other-modules:+ Paths_typson_core+ hs-source-dirs:+ src+ ghc-options: -Wall+ build-depends:+ aeson+ , base >=4.7 && <5+ , containers+ , profunctors+ , text+ , unordered-containers+ , vector+ default-language: Haskell2010