Skip to content

Commit 1620314

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 1620314

File tree

17 files changed

+654
-22
lines changed

17 files changed

+654
-22
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/Migrate/Actions.hs

Lines changed: 61 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,43 @@ 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 (NE.toList localCols) (qnameAsText refTbl) (NE.toList 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 = (if null primaryKey then [] else [ primaryKeyConstraintSyntax primaryKey ])
432+
++ fkConstraints
383433
colsSyntax = map (\(colNm, type_, cs) -> (colNm, columnSchemaSyntax type_ Nothing cs Nothing)) columns
384434
pure (PotentialAction mempty (HS.fromList postConditions)
385435
(Seq.singleton (MigrationCommand cmd MigrationKeepsData))
@@ -590,17 +640,18 @@ dropIndexActionProvider = ActionProvider provider
590640
-- For default schema actions, see 'defaultSchemaActionProvider'.
591641
defaultActionProvider :: ( Typeable be
592642
, BeamMigrateOnlySqlBackend be )
593-
=> ActionProvider be
594-
defaultActionProvider =
643+
=> ForeignKeySupport be
644+
-> ActionProvider be
645+
defaultActionProvider fkSupport =
595646
mconcat
596-
[ createTableActionProvider
647+
[ createTableActionProvider fkSupport
597648
, dropTableActionProvider
598649

599650
, addColumnProvider
600651
, dropColumnProvider
601652

602653
, addColumnNullProvider
603-
, dropColumnNullProvider
654+
, dropColumnNullProvider
604655
]
605656

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

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

Lines changed: 74 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -168,6 +168,65 @@ instance DatabasePredicate TableHasPrimaryKey where
168168
| Just (TableExistsPredicate tblNm') <- cast p' = tblNm' == tblNm
169169
| otherwise = False
170170

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

173232
-- | 'BeamDeserializers' for all the predicates defined in this module
@@ -182,6 +241,7 @@ beamCheckDeserializers = mconcat
182241
, beamDeserializer (const deserializeTableExistsPredicate)
183242
, beamDeserializer (const deserializeTableHasPrimaryKeyPredicate)
184243
, beamDeserializer (const deserializeTableHasIndexPredicate)
244+
, beamDeserializer (const deserializeTableHasForeignKeyPredicate)
185245
, beamDeserializer deserializeTableHasColumnPredicate
186246
, beamDeserializer deserializeTableColumnHasConstraintPredicate
187247
]
@@ -214,6 +274,20 @@ beamCheckDeserializers = mconcat
214274
<*> v' .: "columns"
215275
<*> (deserializeIndexOptions @(BeamSqlBackendSyntax be) =<< v' .: "options")))
216276

277+
deserializeTableHasForeignKeyPredicate :: Value -> Parser SomeDatabasePredicate
278+
deserializeTableHasForeignKeyPredicate =
279+
withObject "TableHasForeignKey" $ \v ->
280+
v .: "has-foreign-key" >>=
281+
(withObject "TableHasForeignKey" $ \v' ->
282+
SomeDatabasePredicate <$>
283+
(TableHasForeignKey
284+
<$> v' .: "table"
285+
<*> v' .: "columns"
286+
<*> v' .: "ref-table"
287+
<*> v' .: "ref-columns"
288+
<*> (fmap (>>= deserializeForeignKeyAction) (v' .: "on-update"))
289+
<*> (fmap (>>= deserializeForeignKeyAction) (v' .: "on-delete"))))
290+
217291
deserializeTableHasColumnPredicate :: BeamDeserializers be'
218292
-> Value -> Parser SomeDatabasePredicate
219293
deserializeTableHasColumnPredicate d =

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

Lines changed: 33 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,38 @@ 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 colums to their default
196+
-- values.
197+
| ForeignKeyActionRestrict
198+
-- ^ @RESTRICT@: prohibit modification of a row when foreign key references
199+
-- to that row exist.
200+
deriving (Show, Eq, Ord, Generic)
201+
instance Hashable ForeignKeyAction
202+
187203
class Typeable constraint => IsSql92TableConstraintSyntax constraint where
188-
primaryKeyConstraintSyntax :: [ Text ] -> constraint
204+
primaryKeyConstraintSyntax :: [Text] -> constraint
205+
206+
-- | Class for table constraint syntaxes that support the SQL @FOREIGN KEY@
207+
-- clause.
208+
class IsSql92TableConstraintSyntax constraint =>
209+
IsSql92ForeignKeyTableConstraintSyntax constraint where
210+
-- | Emit a @FOREIGN KEY (cols) REFERENCES tbl (refCols)@ table constraint.
211+
--
212+
-- Use 'addTableForeignKey' to attach a foreign key to a schema definition.
213+
foreignKeyConstraintSyntax
214+
:: [Text] -- ^ local columns
215+
-> Text -- ^ referenced table name
216+
-> [Text] -- ^ referenced columns
217+
-> Maybe ForeignKeyAction -- ^ ON UPDATE action
218+
-> Maybe ForeignKeyAction -- ^ ON DELETE action
219+
-> constraint
189220

190221
class Typeable match => IsSql92MatchTypeSyntax match where
191222
fullMatchSyntax :: match

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 ->

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

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,9 @@ module Database.Beam.Migrate.Types
3030

3131
, IsSql92CreateDropIndexSyntax(..)
3232

33+
, addTableForeignKey
34+
, primaryKeyColumns
35+
3336
-- * Predicates
3437
, DatabasePredicate(..)
3538
, SomeDatabasePredicate(..)

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

Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -298,6 +298,43 @@ addTableIndex idxNm opts getCols =
298298
in CheckedDatabaseEntity (CheckedDatabaseTable dt (tblChecks ++ [idxCheck]) fieldChecks)
299299
extraChecks
300300

301+
-- | Declare a foreign key constraint on a checked table entity.
302+
--
303+
-- Example referencing the primary key of a @UserT@ table:
304+
--
305+
-- @
306+
-- addTableForeignKey (db ^. usersTable)
307+
-- (\\ t -> selectorColumnName _postAuthorId t NE.:| [])
308+
-- primaryKeyColumns
309+
-- Nothing -- ON UPDATE action
310+
-- (Just ForeignKeyActionCascade) -- ON DELETE action
311+
-- @
312+
addTableForeignKey
313+
:: forall be db localTbl refTbl.
314+
(Table localTbl, Table refTbl)
315+
=> CheckedDatabaseEntity be db (TableEntity refTbl) -- ^ referenced table
316+
-> (localTbl (TableField localTbl) -> NE.NonEmpty Text) -- ^ local columns
317+
-> (refTbl (TableField refTbl) -> NE.NonEmpty Text) -- ^ referenced columns
318+
-> Maybe ForeignKeyAction -- ^ ON UPDATE action
319+
-> Maybe ForeignKeyAction -- ^ ON DELETE action
320+
-> EntityModification (CheckedDatabaseEntity be db) be (TableEntity localTbl)
321+
addTableForeignKey refTableEntity getLocalFk getRefFk onUpdate onDelete =
322+
EntityModification $ Endo $
323+
\(CheckedDatabaseEntity (CheckedDatabaseTable dt tblChecks fieldChecks) extraChecks) ->
324+
let
325+
CheckedDatabaseEntity (CheckedDatabaseTable refDt _ _) _ = refTableEntity
326+
refTableName = QualifiedName (dbTableSchema refDt) (dbTableCurrentName refDt)
327+
328+
localCols = getLocalFk (dbTableSettings dt)
329+
refCols = getRefFk (dbTableSettings refDt)
330+
331+
fkCheck = TableCheck $ \tblNm _flds ->
332+
Just (SomeDatabasePredicate
333+
(TableHasForeignKey tblNm localCols refTableName refCols onUpdate onDelete))
334+
335+
in CheckedDatabaseEntity (CheckedDatabaseTable dt (tblChecks ++ [fkCheck]) fieldChecks)
336+
extraChecks
337+
301338
-- | Produce a table field modification that does nothing
302339
--
303340
-- Most commonly supplied as the second argument to 'modifyCheckedTable' when

beam-postgres/ChangeLog.md

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,10 @@
1+
# Unreleased
2+
3+
## Added features
4+
5+
* `getDbConstraintsForSchemas` now discovers foreign key constraints
6+
via `pg_constraint`, including `ON DELETE` / `ON UPDATE` actions.
7+
18
# 0.5.5.0
29

310
## Added features

0 commit comments

Comments
 (0)