packages feed

moonlight-core-0.1.0.0: test/term/DatabaseLawSpec.hs

{-# LANGUAGE DeriveTraversable #-}

module DatabaseLawSpec
  ( tests,
  )
where

import Control.Monad (foldM)
import Data.Foldable (toList)
import Data.IntSet (IntSet)
import Data.IntSet qualified as IntSet
import Data.List (sort)
import Data.Map.Strict (Map)
import Data.Map.Strict qualified as Map
import Data.Maybe (mapMaybe)
import Data.Set (Set)
import Data.Set qualified as Set
import Moonlight.Core
  ( IsLawName (..),
    constructorLawName,
  )
import Moonlight.Core
  ( ArrangementValidationError,
    Database,
    DatabaseRow,
    DatabaseRowDelta (..),
    FreeJoinPlan (..),
    Operator,
    QueryAtom (..),
    QueryBinding (..),
    QueryTerm (..),
    QueryVar (..),
    TermCommitResult (..),
    TermCommand (..),
    TupleLookup (..),
    commitTermCommands,
    compact,
    databaseEntries,
    entriesForResultKey,
    operatorRows,
    rowChildren,
    rowEntry,
    rowResult,
    rowsForOperator,
    deleteTuple,
    emptyDatabase,
    extractOperator,
    insertTuple,
    insertTuples,
    lookupTupleAll,
    rehydrateTuple,
  )
import Moonlight.Core qualified as Database
import Moonlight.Core
  ( canonicalizeDatabase,
    canonicalizeDirtyRows,
  )
import Prelude
import Test.Tasty (TestTree, localOption, testGroup)
import Test.QuickCheck (Testable)
import Test.Tasty.QuickCheck qualified as QuickCheck

data TestTerm key
  = TNull
  | TUnary !key
  | TBinary !key !key
  | TNary [key]
  deriving stock (Eq, Ord, Show, Functor, Foldable, Traversable)

data TermDatabaseLawName
  = TermDatabaseRoundTrip
  | TermDatabaseInsertIdempotenceUnionClosure
  | TermDatabaseCommitInsertFrontier
  | TermDatabaseBulkEqualsRepeated
  | TermDatabaseCompactionFixpoint
  | TermDatabaseCanonicalizationMorphism
  | TermDatabaseFreeJoinNaiveEnumeration
  | TermDatabaseWatermarkDeferredIndexInterleaving
  deriving stock (Bounded, Enum, Eq, Ord, Show)

instance IsLawName TermDatabaseLawName where
  lawNameText =
    constructorLawName . show

tests :: TestTree
tests =
  localOption (QuickCheck.QuickCheckTests 80) $
    testGroup
      "Moonlight.Core.Term.Database laws"
      [ lawProperty TermDatabaseRoundTrip propRoundTrip,
        lawProperty TermDatabaseInsertIdempotenceUnionClosure propInsertIdempotenceUnionClosure,
        lawProperty TermDatabaseCommitInsertFrontier propCommitInsertFrontier,
        lawProperty TermDatabaseBulkEqualsRepeated propBulkEqualsRepeated,
        lawProperty TermDatabaseCompactionFixpoint propCompactionFixpoint,
        lawProperty TermDatabaseCanonicalizationMorphism propCanonicalizationMorphism,
        lawProperty TermDatabaseFreeJoinNaiveEnumeration propFreeJoinNaiveEnumeration,
        lawProperty TermDatabaseWatermarkDeferredIndexInterleaving propWatermarkDeferredIndexInterleaving
      ]

lawProperty :: Testable property => TermDatabaseLawName -> property -> TestTree
lawProperty lawName =
  QuickCheck.testProperty (lawNameText lawName)

newtype RoundTripCase = RoundTripCase (Int, TestTerm Int)
  deriving stock (Eq, Show)

data CommitClosureCase = CommitClosureCase [(Int, TestTerm Int)] [(Int, TestTerm Int)]
  deriving stock (Eq, Show)

data BulkInsertCase = BulkInsertCase [(Int, TestTerm Int)] [(Int, TestTerm Int)]
  deriving stock (Eq, Show)

data CompactionCase = CompactionCase [(Int, TestTerm Int)] [(Int, TestTerm Int)]
  deriving stock (Eq, Show)

data KeyCanonicalizer = KeyCanonicalizer !Int
  deriving stock (Eq, Show)

data CanonicalizationCase = CanonicalizationCase [(Int, TestTerm Int)] !IntSet !KeyCanonicalizer
  deriving stock (Eq, Show)

data FreeJoinCase = FreeJoinCase [(Int, TestTerm Int)] (FreeJoinPlan TestTerm Int)

instance Show FreeJoinCase where
  show (FreeJoinCase entries plan) =
    "FreeJoinCase " <> show entries <> " " <> showFreeJoinPlan plan

newtype WatermarkInterleavingCase = WatermarkInterleavingCase [TermDatabaseAction]
  deriving stock (Eq, Show)

data TermDatabaseAction
  = InsertSingle !(Int, TestTerm Int)
  | InsertBatch ![(Int, TestTerm Int)]
  | DeleteExact !(Int, TestTerm Int)
  | CompactDatabase
  | QueryEntriesForResultKey !Int
  | QueryTupleAll !(TestTerm Int)
  | QueryFixedFreeJoin
  deriving stock (Eq, Show)

newtype ReferenceTermTable = ReferenceTermTable (Map (TestTerm Int) (Set Int))

data InterleavingState = InterleavingState
  { interleavingDatabase :: !(Database TestTerm Int),
    interleavingReference :: !ReferenceTermTable
  }

data InterleavingMismatch
  = FinalEntriesMismatch !(Set (Int, TestTerm Int)) !(Set (Int, TestTerm Int))
  | ResultEntriesMismatch !Int !(Set (Int, TestTerm Int)) !(Set (Int, TestTerm Int))
  | TupleOwnersMismatch !(TestTerm Int) !(Set Int) !(Set Int)
  | FixedFreeJoinValidationFailure !ArrangementValidationError
  | FixedFreeJoinBindingsMismatch !(Set (Map QueryVar Int)) !(Set (Map QueryVar Int))
  deriving stock (Show)

instance QuickCheck.Arbitrary RoundTripCase where
  arbitrary =
    RoundTripCase <$> entryGen

instance QuickCheck.Arbitrary CommitClosureCase where
  arbitrary =
    CommitClosureCase <$> entryListGen 14 <*> entryListGen 14

instance QuickCheck.Arbitrary BulkInsertCase where
  arbitrary =
    BulkInsertCase <$> entryListGen 18 <*> entryListGen 18

instance QuickCheck.Arbitrary CompactionCase where
  arbitrary =
    CompactionCase <$> entryListGen 18 <*> entryListGen 10

instance QuickCheck.Arbitrary KeyCanonicalizer where
  arbitrary =
    KeyCanonicalizer <$> QuickCheck.chooseInt (1, 8)

instance QuickCheck.Arbitrary CanonicalizationCase where
  arbitrary = do
    entries <- entryListGen 18
    canonicalizer <- QuickCheck.arbitrary
    dirtyKeys <- dirtyKeySetGen entries
    pure (CanonicalizationCase entries dirtyKeys canonicalizer)

instance QuickCheck.Arbitrary FreeJoinCase where
  arbitrary =
    FreeJoinCase <$> entryListGen 12 <*> freeJoinPlanGen

instance QuickCheck.Arbitrary WatermarkInterleavingCase where
  arbitrary = do
    sharedEntry <- interleavingEntryGen
    batchEntries <- interleavingEntryListGen 5
    resultProbe <- interleavingKeyGen
    tupleProbe <- interleavingTermGen
    additionalActionCount <- QuickCheck.chooseInt (10, 22)
    additionalActions <- QuickCheck.vectorOf additionalActionCount interleavingActionGen
    WatermarkInterleavingCase
      <$> QuickCheck.shuffle
        ( [ InsertSingle sharedEntry,
            InsertBatch batchEntries,
            DeleteExact sharedEntry,
            CompactDatabase,
            QueryEntriesForResultKey resultProbe,
            QueryTupleAll tupleProbe,
            QueryFixedFreeJoin
          ]
            <> additionalActions
        )

propRoundTrip :: RoundTripCase -> QuickCheck.Property
propRoundTrip (RoundTripCase (resultKey, term)) =
  case rowsForOperator operator db of
    [(_rowId, row)] ->
      QuickCheck.conjoin
        [ rowEntry operator row QuickCheck.=== Just (resultKey, term),
          rehydrateTuple operator (rowResult row : rowChildren row) QuickCheck.=== Just (resultKey, term)
        ]
    rows ->
      QuickCheck.counterexample ("expected one encoded row, got " <> show rows) False
  where
    operator =
      extractOperator term
    db =
      insertTuple resultKey term (emptyDatabase :: Database TestTerm Int)

propInsertIdempotenceUnionClosure :: CommitClosureCase -> QuickCheck.Property
propInsertIdempotenceUnionClosure (CommitClosureCase baseEntries insertedEntries) =
  QuickCheck.counterexample counterexampleText $
    QuickCheck.conjoin
      [ entrySet committedDb QuickCheck.=== expectedCommittedEntries,
        unionResultSet residualCommandList QuickCheck.=== expectedResidualPairs,
        entrySet duplicateCommittedDb QuickCheck.=== entrySet committedDb
      ]
  where
    baseDb =
      databaseFromEntries baseEntries
    commands =
      fmap (uncurry InsertTerm) insertedEntries
    duplicateCommands =
      commands <> commands
    commitResult =
      commitTermCommands commands baseDb
    duplicateCommitResult =
      commitTermCommands duplicateCommands baseDb
    residualCommandList =
      residualCommands commitResult
    committedDb =
      committedDatabase commitResult
    duplicateCommittedDb =
      committedDatabase duplicateCommitResult
    expectedCommittedEntries =
      Set.union (entrySet baseDb) (Set.fromList insertedEntries)
    expectedResidualPairs =
      expectedInsertClosurePairs baseDb insertedEntries
    counterexampleText =
      "base: "
        <> show (entryList baseDb)
        <> "\ninserted: "
        <> show insertedEntries
        <> "\nresidual: "
        <> show (termCommandsText residualCommandList)
        <> "\nexpected residual pairs: "
        <> show expectedResidualPairs

propCommitInsertFrontier :: CommitClosureCase -> QuickCheck.Property
propCommitInsertFrontier (CommitClosureCase baseEntries candidateEntries) =
  QuickCheck.counterexample counterexampleText $
    QuickCheck.conjoin
      [ QuickCheck.counterexample "frontier row ids must be unique and ascending per operator" $
          QuickCheck.property (all frontierIdsUniqueAndAscending frontierByOperator),
        QuickCheck.counterexample "every frontier row must occur at the same id in the committed snapshot" $
          QuickCheck.property (all (frontierRowsPresent committedRowsByOperator) (Map.toAscList frontierByOperator)),
        QuickCheck.counterexample "frontier rows must all rehydrate" $
          QuickCheck.property (maybe False (const True) frontierEntries),
        frontierEntries QuickCheck.=== Just semanticEntryIncrease
      ]
  where
    baseDatabase =
      databaseFromEntries baseEntries
    commitResult =
      commitTermCommands (fmap (uncurry InsertTerm) candidateEntries) baseDatabase
    committedSnapshot =
      committedDatabase commitResult
    frontierByOperator =
      insertedRows commitResult
    committedRowsByOperator =
      operatorRows committedSnapshot
    frontierEntries =
      fmap (Set.fromList . concat) $
        traverse
          (\(operator, rows) -> traverse (rowEntry operator . snd) rows)
          (Map.toAscList frontierByOperator)
    semanticEntryIncrease =
      Set.difference (entrySet committedSnapshot) (entrySet baseDatabase)
    counterexampleText =
      "base entries: "
        <> show (entryList baseDatabase)
        <> "\ncandidate entries: "
        <> show candidateEntries
        <> "\nfrontier: "
        <> show (fmap (fmap fst) frontierByOperator)
        <> "\nsemantic increase: "
        <> show semanticEntryIncrease

frontierIdsUniqueAndAscending :: Ord rowId => [(rowId, row)] -> Bool
frontierIdsUniqueAndAscending rows =
  rowIds == sort rowIds
    && Set.size (Set.fromList rowIds) == length rowIds
  where
    rowIds =
      fmap fst rows

frontierRowsPresent ::
  (Ord operator, Ord rowId, Eq row) =>
  Map operator [(rowId, row)] ->
  (operator, [(rowId, row)]) ->
  Bool
frontierRowsPresent committedRowsByOperator (operator, frontierRows) =
  all
    (\(rowId, row) -> Map.lookup rowId committedRows == Just row)
    frontierRows
  where
    committedRows =
      Map.fromList (Map.findWithDefault [] operator committedRowsByOperator)

propBulkEqualsRepeated :: BulkInsertCase -> QuickCheck.Property
propBulkEqualsRepeated (BulkInsertCase baseEntries insertedEntries) =
  operatorEntrySets bulkDb QuickCheck.=== operatorEntrySets repeatedDb
  where
    baseDb =
      databaseFromEntries baseEntries
    bulkDb =
      insertTuples insertedEntries baseDb
    repeatedDb =
      foldl'
        (\database (resultKey, term) -> insertTuple resultKey term database)
        baseDb
        insertedEntries

propCompactionFixpoint :: CompactionCase -> QuickCheck.Property
propCompactionFixpoint (CompactionCase baseEntries deletedEntries) =
  QuickCheck.conjoin
    [ entrySet compactedDb QuickCheck.=== entrySet deletedDb,
      operatorRows compactedAgainDb QuickCheck.=== operatorRows compactedDb
    ]
  where
    baseDb =
      databaseFromEntries baseEntries
    deletedDb =
      foldl'
        (\database (resultKey, term) -> deleteTuple resultKey term database)
        baseDb
        deletedEntries
    compactedDb =
      compact deletedDb
    compactedAgainDb =
      compact compactedDb

propCanonicalizationMorphism :: CanonicalizationCase -> QuickCheck.Property
propCanonicalizationMorphism (CanonicalizationCase entries dirtyKeys canonicalizer) =
  QuickCheck.counterexample counterexampleText $
    QuickCheck.conjoin
      [ canonicalizationOutcome,
        deltaInsertedEntries delta QuickCheck.=== committedNewEntries
      ]
  where
    canonicalize =
      applyCanonicalizer canonicalizer
    db =
      databaseFromEntries entries
    canonicalOperatorEntrySets =
      fmap (Set.map (canonicalizeEntry canonicalize)) (operatorEntrySets db)
    canonicalizationOutcome =
      case canonicalizeDatabase canonicalize db of
        Left _residualCommands ->
          QuickCheck.property True
        Right fullyCanonicalDb ->
          operatorEntrySets fullyCanonicalDb QuickCheck.=== canonicalOperatorEntrySets
    (delta, _commands, committedDb) =
      canonicalizeDirtyRows dirtyKeys canonicalize db
    entriesAfterDeletion =
      Set.difference (entrySet db) (Set.fromList (deltaDeletedEntries delta))
    committedNewEntries =
      sort (Set.toList (Set.difference (entrySet committedDb) entriesAfterDeletion))
    counterexampleText =
      "entries: "
        <> show (entryList db)
        <> "\ndirty keys: "
        <> show dirtyKeys
        <> "\ncanonicalizer: "
        <> show canonicalizer
        <> "\ndelta inserted: "
        <> show (deltaInsertedEntries delta)
        <> "\ncommitted entries: "
        <> show (entryList committedDb)

propFreeJoinNaiveEnumeration :: FreeJoinCase -> QuickCheck.Property
propFreeJoinNaiveEnumeration (FreeJoinCase entries plan) =
  case Database.freeJoin plan db of
    Left validationError ->
      QuickCheck.counterexample (counterexampleText Set.empty <> "\nvalidation error: " <> show validationError) False
    Right (bindings, _arrangedDb) ->
      let producedBindings =
            bindingSet bindings
       in QuickCheck.counterexample (counterexampleText producedBindings) $
            QuickCheck.conjoin
              [ QuickCheck.counterexample "freeJoin produced a binding outside naive enumeration" $
                  producedBindings `Set.isSubsetOf` expectedBindings,
                producedBindings QuickCheck.=== expectedBindings
              ]
  where
    db =
      databaseFromEntries entries
    expectedBindings =
      bindingSet (naiveFreeJoinEntries plan entries)
    counterexampleText producedBindings =
      "entries: "
        <> show (entryList db)
        <> "\nplan: "
        <> showFreeJoinPlan plan
        <> "\nproduced: "
        <> show producedBindings
        <> "\nexpected: "
        <> show expectedBindings

propWatermarkDeferredIndexInterleaving :: WatermarkInterleavingCase -> QuickCheck.Property
propWatermarkDeferredIndexInterleaving (WatermarkInterleavingCase actions) =
  case foldM applyTermDatabaseAction emptyInterleavingState actions >>= validateFinalInterleavingState of
    Left mismatch ->
      QuickCheck.counterexample
        ( "actions: "
            <> show actions
            <> "\nmismatch: "
            <> show mismatch
        )
        False
    Right () ->
      QuickCheck.property True

emptyInterleavingState :: InterleavingState
emptyInterleavingState =
  InterleavingState
    { interleavingDatabase = emptyDatabase,
      interleavingReference = ReferenceTermTable Map.empty
    }

applyTermDatabaseAction ::
  InterleavingState ->
  TermDatabaseAction ->
  Either InterleavingMismatch InterleavingState
applyTermDatabaseAction state action =
  case action of
    InsertSingle entry@(resultKey, term) ->
      Right
        state
          { interleavingDatabase = insertTuple resultKey term (interleavingDatabase state),
            interleavingReference = insertReferenceEntry entry (interleavingReference state)
          }
    InsertBatch entries ->
      Right
        state
          { interleavingDatabase = insertTuples entries (interleavingDatabase state),
            interleavingReference = insertReferenceEntries entries (interleavingReference state)
          }
    DeleteExact entry@(resultKey, term) ->
      Right
        state
          { interleavingDatabase = deleteTuple resultKey term (interleavingDatabase state),
            interleavingReference = deleteReferenceEntry entry (interleavingReference state)
          }
    CompactDatabase ->
      Right
        state
          { interleavingDatabase = compact (interleavingDatabase state)
          }
    QueryEntriesForResultKey resultKey ->
      let expectedEntries =
            referenceEntriesForResultKey resultKey (interleavingReference state)
          actualEntries =
            Set.fromList (entriesForResultKey resultKey (interleavingDatabase state))
       in if actualEntries == expectedEntries
            then Right state
            else Left (ResultEntriesMismatch resultKey expectedEntries actualEntries)
    QueryTupleAll term ->
      let expectedOwners =
            referenceTupleOwners term (interleavingReference state)
          actualOwners =
            tupleLookupOwners (lookupTupleAll term (interleavingDatabase state))
       in if actualOwners == expectedOwners
            then Right state
            else Left (TupleOwnersMismatch term expectedOwners actualOwners)
    QueryFixedFreeJoin ->
      case Database.freeJoin fixedTwoAtomFreeJoinPlan (interleavingDatabase state) of
        Left validationError ->
          Left (FixedFreeJoinValidationFailure validationError)
        Right (bindings, arrangedDatabase) ->
          let expectedBindings =
                bindingSet
                  (naiveFreeJoinEntries fixedTwoAtomFreeJoinPlan (referenceEntries (interleavingReference state)))
              actualBindings =
                bindingSet bindings
           in if actualBindings == expectedBindings
                then Right state {interleavingDatabase = arrangedDatabase}
                else Left (FixedFreeJoinBindingsMismatch expectedBindings actualBindings)

validateFinalInterleavingState :: InterleavingState -> Either InterleavingMismatch ()
validateFinalInterleavingState state =
  if actualEntries == expectedEntries
    then Right ()
    else Left (FinalEntriesMismatch expectedEntries actualEntries)
  where
    expectedEntries =
      Set.fromList (referenceEntries (interleavingReference state))
    actualEntries =
      entrySet (interleavingDatabase state)

insertReferenceEntry :: (Int, TestTerm Int) -> ReferenceTermTable -> ReferenceTermTable
insertReferenceEntry (resultKey, term) (ReferenceTermTable ownersByTerm) =
  ReferenceTermTable
    (Map.insertWith Set.union term (Set.singleton resultKey) ownersByTerm)

insertReferenceEntries :: [(Int, TestTerm Int)] -> ReferenceTermTable -> ReferenceTermTable
insertReferenceEntries entries reference =
  foldl' (flip insertReferenceEntry) reference entries

deleteReferenceEntry :: (Int, TestTerm Int) -> ReferenceTermTable -> ReferenceTermTable
deleteReferenceEntry (resultKey, term) (ReferenceTermTable ownersByTerm) =
  ReferenceTermTable (Map.update deleteOwner term ownersByTerm)
  where
    deleteOwner owners =
      case Set.delete resultKey owners of
        remainingOwners
          | Set.null remainingOwners -> Nothing
          | otherwise -> Just remainingOwners

referenceEntries :: ReferenceTermTable -> [(Int, TestTerm Int)]
referenceEntries (ReferenceTermTable ownersByTerm) =
  Map.foldMapWithKey
    (\term owners -> fmap (\resultKey -> (resultKey, term)) (Set.toAscList owners))
    ownersByTerm

referenceEntriesForResultKey :: Int -> ReferenceTermTable -> Set (Int, TestTerm Int)
referenceEntriesForResultKey resultKey =
  Set.fromList . filter ((== resultKey) . fst) . referenceEntries

referenceTupleOwners :: TestTerm Int -> ReferenceTermTable -> Set Int
referenceTupleOwners term (ReferenceTermTable ownersByTerm) =
  Map.findWithDefault Set.empty term ownersByTerm

tupleLookupOwners :: TupleLookup Int -> Set Int
tupleLookupOwners tupleLookup =
  case tupleLookup of
    TupleMissing -> Set.empty
    TupleUnique resultKey -> Set.singleton resultKey
    TupleAmbiguous resultKeys -> Set.fromList (toList resultKeys)

fixedTwoAtomFreeJoinPlan :: FreeJoinPlan TestTerm Int
fixedTwoAtomFreeJoinPlan =
  FreeJoinPlan
    [ QueryAtom
        { atomOperator = binaryOperator,
          atomResult = QueryVariable (ExplicitQueryVar 0),
          atomChildren =
            [ QueryVariable (ExplicitQueryVar 1),
              QueryVariable (ExplicitQueryVar 2)
            ]
        },
      QueryAtom
        { atomOperator = binaryOperator,
          atomResult = QueryVariable (ExplicitQueryVar 3),
          atomChildren =
            [ QueryVariable (ExplicitQueryVar 2),
              QueryVariable (ExplicitQueryVar 4)
            ]
        }
    ]
  where
    binaryOperator =
      operatorFromArity 2

interleavingActionGen :: QuickCheck.Gen TermDatabaseAction
interleavingActionGen =
  QuickCheck.frequency
    [ (4, InsertSingle <$> interleavingEntryGen),
      (2, InsertBatch <$> interleavingEntryListGen 5),
      (3, DeleteExact <$> interleavingEntryGen),
      (1, pure CompactDatabase),
      (2, QueryEntriesForResultKey <$> interleavingKeyGen),
      (2, QueryTupleAll <$> interleavingTermGen),
      (2, pure QueryFixedFreeJoin)
    ]

interleavingEntryGen :: QuickCheck.Gen (Int, TestTerm Int)
interleavingEntryGen =
  (,) <$> interleavingKeyGen <*> interleavingTermGen

interleavingEntryListGen :: Int -> QuickCheck.Gen [(Int, TestTerm Int)]
interleavingEntryListGen upperBound = do
  entryCount <- QuickCheck.chooseInt (1, upperBound)
  QuickCheck.vectorOf entryCount interleavingEntryGen

interleavingKeyGen :: QuickCheck.Gen Int
interleavingKeyGen =
  QuickCheck.chooseInt (0, 7)

interleavingTermGen :: QuickCheck.Gen (TestTerm Int)
interleavingTermGen = do
  arity <- QuickCheck.chooseInt (0, 4)
  termFromArity arity <$> QuickCheck.vectorOf arity interleavingKeyGen

entryGen :: QuickCheck.Gen (Int, TestTerm Int)
entryGen =
  (,) <$> keyGen <*> termGen 4

entryListGen :: Int -> QuickCheck.Gen [(Int, TestTerm Int)]
entryListGen upperBound = do
  entryCount <- QuickCheck.chooseInt (0, upperBound)
  QuickCheck.vectorOf entryCount entryGen

keyGen :: QuickCheck.Gen Int
keyGen =
  QuickCheck.chooseInt (0, 15)

termGen :: Int -> QuickCheck.Gen (TestTerm Int)
termGen maximumArity = do
  arity <- QuickCheck.chooseInt (0, maximumArity)
  termFromArity arity <$> QuickCheck.vectorOf arity keyGen

termFromArity :: Int -> [Int] -> TestTerm Int
termFromArity arity children =
  case (arity, children) of
    (0, []) ->
      TNull
    (1, [child]) ->
      TUnary child
    (2, [leftChild, rightChild]) ->
      TBinary leftChild rightChild
    _ ->
      TNary children

operatorFromArity :: Int -> Operator TestTerm
operatorFromArity arity =
  extractOperator (termFromArity arity (replicate arity 0))

freeJoinPlanGen :: QuickCheck.Gen (FreeJoinPlan TestTerm Int)
freeJoinPlanGen = do
  atomCount <- QuickCheck.chooseInt (0, 3)
  FreeJoinPlan <$> QuickCheck.vectorOf atomCount queryAtomGen

queryAtomGen :: QuickCheck.Gen (QueryAtom TestTerm Int)
queryAtomGen = do
  arity <- QuickCheck.chooseInt (0, 3)
  QueryAtom
    <$> pure (operatorFromArity arity)
    <*> queryTermGen
    <*> QuickCheck.vectorOf arity queryTermGen

queryTermGen :: QuickCheck.Gen (QueryTerm Int)
queryTermGen =
  QuickCheck.frequency
    [ (3, QueryVariable . ExplicitQueryVar <$> QuickCheck.chooseInt (0, 4)),
      (2, QueryBound <$> keyGen)
    ]

dirtyKeySetGen :: [(Int, TestTerm Int)] -> QuickCheck.Gen IntSet
dirtyKeySetGen entries =
  case Set.toList (entryKeys entries) of
    [] ->
      pure IntSet.empty
    keys ->
      IntSet.fromList <$> QuickCheck.sublistOf keys

entryKeys :: [(Int, TestTerm Int)] -> Set Int
entryKeys =
  foldMap (\(resultKey, term) -> Set.fromList (resultKey : toList term))

applyCanonicalizer :: KeyCanonicalizer -> Int -> Int
applyCanonicalizer (KeyCanonicalizer modulus) key =
  key `mod` modulus

canonicalizeEntry :: (Int -> Int) -> (Int, TestTerm Int) -> (Int, TestTerm Int)
canonicalizeEntry canonicalize (resultKey, term) =
  (canonicalize resultKey, fmap canonicalize term)

databaseFromEntries :: [(Int, TestTerm Int)] -> Database TestTerm Int
databaseFromEntries entries =
  insertTuples entries emptyDatabase

entrySet :: Database TestTerm Int -> Set (Int, TestTerm Int)
entrySet =
  Set.fromList . databaseEntries

entryList :: Database TestTerm Int -> [(Int, TestTerm Int)]
entryList =
  sort . databaseEntries

operatorEntrySets :: Database TestTerm Int -> Map (Operator TestTerm) (Set (Int, TestTerm Int))
operatorEntrySets =
  Map.mapWithKey (\operator rows -> Set.fromList (rowEntries operator rows)) . operatorRows

rowEntries :: Operator TestTerm -> [(rowId, DatabaseRow)] -> [(Int, TestTerm Int)]
rowEntries operator =
  mapMaybe (rowEntry operator . snd)

deltaDeletedEntries :: DatabaseRowDelta TestTerm -> [(Int, TestTerm Int)]
deltaDeletedEntries =
  sort . deltaEntries . rowsDeleted

deltaInsertedEntries :: DatabaseRowDelta TestTerm -> [(Int, TestTerm Int)]
deltaInsertedEntries =
  sort . deltaEntries . rowsInserted

deltaEntries :: Map (Operator TestTerm) [(rowId, DatabaseRow)] -> [(Int, TestTerm Int)]
deltaEntries =
  Map.foldMapWithKey rowEntries

expectedInsertClosurePairs ::
  Database TestTerm Int ->
  [(Int, TestTerm Int)] ->
  Set (Int, Int)
expectedInsertClosurePairs baseDb insertedEntries =
  Map.foldlWithKey' collectTuplePairs Set.empty insertedOwnersByTuple
  where
    baseOwnersByTuple =
      tupleOwners (databaseEntries baseDb)
    insertedOwnersByTuple =
      tupleOwners insertedEntries
    collectTuplePairs pairs term insertedOwners =
      Set.union pairs $
        Set.filter
          (pairTouches insertedOwners)
          (ownerPairs (Set.union insertedOwners (Map.findWithDefault Set.empty term baseOwnersByTuple)))

tupleOwners :: [(Int, TestTerm Int)] -> Map (TestTerm Int) (Set Int)
tupleOwners =
  foldl'
    (\owners (resultKey, term) -> Map.insertWith Set.union term (Set.singleton resultKey) owners)
    Map.empty

ownerPairs :: Set Int -> Set (Int, Int)
ownerPairs owners =
  Set.fromList
    [ (leftOwner, rightOwner)
      | leftOwner <- ownerList,
        rightOwner <- ownerList,
        leftOwner < rightOwner
    ]
  where
    ownerList =
      Set.toList owners

pairTouches :: Set Int -> (Int, Int) -> Bool
pairTouches owners (leftOwner, rightOwner) =
  Set.member leftOwner owners || Set.member rightOwner owners

unionResultSet :: [TermCommand TestTerm Int] -> Set (Int, Int)
unionResultSet =
  foldMap unionPair

unionPair :: TermCommand TestTerm Int -> Set (Int, Int)
unionPair command =
  case command of
    UnionResults left right ->
      maybe Set.empty Set.singleton (orderedDistinctPair left right)
    _ ->
      Set.empty

orderedDistinctPair :: Ord key => key -> key -> Maybe (key, key)
orderedDistinctPair left right
  | left == right =
      Nothing
  | left < right =
      Just (left, right)
  | otherwise =
      Just (right, left)

termCommandsText :: [TermCommand TestTerm Int] -> [String]
termCommandsText =
  fmap termCommandText

termCommandText :: TermCommand TestTerm Int -> String
termCommandText command =
  case command of
    DeleteRow operator rowId ->
      "DeleteRow " <> show operator <> " " <> show rowId
    InsertTerm resultKey term ->
      "InsertTerm " <> show resultKey <> " " <> show term
    UnionResults left right ->
      "UnionResults " <> show left <> " " <> show right

bindingSet :: [QueryBinding Int] -> Set (Map QueryVar Int)
bindingSet =
  Set.fromList . fmap queryBindingAssignments

naiveFreeJoinEntries :: FreeJoinPlan TestTerm Int -> [(Int, TestTerm Int)] -> [QueryBinding Int]
naiveFreeJoinEntries (FreeJoinPlan atoms) entries =
  foldl'
    (\bindings atom -> foldMap (bindNaiveAtom atom) bindings)
    [QueryBinding Map.empty]
    atoms
  where
    rowsByOperator =
      entriesByOperator entries
    bindNaiveAtom atom binding =
      mapMaybe (bindNaiveEntry atom binding) (Map.findWithDefault [] (atomOperator atom) rowsByOperator)

entriesByOperator :: [(Int, TestTerm Int)] -> Map (Operator TestTerm) [(Int, TestTerm Int)]
entriesByOperator =
  Map.fromListWith (<>)
    . fmap (\entry@(_resultKey, term) -> (extractOperator term, [entry]))

bindNaiveEntry :: QueryAtom TestTerm Int -> QueryBinding Int -> (Int, TestTerm Int) -> Maybe (QueryBinding Int)
bindNaiveEntry atom binding (resultKey, term)
  | extractOperator term == atomOperator atom =
      bindNaiveValues atom binding (resultKey : toList term)
  | otherwise =
      Nothing

bindNaiveValues :: QueryAtom TestTerm Int -> QueryBinding Int -> [Int] -> Maybe (QueryBinding Int)
bindNaiveValues atom binding values
  | length terms == length values =
      foldM bindQueryTerm binding (zip terms values)
  | otherwise =
      Nothing
  where
    terms =
      atomResult atom : atomChildren atom

bindQueryTerm :: QueryBinding Int -> (QueryTerm Int, Int) -> Maybe (QueryBinding Int)
bindQueryTerm binding (term, value) =
  case term of
    QueryBound expectedValue
      | expectedValue == value ->
          Just binding
      | otherwise ->
          Nothing
    QueryVariable variable ->
      bindQueryVariable variable value binding

bindQueryVariable :: QueryVar -> Int -> QueryBinding Int -> Maybe (QueryBinding Int)
bindQueryVariable variable value binding =
  case Map.lookup variable (queryBindingAssignments binding) of
    Nothing ->
      Just binding {queryBindingAssignments = Map.insert variable value (queryBindingAssignments binding)}
    Just existingValue
      | existingValue == value ->
          Just binding
      | otherwise ->
          Nothing

showFreeJoinPlan :: FreeJoinPlan TestTerm Int -> String
showFreeJoinPlan (FreeJoinPlan atoms) =
  "FreeJoinPlan " <> show (fmap showQueryAtom atoms)

showQueryAtom :: QueryAtom TestTerm Int -> String
showQueryAtom atom =
  "{operator="
    <> show (atomOperator atom)
    <> ", result="
    <> show (atomResult atom)
    <> ", children="
    <> show (atomChildren atom)
    <> "}"