Skip to content
Merged
Show file tree
Hide file tree
Changes from 2 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions api-tools.cabal
Original file line number Diff line number Diff line change
Expand Up @@ -176,6 +176,8 @@ Test-Suite test-api-tools
Data.API.Test.Migration
Data.API.Test.MigrationData
Data.API.Test.Time
Data.API.Test.UnionMigration
Data.API.Test.UnionMigrationData

Build-depends:
api-tools,
Expand Down
39 changes: 31 additions & 8 deletions src/Data/API/Changes.hs
Original file line number Diff line number Diff line change
Expand Up @@ -200,10 +200,11 @@ data DataChecks = NoChecks -- ^ Not at all

-- | Whether to validate the dataset after this change
validateAfter :: DataChecks -> APIChange -> Bool
validateAfter chks (ChChangeField{}) = chks >= CheckCustom
validateAfter chks (ChCustomType{}) = chks >= CheckCustom
validateAfter chks (ChCustomAll{}) = chks >= CheckCustom
validateAfter chks _ = chks >= CheckAll
validateAfter chks (ChChangeField{}) = chks >= CheckCustom
validateAfter chks (ChChangeUnionAlt{}) = chks >= CheckCustom
validateAfter chks (ChCustomType{}) = chks >= CheckCustom
validateAfter chks (ChCustomAll{}) = chks >= CheckCustom
validateAfter chks _ = chks >= CheckAll


--------------------
Expand Down Expand Up @@ -255,10 +256,11 @@ changelogTags (ChangesUpTo _ cs older) =

-- | Sets of custom migration tags in a single change
changeTags :: APIChange -> (Set MigrationTag, Set MigrationTag, Set MigrationTag)
changeTags (ChChangeField _ _ _ t) = (Set.empty, Set.empty, Set.singleton t)
changeTags (ChCustomType _ t) = (Set.empty, Set.singleton t, Set.empty)
changeTags (ChCustomAll t) = (Set.singleton t, Set.empty, Set.empty)
changeTags _ = (Set.empty, Set.empty, Set.empty)
changeTags (ChChangeField _ _ _ t) = (Set.empty, Set.empty, Set.singleton t)
changeTags (ChChangeUnionAlt _ _ _ t) = (Set.empty, Set.singleton t, Set.empty)
changeTags (ChCustomType _ t) = (Set.empty, Set.singleton t, Set.empty)
Comment thread
adinapoli marked this conversation as resolved.
Outdated
changeTags (ChCustomAll t) = (Set.singleton t, Set.empty, Set.empty)
changeTags _ = (Set.empty, Set.empty, Set.empty)


--------------------------------
Expand Down Expand Up @@ -484,6 +486,14 @@ applyAPIChangeToAPI _ _ (ChRenameUnionAlt tname fname fname') api = do
. Map.delete fname) unioninfo
return (Map.insert tname tinfo' api, findUpdatePos tname api)

applyAPIChangeToAPI _ _custom (ChChangeUnionAlt tname fname ftype _tag) api = do
tinfo <- lookupType tname api
unioninfo <- expectUnionType tinfo ?! TypeWrongKind tname TKUnion
guard (Map.member fname unioninfo) ?! FieldDoesNotExist tname TKUnion fname
typeIsValid ftype api ?!? TypeMalformed ftype
let tinfo' = (NUnionType . Map.insert fname ftype) unioninfo
return (Map.insert tname tinfo' api, findUpdatePos tname api)

applyAPIChangeToAPI _ _ (ChAddEnumVal tname fname) api = do
tinfo <- lookupType tname api
enuminfo <- expectEnumType tinfo ?! TypeWrongKind tname TKEnum
Expand Down Expand Up @@ -607,6 +617,13 @@ applyChangeToData (ChRenameUnionAlt _ fname fname') _ = withObject $ \un p ->
| otherwise -> return un
Nothing -> Left (JSONError $ SyntaxError "Not singleton", p)

applyChangeToData (ChChangeUnionAlt _ fname _ftype tag) custom = withObject $ \un p ->
case matchSingletonObject un of
Just (k, r) | k == _FieldName fname -> do
r' <- liftMigration (typeMigration custom tag) r p
Comment thread
adinapoli marked this conversation as resolved.
Outdated
return $ singletonObject (_FieldName fname) r'
_ -> return un

applyChangeToData (ChRenameEnumVal _ fname fname') _ = withString $ \s _ ->
if s == _FieldName fname then return (_FieldName fname')
else return s
Expand Down Expand Up @@ -718,6 +735,12 @@ applyChangeToData' _ (ChRenameUnionAlt _ fname fname') _ v p = do
(fn, v') <- expectUnion v p
pure $! if fn == fname then Union fname' v' else v

applyChangeToData' _ (ChChangeUnionAlt _ fname _ftype tag) custom v p = do
(fn, v') <- expectUnion v p
if fn == fname
then Union fn <$!> liftMigration (typeMigration custom tag) v' (inField fn:p)
else pure v

applyChangeToData' _ (ChRenameEnumVal _ fname fname') _ v p = do
fn <- expectEnum v p
pure $! if fn == fname then Enum fname' else v
Expand Down
4 changes: 4 additions & 0 deletions src/Data/API/Changes/Types.hs
Original file line number Diff line number Diff line change
Expand Up @@ -57,6 +57,7 @@ data APIChange
| ChAddUnionAlt TypeName FieldName APIType
| ChDeleteUnionAlt TypeName FieldName
| ChRenameUnionAlt TypeName FieldName FieldName
| ChChangeUnionAlt TypeName FieldName APIType MigrationTag

-- Changes for enum types
| ChAddEnumVal TypeName FieldName
Expand Down Expand Up @@ -87,6 +88,9 @@ instance PPLines APIChange where
, " alternative removed " ++ pp f]
ppLines (ChRenameUnionAlt t f f') = [ "changed union " ++ pp t
, " alternative renamed " ++ pp f ++ " to " ++ pp f']
ppLines (ChChangeUnionAlt t f ty c) = [ "changed union " ++ pp t
, " alternative changed " ++ pp f ++ " :: " ++ pp ty
++ " migration " ++ pp c]
ppLines (ChAddEnumVal t f) = [ "changed enum " ++ pp t
, " alternative added " ++ pp f]
ppLines (ChDeleteEnumVal t f) = [ "changed enum " ++ pp t
Expand Down
9 changes: 6 additions & 3 deletions src/Data/API/Parse.y
Original file line number Diff line number Diff line change
Expand Up @@ -280,6 +280,7 @@ UnionChange :: { [UnionChange] }
: alternative added FieldName '::' Type { [UnChAdd $3 $5] }
| alternative removed FieldName { [UnChDelete $3] }
| alternative renamed FieldName to FieldName { [UnChRename $3 $5] }
| alternative changed FieldName '::' Type migration MigrationTag { [UnChChange $3 $5 $7] }
| comment { [] }

REnumChanges :: { [EnumChange] }
Expand Down Expand Up @@ -331,11 +332,13 @@ fldChangeToAPIChange t (FldChChange f ty m) = ChChangeField t f ty m
data UnionChange = UnChAdd FieldName APIType
| UnChDelete FieldName
| UnChRename FieldName FieldName
| UnChChange FieldName APIType MigrationTag

unionChangeToAPIChange :: TypeName -> UnionChange -> APIChange
unionChangeToAPIChange t (UnChAdd f ty) = ChAddUnionAlt t f ty
unionChangeToAPIChange t (UnChDelete f) = ChDeleteUnionAlt t f
unionChangeToAPIChange t (UnChRename f f') = ChRenameUnionAlt t f f'
unionChangeToAPIChange t (UnChAdd f ty) = ChAddUnionAlt t f ty
unionChangeToAPIChange t (UnChDelete f) = ChDeleteUnionAlt t f
unionChangeToAPIChange t (UnChRename f f') = ChRenameUnionAlt t f f'
unionChangeToAPIChange t (UnChChange f ty m) = ChChangeUnionAlt t f ty m

data EnumChange = EnChAdd FieldName
| EnChDelete FieldName
Expand Down
2 changes: 2 additions & 0 deletions tests/Data/API/Test/Main.hs
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import Data.API.API.Gen
import Data.API.Test.JSON
import Data.API.Test.Migration
import Data.API.Test.Time
import Data.API.Test.UnionMigration

import Test.Tasty
import Test.Tasty.QuickCheck
Expand All @@ -12,6 +13,7 @@ main = defaultMain tests

tests :: TestTree
tests = testGroup "api-tools" [ migrationTests
, unionMigrationTests
, jsonTests
, timeTests
, testProperty "Convert/unconvert" convertUncovertTest
Expand Down
168 changes: 168 additions & 0 deletions tests/Data/API/Test/UnionMigration.hs
Original file line number Diff line number Diff line change
@@ -0,0 +1,168 @@
{-# LANGUAGE OverloadedStrings #-}
{-# LANGUAGE TemplateHaskell #-}

-- | Tests for union alternative migration with type changes
--
-- This module tests the 'alternative changed' changelog feature, which allows
-- changing the type of a union alternative with a custom migration function.
module Data.API.Test.UnionMigration
( unionMigrationTests
) where

import Data.API.Changes
import Data.API.JSON
import Data.API.JSON.Compat
import Data.API.Types
import Data.API.Utils

import qualified Data.Aeson as JS
import qualified Data.Aeson.Encode.Pretty as JS
import qualified Data.ByteString.Lazy.Char8 as BL
import qualified Data.Text as T
import Data.Version
import Test.Tasty
import Test.Tasty.HUnit

import Data.API.Test.UnionMigrationData


-- Generate migration enums from changelog
$(generateMigrationKinds typeSwapChangelog "TypeSwapDbMigration" "TypeSwapRecordMigration" "TypeSwapFieldMigration")


-- -----------------------------------------------------------------------------
-- Type Swap Migration (PersonV1 -> PersonV2)
-- -----------------------------------------------------------------------------

-- | Migrate PersonV1 to PersonV2
--
-- PersonV1: { "name": "John" }
-- PersonV2: { "fullName": "John", "age": 0 }
--
-- This is a type migration because we're transforming the entire inner value
-- of the union alternative from one type to another.
migratePersonV1ToV2 :: TypeSwapRecordMigration -> JS.Value -> Either ValueError JS.Value
migratePersonV1ToV2 MigratePersonV1ToV2 (JS.Object obj) = do
nameVal <- lookupKey "name" obj ?! CustomMigrationError "missing 'name' field" (JS.Object obj)
case nameVal of
JS.String name -> return $ JS.Object $
insertKey "fullName" (JS.String name) $
singletonObject "age" (JS.Number 0)
_ -> Left $ CustomMigrationError "expected string for 'name'" (JS.Object obj)
migratePersonV1ToV2 MigratePersonV1ToV2 v =
Left $ CustomMigrationError "expected object for PersonV1" v


typeSwapMigration :: CustomMigrations JS.Object JS.Value TypeSwapDbMigration TypeSwapRecordMigration TypeSwapFieldMigration
typeSwapMigration = CustomMigrations
{ databaseMigration = \ _ -> noDataChanges
, databaseMigrationSchema = \ _ -> noSchemaChanges
, typeMigration = migratePersonV1ToV2
, typeMigrationSchema = \ _ -> noSchemaChanges
, fieldMigration = \ _ -> noDataChanges
}


-- Test data for type swap
--
-- Start: Container with MyUnion containing PersonV1
-- End: Container with MyUnion containing PersonV2

-- | Start data: { "person": { "person": { "name": "Alice" } } }
startTypeSwapData :: JS.Value
Just startTypeSwapData = JS.decode "{ \"person\": { \"person\": { \"name\": \"Alice\" } } }"

-- | Expected end data: { "person": { "person": { "fullName": "Alice", "age": 0 } } }
expectedTypeSwapData :: JS.Value
Just expectedTypeSwapData = JS.decode "{ \"person\": { \"person\": { \"fullName\": \"Alice\", \"age\": 0 } } }"

-- | Start data with "other" alternative (should pass through unchanged)
startOtherAltData :: JS.Value
Just startOtherAltData = JS.decode "{ \"person\": { \"other\": 42 } }"

-- | Expected end data for "other" alternative (unchanged)
expectedOtherAltData :: JS.Value
Just expectedOtherAltData = JS.decode "{ \"person\": { \"other\": 42 } }"


-- | Test migrating PersonV1 to PersonV2 within a union
typeSwapMigrationTest :: Assertion
typeSwapMigrationTest = do
-- Verify start data matches start schema
case dataMatchesAPI rootName startTypeSwapSchema startTypeSwapData of
Right () -> return ()
Left err -> assertFailure $ "Start data does not match start API: "
++ prettyValueErrorPosition err

-- Verify expected end data matches end schema
case dataMatchesAPI rootName endTypeSwapSchema expectedTypeSwapData of
Right () -> return ()
Left err -> assertFailure $ "Expected end data does not match end API: "
++ prettyValueErrorPosition err

-- Run migration
case migrateDataDump (startTypeSwapSchema, parseVer "0")
(endTypeSwapSchema, Release (parseVer "1.0"))
typeSwapChangelog typeSwapMigration rootName CheckAll
startTypeSwapData of
Right (v, [])
| expectedTypeSwapData == v -> return ()
| otherwise -> assertFailure $ unlines
[ "Type swap migration produced wrong result"
, "Expected:"
, BL.unpack (JS.encodePretty expectedTypeSwapData)
, "but got:"
, BL.unpack (JS.encodePretty v)
]
Right (_, ws) -> assertFailure $ "Unexpected warnings: " ++ show ws
Left err -> assertFailure $ "Migration failed: " ++ prettyMigrateFailure err


-- | Test that non-matching alternatives pass through unchanged
otherAlternativeUnchangedTest :: Assertion
otherAlternativeUnchangedTest = do
-- Verify start data matches start schema
case dataMatchesAPI rootName startTypeSwapSchema startOtherAltData of
Right () -> return ()
Left err -> assertFailure $ "Start data does not match start API: "
++ prettyValueErrorPosition err

-- Verify expected end data matches end schema
case dataMatchesAPI rootName endTypeSwapSchema expectedOtherAltData of
Right () -> return ()
Left err -> assertFailure $ "Expected end data does not match end API: "
++ prettyValueErrorPosition err

-- Run migration - "other" alternative should pass through unchanged
case migrateDataDump (startTypeSwapSchema, parseVer "0")
(endTypeSwapSchema, Release (parseVer "1.0"))
typeSwapChangelog typeSwapMigration rootName CheckAll
startOtherAltData of
Right (v, [])
| expectedOtherAltData == v -> return ()
| otherwise -> assertFailure $ unlines
[ "Other alternative was incorrectly modified"
, "Expected:"
, BL.unpack (JS.encodePretty expectedOtherAltData)
, "but got:"
, BL.unpack (JS.encodePretty v)
]
Right (_, ws) -> assertFailure $ "Unexpected warnings: " ++ show ws
Left err -> assertFailure $ "Migration failed: " ++ prettyMigrateFailure err


rootName :: TypeName
rootName = TypeName "Container"

parseVer :: String -> Version
parseVer s = case simpleParseVersion s of
Just v -> v
Nothing -> error $ "Invalid version: " ++ s


-- | All union migration tests
unionMigrationTests :: TestTree
unionMigrationTests = testGroup "Union Alternative Migration"
[ testCase "Type swap: PersonV1 -> PersonV2" typeSwapMigrationTest
, testCase "Other alternatives pass through unchanged" otherAlternativeUnchangedTest
]
86 changes: 86 additions & 0 deletions tests/Data/API/Test/UnionMigrationData.hs
Original file line number Diff line number Diff line change
@@ -0,0 +1,86 @@
{-# LANGUAGE QuasiQuotes #-}

-- | Data for union alternative migration tests
--
-- This module tests the 'alternative changed' changelog feature, which allows
-- swapping the type of a union alternative from one type to a completely
-- different type, with a custom migration function to transform the data.
module Data.API.Test.UnionMigrationData
( -- * Type swap scenario (PersonV1 -> PersonV2)
startTypeSwapSchema
, endTypeSwapSchema
, typeSwapChangelog
) where

import Data.API.Changes
import Data.API.Parse
import Data.API.Types


-- -----------------------------------------------------------------------------
-- Type Swap Scenario
--
-- This tests the primary use case: migrating a union alternative from one
-- type (PersonV1) to a completely different type (PersonV2).
--
-- PersonV1 has: name :: string
-- PersonV2 has: fullName :: string, age :: integer
--
-- The migration function transforms PersonV1 data to PersonV2 data.
-- -----------------------------------------------------------------------------

-- | Initial schema with PersonV1
startTypeSwapSchema :: API
startTypeSwapSchema = [api|

personV1Prefix :: PersonV1
= record
name :: string

containerPrefix :: Container
= record
person :: MyUnion

myUnionPrefix :: MyUnion
= union
| person :: PersonV1
| other :: integer
|]


-- | Final schema with PersonV2 and changelog
endTypeSwapSchema :: API
typeSwapChangelog :: APIChangelog
(endTypeSwapSchema, typeSwapChangelog) = [apiWithChangelog|

personV1Prefix :: PersonV1
= record
name :: string

personV2Prefix :: PersonV2
= record
fullName :: string
age :: integer

containerPrefix :: Container
= record
person :: MyUnion

myUnionPrefix :: MyUnion
= union
| person :: PersonV2
| other :: integer

changes

version "1.0"
// Note: changes are processed bottom-up, so we must list the union change
// before adding the new type it references
changed union MyUnion
alternative changed person :: PersonV2 migration MigratePersonV1ToV2
added PersonV2 record
fullName :: string
age :: integer

version "0"
|]