Skip to content

Commit 11a7e8c

Browse files
committed
Add support for foreign key constraints & actions
This commit adds support for foreign key constraints and actions to `beam-migrate`, with support in the Postgres and SQLite backends: - The `ForeignKeyAction` datatype is used to represent possible actions to perform when updating or deleting a row with referencing foreign keys. - `createTableActionProvider` now takes an additional `ForeignKeySupport` argument: a witness of evidence for `IsSql92ForeignKeyTableConstraintSyntax` for backends that support it. - Postgres and SQLite support for parsing existing foreign key constraints. - Introduce the `addTableForeignKey` function for declaring new foreign key constraints.
1 parent 4139e50 commit 11a7e8c

File tree

20 files changed

+699
-31
lines changed

20 files changed

+699
-31
lines changed

beam-migrate/ChangeLog.md

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,19 @@
1+
2+
# Unreleased
3+
4+
## Added features
5+
6+
* Added support for foreign key constraints:
7+
8+
* New `ForeignKeyAction` datatype representing possible actions when updating
9+
or deleting a row with referencing foreign keys.
10+
* New `IsSql92ForeignKeyTableConstraintSyntax` typeclass with method
11+
`foreignKeyConstraintSyntax` for constructing foreign key constraint syntax.
12+
* `createTableActionProvider` now takes an additional `ForeignKeySupport`
13+
argument: a witness of evidence for `IsSql92ForeignKeyTableConstraintSyntax`
14+
for backends that support it.
15+
* Introduce `addTableForeignKey` for declaring new foreign key constraints.
16+
117
# 0.5.4.0
218

319
## Added features

beam-migrate/Database/Beam/Haskell/Syntax.hs

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,7 @@ import Data.Char (toLower, toUpper)
2525
import Data.Hashable
2626
import Data.Int
2727
import Data.List (find, nub)
28+
import qualified Data.List.NonEmpty as NE
2829
import qualified Data.Map as M
2930
import Data.Maybe
3031
import qualified Data.Set as S
@@ -591,7 +592,9 @@ instance IsSql92TableConstraintSyntax HsTableConstraint where
591592
tableTypeNm = tblNm <> "T"
592593
tableTypeKeyNm = tblNm <> "Key"
593594

594-
(fieldRecordNames, fieldTys) = unzip (fromMaybe (error "fieldTys") (mapM (hsFieldLookup tblFields) fields))
595+
(fieldRecordNames, fieldTys) =
596+
unzip (fromMaybe (error "fieldTys")
597+
(mapM (hsFieldLookup tblFields) $ NE.toList fields))
595598

596599
primaryKeyType = tyApp (tyConNamed "PrimaryKey") [ tyConNamed (T.unpack tableTypeNm), tyVarNamed "f" ]
597600
primaryKeyConDecl = Hs.QualConDecl () Nothing Nothing (Hs.ConDecl () (Hs.Ident () (T.unpack tableTypeKeyNm)) fieldTys)

beam-migrate/Database/Beam/Migrate/Actions.hs

Lines changed: 63 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
{-# OPTIONS_GHC -fno-warn-orphans #-}
2+
{-# LANGUAGE GADTs #-}
23
{-# LANGUAGE TupleSections #-}
34
{-# LANGUAGE BangPatterns #-}
45
{-# LANGUAGE ScopedTypeVariables #-}
@@ -78,6 +79,7 @@ module Database.Beam.Migrate.Actions
7879
, ensuringNot_
7980
, justOne_
8081

82+
, ForeignKeySupport(..)
8183
, createSchemaActionProvider
8284
, createTableActionProvider
8385
, dropTableActionProvider
@@ -107,6 +109,7 @@ import Control.Monad
107109
import Control.Parallel.Strategies
108110

109111
import Data.Foldable
112+
import qualified Data.List.NonEmpty as NE
110113

111114
import qualified Data.HashMap.Strict as HM
112115
import qualified Data.HashSet as HS
@@ -279,6 +282,16 @@ createIndexWeight, dropIndexWeight :: Int
279282
createIndexWeight = 200
280283
dropIndexWeight = 50
281284

285+
-- | Witness for whether a backend's table constraint syntax supports
286+
-- @FOREIGN KEY@ clauses in @CREATE TABLE@ statements.
287+
data ForeignKeySupport be where
288+
-- | The backend supports @FOREIGN KEY@ constraints in @CREATE TABLE@.
289+
ForeignKeySupported
290+
:: IsSql92ForeignKeyTableConstraintSyntax (BeamSqlBackendTableConstraintSyntax be)
291+
=> ForeignKeySupport be
292+
-- | The backend does not support @FOREIGN KEY@ constraints.
293+
ForeignKeyUnsupported :: ForeignKeySupport be
294+
282295
-- | Proceeds only if no predicate matches the given pattern. See the
283296
-- implementation of 'dropTableActionProvider' for an example of usage.
284297
ensuringNot_ :: Alternative m => [ a ] -> m ()
@@ -342,10 +355,13 @@ dropSchemaActionProvider =
342355
("Drop schema " <> preSchemaNm) dropSchemaWeight)
343356

344357
-- | Action provider for SQL92 @CREATE TABLE@ actions.
345-
createTableActionProvider :: forall be
346-
. ( Typeable be, BeamMigrateOnlySqlBackend be )
347-
=> ActionProvider be
348-
createTableActionProvider =
358+
createTableActionProvider
359+
:: forall be
360+
. ( Typeable be, BeamMigrateOnlySqlBackend be )
361+
=> ForeignKeySupport be
362+
-- ^ a witness of whether the backend supports FOREIGN KEY constraints
363+
-> ActionProvider be
364+
createTableActionProvider fkSupport =
349365
ActionProvider provider
350366
where
351367
provider :: ActionProviderFn be
@@ -377,9 +393,45 @@ createTableActionProvider =
377393
guard (tblNm == postTblNm)
378394
pure (primaryKeyP, primaryKey)
379395

380-
let postConditions = [ p tblP, p primaryKeyP ] ++ concat columnsP
396+
let (fkPs, fkConstraints) = case fkSupport of
397+
ForeignKeySupported ->
398+
unzip
399+
[ ( p fkP
400+
, foreignKeyConstraintSyntax localCols (qnameAsText refTbl) refCols onUpd onDel )
401+
| fkP@(TableHasForeignKey tblNm localCols refTbl refCols onUpd onDel)
402+
<- findPostConditions
403+
, tblNm == postTblNm
404+
]
405+
ForeignKeyUnsupported -> ([], [])
406+
407+
-- For each foreign key constraint, ensure the referenced table
408+
-- already exists in the current state.
409+
-- This enforces a topological ordering, so that the solver schedules
410+
-- CREATE TABLE for all referenced tables first.
411+
--
412+
-- Note: this does not support cycles in the foreign key reference graph,
413+
-- for which we would need ALTER TABLE ADD CONSTRAINT, which we don't
414+
-- currently support.
415+
case fkSupport of
416+
ForeignKeyUnsupported -> pure ()
417+
ForeignKeySupported -> do
418+
let refTbls =
419+
[ refTbl
420+
| TableHasForeignKey tblNm _ refTbl _ _ _ <- findPostConditions
421+
, tblNm == postTblNm
422+
, refTbl /= postTblNm
423+
]
424+
for_ refTbls $ \refTbl -> do
425+
TableExistsPredicate nm <- findPreConditions
426+
guard (nm == refTbl)
427+
428+
429+
let postConditions = [ p tblP, p primaryKeyP ] ++ concat columnsP ++ fkPs
381430
cmd = createTableCmd (createTableSyntax Nothing (qnameAsTableName postTblNm) colsSyntax tblConstraints)
382-
tblConstraints = if null primaryKey then [] else [ primaryKeyConstraintSyntax primaryKey ]
431+
tblConstraints = (case NE.nonEmpty primaryKey of
432+
Nothing -> []
433+
Just pkey -> [primaryKeyConstraintSyntax pkey])
434+
++ fkConstraints
383435
colsSyntax = map (\(colNm, type_, cs) -> (colNm, columnSchemaSyntax type_ Nothing cs Nothing)) columns
384436
pure (PotentialAction mempty (HS.fromList postConditions)
385437
(Seq.singleton (MigrationCommand cmd MigrationKeepsData))
@@ -590,17 +642,18 @@ dropIndexActionProvider = ActionProvider provider
590642
-- For default schema actions, see 'defaultSchemaActionProvider'.
591643
defaultActionProvider :: ( Typeable be
592644
, BeamMigrateOnlySqlBackend be )
593-
=> ActionProvider be
594-
defaultActionProvider =
645+
=> ForeignKeySupport be
646+
-> ActionProvider be
647+
defaultActionProvider fkSupport =
595648
mconcat
596-
[ createTableActionProvider
649+
[ createTableActionProvider fkSupport
597650
, dropTableActionProvider
598651

599652
, addColumnProvider
600653
, dropColumnProvider
601654

602655
, addColumnNullProvider
603-
, dropColumnNullProvider
656+
, dropColumnNullProvider
604657
]
605658

606659
-- | Default action providers for any syntax which supports schemas.

beam-migrate/Database/Beam/Migrate/Checks.hs

Lines changed: 78 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,8 @@ import Database.Beam.Migrate.SQL.Types
1111
import Database.Beam.Migrate.Serialization
1212
import Database.Beam.Migrate.Types.Predicates
1313

14-
import Data.Aeson ((.:), (.=), withObject, object)
14+
import Data.Aeson ((.:), (.:?), (.=), withObject, object)
15+
import Data.Maybe (fromMaybe)
1516
import Data.Aeson.Types (Parser, Value)
1617
import Data.Hashable (Hashable(..))
1718
import qualified Data.List.NonEmpty as NE (NonEmpty)
@@ -168,6 +169,67 @@ instance DatabasePredicate TableHasPrimaryKey where
168169
| Just (TableExistsPredicate tblNm') <- cast p' = tblNm' == tblNm
169170
| otherwise = False
170171

172+
-- | Asserts that the given table has a foreign key referencing another table.
173+
-- Create these predicates with
174+
-- 'Database.Beam.Migrate.Types.CheckedEntities.addTableForeignKey'.
175+
data TableHasForeignKey
176+
= TableHasForeignKey
177+
{ hasForeignKey_table :: QualifiedName -- ^ source table
178+
, hasForeignKey_columns :: NE.NonEmpty Text -- ^ local columns (in order)
179+
, hasForeignKey_refTable :: QualifiedName -- ^ referenced table
180+
, hasForeignKey_refColumns :: NE.NonEmpty Text -- ^ referenced columns (in order)
181+
, hasForeignKey_onUpdate :: ForeignKeyAction
182+
, hasForeignKey_onDelete :: ForeignKeyAction
183+
} deriving (Show, Eq, Generic)
184+
instance Hashable TableHasForeignKey
185+
instance DatabasePredicate TableHasForeignKey where
186+
englishDescription (TableHasForeignKey { hasForeignKey_table = tbl
187+
, hasForeignKey_columns = cols
188+
, hasForeignKey_refTable = refTbl
189+
, hasForeignKey_refColumns = refCols
190+
, hasForeignKey_onUpdate = onUpd
191+
, hasForeignKey_onDelete = onDel }) =
192+
"Table " <> show tbl <> " has foreign key on " <> show cols <>
193+
" referencing " <> show refTbl <> " " <> show refCols <>
194+
" ON UPDATE " <> show onUpd <>
195+
" ON DELETE " <> show onDel
196+
197+
predicateSpecificity _ = PredicateSpecificityAllBackends
198+
199+
serializePredicate (TableHasForeignKey { hasForeignKey_table = tbl
200+
, hasForeignKey_columns = cols
201+
, hasForeignKey_refTable = refTbl
202+
, hasForeignKey_refColumns = refCols
203+
, hasForeignKey_onUpdate = onUpd
204+
, hasForeignKey_onDelete = onDel }) =
205+
object [ "has-foreign-key" .= object
206+
[ "table" .= tbl
207+
, "columns" .= cols
208+
, "ref-table" .= refTbl
209+
, "ref-columns" .= refCols
210+
, "on-update" .= serializeForeignKeyAction onUpd
211+
, "on-delete" .= serializeForeignKeyAction onDel
212+
] ]
213+
214+
predicateCascadesDropOn (TableHasForeignKey { hasForeignKey_table = tblNm }) p'
215+
| Just (TableExistsPredicate tblNm') <- cast p' = tblNm' == tblNm
216+
| otherwise = False
217+
218+
serializeForeignKeyAction :: ForeignKeyAction -> Text
219+
serializeForeignKeyAction ForeignKeyActionCascade = "cascade"
220+
serializeForeignKeyAction ForeignKeyActionSetNull = "set-null"
221+
serializeForeignKeyAction ForeignKeyActionSetDefault = "set-default"
222+
serializeForeignKeyAction ForeignKeyActionRestrict = "restrict"
223+
serializeForeignKeyAction ForeignKeyNoAction = "no-action"
224+
225+
deserializeForeignKeyAction :: Text -> Maybe ForeignKeyAction
226+
deserializeForeignKeyAction "cascade" = Just ForeignKeyActionCascade
227+
deserializeForeignKeyAction "set-null" = Just ForeignKeyActionSetNull
228+
deserializeForeignKeyAction "set-default" = Just ForeignKeyActionSetDefault
229+
deserializeForeignKeyAction "restrict" = Just ForeignKeyActionRestrict
230+
deserializeForeignKeyAction "no-action" = Just ForeignKeyNoAction
231+
deserializeForeignKeyAction _ = Nothing
232+
171233
-- * Deserialization
172234

173235
-- | 'BeamDeserializers' for all the predicates defined in this module
@@ -182,6 +244,7 @@ beamCheckDeserializers = mconcat
182244
, beamDeserializer (const deserializeTableExistsPredicate)
183245
, beamDeserializer (const deserializeTableHasPrimaryKeyPredicate)
184246
, beamDeserializer (const deserializeTableHasIndexPredicate)
247+
, beamDeserializer (const deserializeTableHasForeignKeyPredicate)
185248
, beamDeserializer deserializeTableHasColumnPredicate
186249
, beamDeserializer deserializeTableColumnHasConstraintPredicate
187250
]
@@ -214,6 +277,20 @@ beamCheckDeserializers = mconcat
214277
<*> v' .: "columns"
215278
<*> (deserializeIndexOptions @(BeamSqlBackendSyntax be) =<< v' .: "options")))
216279

280+
deserializeTableHasForeignKeyPredicate :: Value -> Parser SomeDatabasePredicate
281+
deserializeTableHasForeignKeyPredicate =
282+
withObject "TableHasForeignKey" $ \v ->
283+
v .: "has-foreign-key" >>=
284+
(withObject "TableHasForeignKey" $ \v' ->
285+
SomeDatabasePredicate <$>
286+
(TableHasForeignKey
287+
<$> v' .: "table"
288+
<*> v' .: "columns"
289+
<*> v' .: "ref-table"
290+
<*> v' .: "ref-columns"
291+
<*> (fromMaybe ForeignKeyNoAction . (>>= deserializeForeignKeyAction) <$> v' .:? "on-update")
292+
<*> (fromMaybe ForeignKeyNoAction . (>>= deserializeForeignKeyAction) <$> v' .:? "on-delete")))
293+
217294
deserializeTableHasColumnPredicate :: BeamDeserializers be'
218295
-> Value -> Parser SomeDatabasePredicate
219296
deserializeTableHasColumnPredicate d =

beam-migrate/Database/Beam/Migrate/SQL/Builder.hs

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,9 @@ import Control.Applicative
1414
import Data.ByteString.Builder (Builder, byteString, toLazyByteString)
1515
import qualified Data.ByteString.Lazy.Char8 as BCL
1616

17+
import qualified Data.List.NonEmpty as NE
18+
19+
1720

1821
-- | Options for @CREATE TABLE@. Given as a separate ADT because the options may
1922
-- go in different places syntactically.
@@ -124,7 +127,7 @@ instance IsSql92CreateTableSyntax SqlSyntaxBuilder where
124127
instance IsSql92TableConstraintSyntax SqlSyntaxBuilder where
125128
primaryKeyConstraintSyntax fs =
126129
SqlSyntaxBuilder $
127-
byteString "PRIMARY KEY(" <> buildSepBy (byteString ", ") (map quoteSql fs) <> byteString ")"
130+
byteString "PRIMARY KEY(" <> buildSepBy (byteString ", ") (map quoteSql $ NE.toList fs) <> byteString ")"
128131

129132
-- | Some backends use this to represent their constraint attributes. Does not
130133
-- need to be used in practice.

beam-migrate/Database/Beam/Migrate/SQL/SQL92.hs

Lines changed: 35 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -17,7 +17,8 @@ import Data.Hashable
1717
import Data.Kind (Type)
1818
import qualified Data.List.NonEmpty as NE (NonEmpty)
1919
import Data.Text (Text)
20-
import Data.Typeable
20+
import Data.Typeable (Typeable)
21+
import GHC.Generics (Generic)
2122

2223
-- * Convenience type synonyms
2324

@@ -184,8 +185,40 @@ class ( IsSql92ColumnConstraintDefinitionSyntax (Sql92ColumnSchemaColumnConstrai
184185
-> Maybe Text {-^ Default collation -}
185186
-> columnSchema
186187

188+
-- | Action to perform on referencing foreign keys when a row is modified.
189+
data ForeignKeyAction
190+
= ForeignKeyActionCascade
191+
-- ^ @CASCADE@: propagate the action to referencing foreign key columns.
192+
| ForeignKeyActionSetNull
193+
-- ^ @SET NULL@: set the referencing foreign key colums to @NULL@.
194+
| ForeignKeyActionSetDefault
195+
-- ^ @SET DEFAULT@: set the referencing foreign key columns to their default
196+
-- values.
197+
| ForeignKeyActionRestrict
198+
-- ^ @RESTRICT@: prohibit modification of a row when foreign key references
199+
-- to that row exist.
200+
| ForeignKeyNoAction
201+
-- ^ @NO ACTION@
202+
deriving (Show, Eq, Ord, Generic)
203+
instance Hashable ForeignKeyAction
204+
187205
class Typeable constraint => IsSql92TableConstraintSyntax constraint where
188-
primaryKeyConstraintSyntax :: [ Text ] -> constraint
206+
primaryKeyConstraintSyntax :: NE.NonEmpty Text -> constraint
207+
208+
-- | Class for table constraint syntaxes that support the SQL @FOREIGN KEY@
209+
-- clause.
210+
class IsSql92TableConstraintSyntax constraint =>
211+
IsSql92ForeignKeyTableConstraintSyntax constraint where
212+
-- | Emit a @FOREIGN KEY (cols) REFERENCES tbl (refCols)@ table constraint.
213+
--
214+
-- Use 'addTableForeignKey' to attach a foreign key to a schema definition.
215+
foreignKeyConstraintSyntax
216+
:: NE.NonEmpty Text -- ^ local columns
217+
-> Text -- ^ referenced table name
218+
-> NE.NonEmpty Text -- ^ referenced columns
219+
-> ForeignKeyAction -- ^ ON UPDATE action
220+
-> ForeignKeyAction -- ^ ON DELETE action
221+
-> constraint
189222

190223
class Typeable match => IsSql92MatchTypeSyntax match where
191224
fullMatchSyntax :: match

beam-migrate/Database/Beam/Migrate/SQL/Tables.hs

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -55,6 +55,7 @@ import Control.Monad.State
5555

5656
import Data.Coerce (coerce)
5757
import Data.Kind (Type)
58+
import qualified Data.List.NonEmpty as NE
5859
import Data.Text (Text)
5960
import Data.Typeable
6061
import qualified Data.Kind as Kind (Constraint)
@@ -63,6 +64,7 @@ import GHC.TypeLits
6364

6465
import Lens.Micro ((^.))
6566

67+
6668
-- * Table manipulation
6769

6870
-- | Add a @CREATE TABLE@ statement to this migration
@@ -143,7 +145,10 @@ createTableWithSchema :: ( Beamable table, Table table
143145
-> Migration be (CheckedDatabaseEntity be db (TableEntity table))
144146
createTableWithSchema maybeSchemaName newTblName tblSettings =
145147
do let pkFields = allBeamValues (\(Columnar' (TableFieldSchema name _ _)) -> name) (primaryKey tblSettings)
146-
tblConstraints = if null pkFields then [] else [ primaryKeyConstraintSyntax pkFields ]
148+
tblConstraints =
149+
case NE.nonEmpty pkFields of
150+
Nothing -> []
151+
Just pks -> [ primaryKeyConstraintSyntax pks ]
147152
createTableCommand =
148153
createTableSyntax Nothing (tableName (coerce <$> maybeSchemaName) newTblName)
149154
(allBeamValues (\(Columnar' (TableFieldSchema name (FieldSchema schema) _)) -> (name, schema)) tblSettings)

beam-migrate/Database/Beam/Migrate/SQL/Types.hs

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@ module Database.Beam.Migrate.SQL.Types
1919
, BeamSqlBackendReferentialActionSyntax
2020
, BeamSqlBackendConstraintAttributesSyntax
2121
, BeamSqlBackendIndexSyntax
22+
, BeamSqlBackendTableConstraintSyntax
2223
) where
2324

2425
import Database.Beam.Migrate.Types.Predicates
@@ -93,4 +94,6 @@ type BeamSqlBackendReferentialActionSyntax be
9394
type BeamSqlBackendConstraintAttributesSyntax be
9495
= Sql92DdlCommandConstraintAttributesSyntax (BeamSqlBackendSyntax be)
9596
type BeamSqlBackendIndexSyntax be
96-
= Sql92CreateIndexOptionsSyntax (BeamSqlBackendSyntax be)
97+
= Sql92CreateIndexOptionsSyntax (BeamSqlBackendSyntax be)
98+
type BeamSqlBackendTableConstraintSyntax be
99+
= Sql92CreateTableTableConstraintSyntax (Sql92DdlCommandCreateTableSyntax (BeamSqlBackendSyntax be))

beam-migrate/Database/Beam/Migrate/Simple.hs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -372,7 +372,7 @@ haskellSchema BeamMigrationBackend { backendGetDbConstraints = getCs
372372
constraints <- getCs
373373
let hsConstraints = [ hsConstraint | c <- constraints, Just hsConstraint <- [ conv2Hs c ] ]
374374

375-
solver = heuristicSolver (defaultActionProvider @HsMigrateBackend) [] hsConstraints
375+
solver = heuristicSolver (defaultActionProvider @HsMigrateBackend ForeignKeyUnsupported) [] hsConstraints
376376

377377
case finalSolution solver of
378378
Solved cmds ->

0 commit comments

Comments
 (0)