warlock-0.1.0.0: src/Warlock/Tweak.hs
{-# LANGUAGE TemplateHaskell #-}
{-# LANGUAGE RecordWildCards #-}
-- |
-- Module : Warlock.Tweak
-- Description : Generate new data types from existing ones with modifications
-- License : MIT
--
-- = Overview
--
-- @Warlock.Tweak@ allows you to derive new data types from existing ones by
-- specifying field modifications (selection, renaming, prefix operations).
-- It automatically generates the new type and optionally creates 'Warlock.From'
-- instances between the source and derived types.
--
-- = Quick Example
--
-- @
-- {-# LANGUAGE TemplateHaskell #-}
-- import Warlock.Tweak
--
-- data User = User
-- { userId :: Int
-- , userName :: String
-- , userEmail :: String
-- , userPassword :: ByteString
-- }
--
-- -- Generate DTO without sensitive fields
-- tweakType
-- (DropFrom $ defaultTweakConfig
-- \`withoutFields\` ['userPassword]
-- \`stripPrefix\` "user")
-- "UserDTO"
-- ''User
--
-- -- Generates:
-- -- data UserDTO = UserDTO
-- -- { id :: Int
-- -- , name :: String
-- -- , email :: String
-- -- }
-- -- derive (ByName config) ''User ''UserDTO
-- @
--
-- = Use Cases
--
-- * **DTOs**: Create API response/request types from database entities
-- * **Projections**: Subset of fields for specific use cases
-- * **API Versioning**: Generate V1/V2 types with field evolution
-- * **Type Refinement**: Create more specific types by dropping optional fields
--
-- = Advanced Field Rules
--
-- Warlock.Tweak now supports all Warlock field rules including:
--
-- * 'combineFields' - Combine multiple source fields into one
-- * 'disassembleFields' - Split one source field into multiple
-- * 'mapField' - Apply custom transformations to fields
-- * 'virtualField' - Use HasField for dynamic field access
-- * 'computedField' - Compute field values from expressions
--
-- @
-- -- Combine firstName and lastName into fullName:
-- pick ''Person "PersonDTO" ['fullName, 'age] \`withRules\`
-- [ combineFields 'fullName $ do
-- first <- get 'firstName
-- last <- get 'lastName
-- pure [| $first ++ \" \" ++ $last |]
-- ]
--
-- -- Transform email to lowercase:
-- omit ''User "PublicUser" ['password] \`withRules\`
-- [ mapField 'email (\src _dst -> [| map toLower $(varE src) |])
-- ]
-- @
--
-- = Configuration
--
-- Configuration is composable using helper functions:
--
-- @
-- defaultTweakConfig
-- \`withFields\` ['field1, 'field2] -- Keep only these
-- \`withRenames\` [('oldName, 'newName)] -- Explicit renames
-- \`stripPrefix\` "user" -- Remove prefix from all fields
-- \`withAutoDerive\` False -- Disable automatic instance generation
-- @
module Warlock.Tweak
( -- * Type Derivation (TypeScript-style)
pick
, omit
, pick'
, omit'
-- * Legacy API
, tweakType
, TweakStrategy(..)
-- * Configuration
, TweakConfig(..)
, defaultTweakConfig
, FieldSelector(..)
, PrefixOp(..)
-- * Configuration Helpers
, withFields
, withoutFields
, withRenames
, withPrefixOps
, withAutoDerive
, withRules
, withDefaults
, withNormalize
, withRuleGens
-- * Prefix Operations
, stripPrefix
, addPrefix
, replacePrefix
-- * Re-exports from Warlock (Field Rules)
, Warlock.FieldRule
, Warlock.rename
, Warlock.defaultTo
, Warlock.ignore
, Warlock.mapField
, Warlock.virtualField
, Warlock.computedField
, Warlock.combineFields
, Warlock.disassembleFields
, Warlock.DisassembledField
, (Warlock..=)
-- * Field Getters
, Warlock.FieldGetter
, Warlock.get
, Warlock.getName
, Warlock.DisassembleGetter
, Warlock.getSource
, Warlock.getSourceName
-- * Name Normalizers
, Warlock.snakeToCamel
, Warlock.camelToSnake
, Warlock.lowerFirst
-- * Field Rule Generators
, Warlock.datatypePrefixRules
, Warlock.constructorPrefixRules
, Warlock.customPrefixRules
, Warlock.RuleGenContext(..)
) where
import Language.Haskell.TH
import Control.Monad (when, forM)
import Data.List (isPrefixOf)
import Data.Char (toLower, toUpper)
import qualified Warlock
--------------------------------------------------------------------------------
-- Core Types
-- | Strategy for deriving a new type from an existing one.
data TweakStrategy
= KeepOnly TweakConfig -- ^ Keep only specified fields
| DropFrom TweakConfig -- ^ Drop specified fields, keep rest
| TransformWith TweakConfig -- ^ Reserved for future type transformations
-- | Configuration for type derivation.
--
-- Build configurations using 'defaultTweakConfig' and helper functions:
--
-- @
-- defaultTweakConfig
-- \`withFields\` ['field1, 'field2]
-- \`stripPrefix\` "user"
-- @
data TweakConfig = TweakConfig
{ fieldSelector :: FieldSelector
-- ^ Which fields to select/keep
, warlockConfig :: Warlock.Config
-- ^ Embedded Warlock configuration for field rules
, autoDerive :: Bool
-- ^ Whether to automatically generate From instances (default: True)
}
-- | Selector for which fields to include.
data FieldSelector
= AllFields
-- ^ Select all fields (used with DropFrom)
| SelectFields [Name]
-- ^ Select only these specific fields (used with KeepOnly)
deriving (Show, Eq)
-- | Prefix operations that can be applied to field names.
data PrefixOp
= StripPrefix String
-- ^ Remove prefix from field names
| AddPrefix String
-- ^ Add prefix to field names
| ReplacePrefix String String
-- ^ Replace one prefix with another
deriving (Show, Eq)
--------------------------------------------------------------------------------
-- Default Configuration
-- | Default configuration: all fields, no renames, no prefix ops, auto-derive enabled.
defaultTweakConfig :: TweakConfig
defaultTweakConfig = TweakConfig
{ fieldSelector = AllFields
, warlockConfig = Warlock.defaultConfig
, autoDerive = True
}
--------------------------------------------------------------------------------
-- Configuration Helpers
-- | Keep only the specified fields.
--
-- @
-- defaultTweakConfig \`withFields\` ['userId, 'userName]
-- @
withFields :: TweakConfig -> [Name] -> TweakConfig
withFields cfg fields = cfg { fieldSelector = SelectFields fields }
-- | Drop the specified fields (inverse of 'withFields').
--
-- @
-- defaultTweakConfig \`withoutFields\` ['userPassword, 'userCreatedAt]
-- @
withoutFields :: TweakConfig -> [Name] -> TweakConfig
withoutFields cfg fields = cfg { fieldSelector = SelectFields fields }
-- | Add explicit field renames.
--
-- @
-- defaultTweakConfig \`withRenames\` [('userId, 'id), ('userName, 'name)]
-- @
withRenames :: TweakConfig -> [(Name, Name)] -> TweakConfig
withRenames cfg renames = cfg { warlockConfig = Warlock.withRenames (warlockConfig cfg) renames }
-- | Set prefix operations (legacy support - now implemented via Warlock normalize).
--
-- @
-- defaultTweakConfig \`withPrefixOps\` [StripPrefix "user", AddPrefix "dto"]
-- @
withPrefixOps :: TweakConfig -> [PrefixOp] -> TweakConfig
withPrefixOps cfg ops =
let oldNormalize = Warlock.normalize (warlockConfig cfg)
newNormalize = composePrefixOps oldNormalize ops
in cfg { warlockConfig = Warlock.withNormalize (warlockConfig cfg) newNormalize }
where
composePrefixOps :: (String -> String) -> [PrefixOp] -> (String -> String)
composePrefixOps baseNorm ops' s = foldl applyOp (baseNorm s) ops'
applyOp :: String -> PrefixOp -> String
applyOp name (StripPrefix prefix) =
if prefix `isPrefixOf` name
then lowercaseFirst2 $ drop (length prefix) name
else name
applyOp name (AddPrefix prefix) = prefix ++ uppercaseFirst2 name
applyOp name (ReplacePrefix old new) =
if old `isPrefixOf` name
then new ++ uppercaseFirst2 (drop (length old) name)
else name
lowercaseFirst2 :: String -> String
lowercaseFirst2 [] = []
lowercaseFirst2 (c:cs) = toLower c : cs
uppercaseFirst2 :: String -> String
uppercaseFirst2 [] = []
uppercaseFirst2 (c:cs) = toUpper c : cs
-- | Enable or disable automatic instance generation.
--
-- @
-- defaultTweakConfig \`withAutoDerive\` False
-- @
withAutoDerive :: TweakConfig -> Bool -> TweakConfig
withAutoDerive cfg enabled = cfg { autoDerive = enabled }
-- | Add custom field rules (combineFields, disassembleFields, mapField, etc.).
--
-- @
-- defaultTweakConfig \`withRules\`
-- [ combineFields 'fullName $ do
-- first <- get 'firstName
-- last <- get 'lastName
-- pure [| $first ++ " " ++ $last |]
-- , mapField 'email (\src _dst -> [| toLower <$> $(varE src) |])
-- ]
-- @
withRules :: TweakConfig -> [Warlock.FieldRule] -> TweakConfig
withRules cfg rules = cfg { warlockConfig = Warlock.withRules (warlockConfig cfg) rules }
-- | Add default values for new fields.
--
-- @
-- defaultTweakConfig \`withDefaults\` [('newField, [| \"default\" |])]
-- @
withDefaults :: TweakConfig -> [(Name, Q Exp)] -> TweakConfig
withDefaults cfg defaults = cfg { warlockConfig = Warlock.withDefaults (warlockConfig cfg) defaults }
-- | Set the field name normalization function.
--
-- @
-- defaultTweakConfig \`withNormalize\` snakeToCamel
-- @
withNormalize :: TweakConfig -> (String -> String) -> TweakConfig
withNormalize cfg f = cfg { warlockConfig = Warlock.withNormalize (warlockConfig cfg) f }
-- | Set field rule generators.
--
-- @
-- defaultTweakConfig \`withRuleGens\` [datatypePrefixRules]
-- @
withRuleGens :: TweakConfig -> [Warlock.RuleGenContext -> String -> Maybe Warlock.FieldRule] -> TweakConfig
withRuleGens cfg gens = cfg { warlockConfig = Warlock.withRuleGens (warlockConfig cfg) gens }
--------------------------------------------------------------------------------
-- Convenience Functions
-- | Strip a prefix from all field names.
--
-- @
-- defaultTweakConfig \`stripPrefix\` "user" -- userName -> name
-- @
stripPrefix :: TweakConfig -> String -> TweakConfig
stripPrefix cfg prefix = withPrefixOps cfg [StripPrefix prefix]
-- | Add a prefix to all field names.
--
-- @
-- defaultTweakConfig \`addPrefix\` "dto" -- name -> dtoName
-- @
addPrefix :: TweakConfig -> String -> TweakConfig
addPrefix cfg prefix = withPrefixOps cfg [AddPrefix prefix]
-- | Replace one prefix with another.
--
-- @
-- defaultTweakConfig \`replacePrefix\` ("user", "dto") -- userName -> dtoName
-- @
replacePrefix :: TweakConfig -> (String, String) -> TweakConfig
replacePrefix cfg (old, new) = withPrefixOps cfg [ReplacePrefix old new]
--------------------------------------------------------------------------------
-- TypeScript-style API
-- | Pick specific fields from a type to create a new type.
--
-- Similar to TypeScript's @Pick<T, K>@ utility type.
--
-- @
-- data User = User
-- { userId :: Int
-- , userName :: String
-- , userEmail :: String
-- , userPassword :: String
-- }
--
-- -- Pick only id and name fields
-- pick ''User "UserSummary" ['userId, 'userName]
--
-- -- With additional config
-- pick ''User "UserDTO" ['userId, 'userName, 'userEmail]
-- \`stripPrefix\` "user"
-- @
pick
:: Name -- ^ Source type
-> String -- ^ New type name
-> [Name] -- ^ Fields to pick
-> Q [Dec]
pick sourceType newTypeName fields =
tweakType (KeepOnly $ defaultTweakConfig `withFields` fields) newTypeName sourceType
-- | Omit specific fields from a type to create a new type.
--
-- Similar to TypeScript's @Omit<T, K>@ utility type.
--
-- @
-- data User = User
-- { userId :: Int
-- , userName :: String
-- , userEmail :: String
-- , userPassword :: String
-- }
--
-- -- Omit sensitive fields
-- omit ''User "UserDTO" ['userPassword]
--
-- -- With additional config
-- omit ''User "UserResponse" ['userPassword, 'userCreatedAt]
-- \`addPrefix\` "dto"
-- @
omit
:: Name -- ^ Source type
-> String -- ^ New type name
-> [Name] -- ^ Fields to omit
-> Q [Dec]
omit sourceType newTypeName fields =
tweakType (DropFrom $ defaultTweakConfig `withoutFields` fields) newTypeName sourceType
-- | Configuration-first version of 'pick' for use with config helpers.
--
-- @
-- pick' (defaultTweakConfig \`stripPrefix\` "user") ''User "UserClean" ['userName, 'userEmail]
-- @
pick'
:: TweakConfig -- ^ Configuration
-> Name -- ^ Source type
-> String -- ^ New type name
-> [Name] -- ^ Fields to pick
-> Q [Dec]
pick' config sourceType newTypeName fields =
tweakType (KeepOnly $ config `withFields` fields) newTypeName sourceType
-- | Configuration-first version of 'omit' for use with config helpers.
--
-- @
-- omit' (defaultTweakConfig \`addPrefix\` "dto") ''User "UserDTO" ['userPassword]
-- @
omit'
:: TweakConfig -- ^ Configuration
-> Name -- ^ Source type
-> String -- ^ New type name
-> [Name] -- ^ Fields to omit
-> Q [Dec]
omit' config sourceType newTypeName fields =
tweakType (DropFrom $ config `withoutFields` fields) newTypeName sourceType
--------------------------------------------------------------------------------
-- Main API (Legacy)
-- | Generate a new data type from an existing one with modifications.
--
-- @
-- -- Drop sensitive fields
-- tweakType (DropFrom $ defaultTweakConfig \`withoutFields\` ['userPassword])
-- "UserDTO"
-- ''User
--
-- -- Keep only specific fields
-- tweakType (KeepOnly $ defaultTweakConfig \`withFields\` ['userId, 'userName])
-- "UserMinimal"
-- ''User
-- @
tweakType
:: TweakStrategy -- ^ How to select/modify fields
-> String -- ^ New type name
-> Name -- ^ Source type name
-> Q [Dec] -- ^ Generated: data type + optional From instances
tweakType strategy newTypeName sourceTypeName = do
-- Extract source type info
sourceInfo <- reify sourceTypeName
(sourceConName, sourceFields) <- extractFieldsWithCon sourceInfo
-- Get config from strategy
let config = case strategy of
KeepOnly cfg -> cfg
DropFrom cfg -> cfg
TransformWith cfg -> cfg
-- Apply field selection
selectedFields <- applyFieldSelection strategy sourceFields
-- Apply field renames
renamedFields <- applyFieldRenames config selectedFields
-- Generate new data type
let newTypeNameQ = mkName newTypeName
newTypeDec <- generateDataType newTypeNameQ renamedFields
-- Generate Warlock instances if enabled
instanceDecs <- if autoDerive config
then generateInstances config sourceTypeName sourceConName newTypeNameQ selectedFields renamedFields
else pure []
pure $ newTypeDec : instanceDecs
--------------------------------------------------------------------------------
-- Implementation: Extract Source Type Info
extractFieldsWithCon :: Info -> Q (Name, [(Name, Bang, Type)])
extractFieldsWithCon info = case info of
TyConI (DataD _ _ _ _ [RecC conName fields] _) ->
pure (conName, fields)
TyConI (NewtypeD _ _ _ _ (RecC conName fields) _) ->
pure (conName, fields)
TyConI (DataD _ _ _ _ [NormalC conName _fields] _) ->
fail $ "Warlock.Tweak does not support positional constructors. "
++ "Constructor " ++ nameBase conName ++ " has no named fields."
TyConI (DataD _ _ _ _ cons _) | length cons > 1 ->
fail $ "Warlock.Tweak does not support multi-constructor ADTs (yet). "
++ "Found " ++ show (length cons) ++ " constructors."
TyConI (DataD _ _ _ _ [] _) ->
fail "Warlock.Tweak: Source type has no constructors."
_ -> fail $ "Warlock.Tweak: Cannot derive from " ++ show info
--------------------------------------------------------------------------------
-- Implementation: Field Selection
applyFieldSelection :: TweakStrategy -> [(Name, Bang, Type)] -> Q [(Name, Bang, Type)]
applyFieldSelection strategy sourceFields =
case strategy of
KeepOnly cfg -> case fieldSelector cfg of
AllFields -> pure sourceFields
SelectFields names -> do
let fieldMap = [(fname, (fname, b, ty)) | (fname, b, ty) <- sourceFields]
forM names $ \fieldName ->
case lookup fieldName fieldMap of
Just field -> pure field
Nothing -> fail $ "Field '" ++ nameBase fieldName ++ "' not found in source type"
DropFrom cfg -> case fieldSelector cfg of
AllFields -> pure sourceFields
SelectFields namesToDrop -> do
let shouldKeep (fname, _, _) = fname `notElem` namesToDrop
pure $ filter shouldKeep sourceFields
TransformWith _ ->
fail "TransformWith strategy not yet implemented"
--------------------------------------------------------------------------------
-- Implementation: Field Renaming
applyFieldRenames :: TweakConfig -> [(Name, Bang, Type)] -> Q [(Name, Bang, Type)]
applyFieldRenames TweakConfig{..} inputFields = do
-- Apply field transformations:
-- 1. First check for explicit renames in staticRules
-- 2. Then apply the normalize function for prefix operations
let staticRules = Warlock.staticRules warlockConfig
normalize = Warlock.normalize warlockConfig
-- Build a map of explicit renames from staticRules
-- In Warlock, a rename rule stores dst (populated field) and src (source field)
-- For Tweak, we use renames to transform field names, so we reverse the mapping:
-- the rule's dst is the OLD name (what we're renaming FROM)
-- the rule's src is the NEW name (what we're renaming TO)
explicitRenameMap =
[ (dstName, srcName) -- dst is old name, src is new name
| rule <- staticRules
, let dstName = Warlock.dst rule
, Just (Warlock.RealField srcName) <- [Warlock.src rule]
]
transformedFields = map applyTransform inputFields
applyTransform :: (Name, Bang, Type) -> (Name, Bang, Type)
applyTransform (fname, b, ty) =
-- First check for explicit rename
case lookup fname explicitRenameMap of
Just newName -> (newName, b, ty)
Nothing ->
-- Otherwise apply normalize function
let normalized = normalize (nameBase fname)
in (mkName normalized, b, ty)
-- Check for name collisions
checkNameCollisions transformedFields
pure transformedFields
where
checkNameCollisions :: [(Name, Bang, Type)] -> Q ()
checkNameCollisions flds = do
let names = map (\(n, _, _) -> nameBase n) flds
let duplicates = findDuplicates names
when (not $ null duplicates) $
fail $ "Warlock.Tweak: Field name collision after renaming: "
++ show duplicates
findDuplicates :: Eq a => [a] -> [a]
findDuplicates [] = []
findDuplicates (x:xs)
| x `elem` xs = x : findDuplicates xs
| otherwise = findDuplicates xs
--------------------------------------------------------------------------------
-- Implementation: Generate Data Type
generateDataType :: Name -> [(Name, Bang, Type)] -> Q Dec
generateDataType typeName fields = do
let conName = typeName -- Constructor name same as type name
let conDec = RecC conName fields
pure $ DataD [] typeName [] Nothing [conDec] []
--------------------------------------------------------------------------------
-- Implementation: Generate Warlock Instances
generateInstances
:: TweakConfig
-> Name -- Source type
-> Name -- Source constructor
-> Name -- New type
-> [(Name, Bang, Type)] -- Source fields (before renaming)
-> [(Name, Bang, Type)] -- New fields (after renaming)
-> Q [Dec]
generateInstances TweakConfig{..} sourceType sourceConName newType sourceFields newFields = do
-- For Tweak, we need to create explicit rename rules for each field since the
-- destination fields may have been renamed (e.g., userId -> dtoUserId).
-- We create a config with identity normalization and explicit field mappings.
--
-- sourceFields and newFields are parallel arrays (same length, same order):
-- sourceFields are the selected fields from the source type (before renaming)
-- newFields are those same fields after applying field transformations
let existingRules = Warlock.staticRules warlockConfig
-- Extract destination field names from existing rules
existingDstFields = [Warlock.dst r | r <- existingRules]
-- Only add positional renames for fields that don't have explicit rules
automaticRules =
[ Warlock.rename destName srcName
| ((srcName, _, _), (destName, _, _)) <- zip sourceFields newFields
, destName `notElem` existingDstFields -- Don't override explicit rules
]
-- Use the warlock config but add automatic renames and set normalize to identity
-- (since we've already applied transformations to the field names)
tweakInstanceConfig = warlockConfig
{ Warlock.staticRules = existingRules ++ automaticRules
, Warlock.normalize = id
}
inst <- Warlock.resolveFieldRulesAndBuildInstance tweakInstanceConfig sourceType sourceConName sourceFields newType newType newFields
pure [inst]