Skip to content

Commit b663c92

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 b663c92

File tree

17 files changed

+673
-28
lines changed

17 files changed

+673
-28
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: 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/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/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+
-- ForeignKeyNoAction -- ON UPDATE action
310+
-- 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+
-> ForeignKeyAction -- ^ ON UPDATE action
319+
-> 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)