diff --git a/waspc/src/Wasp/Analyzer/StdTypeDefinitions.hs b/waspc/src/Wasp/Analyzer/StdTypeDefinitions.hs index 5e9d1e4e2..7e005d89f 100644 --- a/waspc/src/Wasp/Analyzer/StdTypeDefinitions.hs +++ b/waspc/src/Wasp/Analyzer/StdTypeDefinitions.hs @@ -41,6 +41,10 @@ makeDeclType ''App -- | Collection of domain types that are standard for Wasp, that define what the Wasp language looks like. -- These are injected this way instead of hardcoding them into the Analyzer in order to make it -- easier to modify and maintain the Wasp compiler/language. + +-- *** MAKE SURE TO UPDATE: The `validateUniqueDeclarationNames` function in the `Wasp.AppSpec.Valid` module +-- when you add a new declaration type here, we need to check for duplicate declaration names +-- for the new declaration type. stdTypes :: TD.TypeDefinitions stdTypes = TD.addDeclType @App $ diff --git a/waspc/src/Wasp/AppSpec/Valid.hs b/waspc/src/Wasp/AppSpec/Valid.hs index 67d549121..11c83c200 100644 --- a/waspc/src/Wasp/AppSpec/Valid.hs +++ b/waspc/src/Wasp/AppSpec/Valid.hs @@ -30,7 +30,7 @@ import qualified Wasp.AppSpec.App.Client as Client import qualified Wasp.AppSpec.App.Db as AS.Db import qualified Wasp.AppSpec.App.EmailSender as AS.EmailSender import qualified Wasp.AppSpec.App.Wasp as Wasp -import Wasp.AppSpec.Core.Decl (takeDecls) +import Wasp.AppSpec.Core.Decl (IsDecl, takeDecls) import qualified Wasp.AppSpec.Crud as AS.Crud import qualified Wasp.AppSpec.Entity as Entity import qualified Wasp.AppSpec.Entity.Field as Entity.Field @@ -43,7 +43,7 @@ import qualified Wasp.Node.Version as V import qualified Wasp.Psl.Ast.Model as PslModel import qualified Wasp.SemanticVersion as SV import qualified Wasp.SemanticVersion.VersionBound as SVB -import Wasp.Util (isCapitalized) +import Wasp.Util (findDuplicateElems, isCapitalized) import qualified Wasp.Version as WV data ValidationError = GenericValidationError !String | GenericValidationWarning !String @@ -78,6 +78,7 @@ validateAppSpec spec = validateApiRoutesAreUnique spec, validateApiNamespacePathsAreUnique spec, validateCrudOperations spec, + validateUniqueDeclarationNames spec, validateDeclarationNames spec, validatePrismaOptions spec, validateWebAppBaseDir spec, @@ -267,6 +268,39 @@ validateCrudOperations spec = maybeIdBlockAttribute = Entity.getIdBlockAttribute entity (entityName, entity) = AS.resolveRef spec (AS.Crud.entity crud) +{- ORMOLU_DISABLE -} +-- *** MAKE SURE TO UPDATE: Unit tests in `AppSpec.ValidTest` module named "duplicate declarations validation" +-- to include the new declaration type. +{- ORMOLU_ENABLE -} +validateUniqueDeclarationNames :: AppSpec -> [ValidationError] +validateUniqueDeclarationNames spec = + concat + [ checkIfDeclarationsAreUnique "page" (AS.getPages spec), + checkIfDeclarationsAreUnique "route" (AS.getRoutes spec), + checkIfDeclarationsAreUnique "action" (AS.getActions spec), + checkIfDeclarationsAreUnique "query" (AS.getQueries spec), + checkIfDeclarationsAreUnique "api" (AS.getApis spec), + checkIfDeclarationsAreUnique "apiNamespace" (AS.getApiNamespaces spec), + checkIfDeclarationsAreUnique "crud" (AS.getCruds spec), + checkIfDeclarationsAreUnique "entity" (AS.getEntities spec), + checkIfDeclarationsAreUnique "job" (AS.getJobs spec) + ] + where + checkIfDeclarationsAreUnique :: IsDecl a => String -> [(String, a)] -> [ValidationError] + checkIfDeclarationsAreUnique declTypeName decls = case duplicateDeclNames of + [] -> [] + (firstDuplicateDeclName : _) -> + [ GenericValidationError $ + "There are duplicate " + ++ declTypeName + ++ " declarations with name '" + ++ firstDuplicateDeclName + ++ "'." + ] + where + duplicateDeclNames :: [String] + duplicateDeclNames = findDuplicateElems $ map fst decls + validateDeclarationNames :: AppSpec -> [ValidationError] validateDeclarationNames spec = concat diff --git a/waspc/src/Wasp/Util.hs b/waspc/src/Wasp/Util.hs index 6ed7c3958..9cedb4e0d 100644 --- a/waspc/src/Wasp/Util.hs +++ b/waspc/src/Wasp/Util.hs @@ -39,6 +39,7 @@ module Wasp.Util textToLazyBS, trim, secondsToMicroSeconds, + findDuplicateElems, ) where @@ -51,7 +52,7 @@ import qualified Data.ByteString.Lazy as BSL import qualified Data.ByteString.UTF8 as BSU import Data.Char (isSpace, isUpper, toLower, toUpper) import qualified Data.HashMap.Strict as M -import Data.List (intercalate) +import Data.List (group, intercalate, sort) import Data.List.Split (splitOn, wordsBy) import Data.Maybe (fromMaybe) import Data.Text (Text) @@ -266,3 +267,6 @@ textToLazyBS = TLE.encodeUtf8 . TL.fromStrict secondsToMicroSeconds :: Int -> Int secondsToMicroSeconds = (* 1000000) + +findDuplicateElems :: Ord a => [a] -> [a] +findDuplicateElems = map head . filter ((> 1) . length) . group . sort diff --git a/waspc/test/AppSpec/ValidTest.hs b/waspc/test/AppSpec/ValidTest.hs index b85ffdff3..8ce7255dd 100644 --- a/waspc/test/AppSpec/ValidTest.hs +++ b/waspc/test/AppSpec/ValidTest.hs @@ -8,18 +8,25 @@ import Fixtures (systemSPRoot) import qualified StrongPath as SP import Test.Tasty.Hspec import qualified Wasp.AppSpec as AS +import qualified Wasp.AppSpec.Action as AS.Action +import qualified Wasp.AppSpec.Api as AS.Api +import qualified Wasp.AppSpec.ApiNamespace as AS.ApiNamespace import qualified Wasp.AppSpec.App as AS.App import qualified Wasp.AppSpec.App.Auth as AS.Auth import qualified Wasp.AppSpec.App.Auth.EmailVerification as AS.Auth.EmailVerification import qualified Wasp.AppSpec.App.Auth.PasswordReset as AS.Auth.PasswordReset +import qualified Wasp.AppSpec.App.Db as AS.Db import qualified Wasp.AppSpec.App.EmailSender as AS.EmailSender import qualified Wasp.AppSpec.App.Wasp as AS.Wasp import qualified Wasp.AppSpec.Core.Decl as AS.Decl import qualified Wasp.AppSpec.Core.Ref as AS.Core.Ref +import qualified Wasp.AppSpec.Crud as AS.Crud import qualified Wasp.AppSpec.Entity as AS.Entity import qualified Wasp.AppSpec.ExtImport as AS.ExtImport +import qualified Wasp.AppSpec.Job as AS.Job import qualified Wasp.AppSpec.PackageJson as AS.PJS import qualified Wasp.AppSpec.Page as AS.Page +import qualified Wasp.AppSpec.Query as AS.Query import qualified Wasp.AppSpec.Route as AS.Route import qualified Wasp.AppSpec.Valid as ASV import qualified Wasp.Psl.Ast.Model as PslM @@ -323,6 +330,56 @@ spec_AppSpecValid = do it "returns an error if the Dummy email sender is used when building the app" $ do ASV.validateAppSpec (makeSpec (Just dummyEmailSender) True) `shouldBe` [ASV.GenericValidationError "app.emailSender must not be set to Dummy when building for production."] + + describe "duplicate declarations validation" $ do + -- Page + let pageDecl = makeBasicPageDecl "testPage" + + -- Route + let routeDecl = makeBasicRouteDecl "testRoute" "testPage" + + -- Action + let actionDecl = makeBasicActionDecl "testAction" + + -- Query + let queryDecl = makeBasicQueryDecl "testQuery" + + -- Api + let apiDecl1 = makeBasicApiDecl "testApi" (AS.Api.GET, "/foo/bar") + -- Using a different route not to trigger duplicate route errors + let apiDecl2 = makeBasicApiDecl "testApi" (AS.Api.GET, "/different/route") + + -- ApiNamespace + let apiNamespaceDecl1 = makeBasicApiNamespaceDecl "testApiNamespace" "/foo" + -- Using a different path not to trigger duplicate route errors + let apiNamespaceDecl2 = makeBasicApiNamespaceDecl "testApiNamespace" "/different/path" + + -- Crud + let crudDecl = makeBasicCrudDecl "testCrud" "TestEntity" + + -- Entity + let entityDecl = makeBasicEntityDecl "TestEntity" + + -- Job + let jobDecl = makeBasicJobDecl "testJob" + + let testDuplicateDecls decls declTypeName expectedErrorMessage = it ("returns an error if there are duplicate " ++ declTypeName ++ " declarations") $ do + ASV.validateAppSpec + ( basicAppSpec + { AS.decls = decls + } + ) + `shouldBe` [ASV.GenericValidationError expectedErrorMessage] + + testDuplicateDecls [basicAppDecl, pageDecl, pageDecl] "page" "There are duplicate page declarations with name 'testPage'." + testDuplicateDecls [basicAppDecl, routeDecl, routeDecl] "route" "There are duplicate route declarations with name 'testRoute'." + testDuplicateDecls [basicAppDecl, actionDecl, actionDecl] "action" "There are duplicate action declarations with name 'testAction'." + testDuplicateDecls [basicAppDecl, queryDecl, queryDecl] "query" "There are duplicate query declarations with name 'testQuery'." + testDuplicateDecls [basicAppDecl, apiDecl1, apiDecl2] "api" "There are duplicate api declarations with name 'testApi'." + testDuplicateDecls [basicAppDecl, apiNamespaceDecl1, apiNamespaceDecl2] "apiNamespace" "There are duplicate apiNamespace declarations with name 'testApiNamespace'." + testDuplicateDecls [basicAppDecl, crudDecl, crudDecl, entityDecl] "crud" "There are duplicate crud declarations with name 'testCrud'." + testDuplicateDecls [basicAppDecl, entityDecl, entityDecl] "entity" "There are duplicate entity declarations with name 'TestEntity'." + testDuplicateDecls [basicAppDecl, jobDecl, jobDecl] "job" "There are duplicate job declarations with name 'testJob'." where makeIdField name typ = PslM.Field @@ -345,7 +402,13 @@ spec_AppSpecValid = do { AS.Wasp.version = "^" ++ show WV.waspVersion }, AS.App.title = "Test App", - AS.App.db = Nothing, + AS.App.db = + Just $ + AS.Db.Db + { AS.Db.system = Just AS.Db.PostgreSQL, + AS.Db.seeds = Nothing, + AS.Db.prisma = Nothing + }, AS.App.server = Nothing, AS.App.client = Nothing, AS.App.auth = Nothing, @@ -389,10 +452,104 @@ spec_AppSpecValid = do basicPageName = "TestPage" - basicPageDecl = AS.Decl.makeDecl basicPageName basicPage - - basicRoute = AS.Route.Route {AS.Route.to = AS.Core.Ref.Ref basicPageName, AS.Route.path = "/test"} + basicPageDecl = makeBasicPageDecl basicPageName basicRouteName = "TestRoute" - basicRouteDecl = AS.Decl.makeDecl basicRouteName basicRoute + basicRouteDecl = makeBasicRouteDecl basicRouteName basicPageName + + makeBasicPageDecl name = + AS.Decl.makeDecl + name + AS.Page.Page + { AS.Page.component = dummyExtImport, + AS.Page.authRequired = Nothing + } + + makeBasicRouteDecl name pageName = + AS.Decl.makeDecl + name + AS.Route.Route {AS.Route.to = AS.Core.Ref.Ref pageName, AS.Route.path = "/test"} + + makeBasicActionDecl name = + AS.Decl.makeDecl + name + AS.Action.Action + { AS.Action.auth = Nothing, + AS.Action.entities = Nothing, + AS.Action.fn = dummyExtImport + } + + makeBasicQueryDecl name = + AS.Decl.makeDecl + name + AS.Query.Query + { AS.Query.auth = Nothing, + AS.Query.entities = Nothing, + AS.Query.fn = dummyExtImport + } + + makeBasicApiDecl name route = + AS.Decl.makeDecl + name + AS.Api.Api + { AS.Api.fn = dummyExtImport, + AS.Api.middlewareConfigFn = Nothing, + AS.Api.entities = Nothing, + AS.Api.httpRoute = route, + AS.Api.auth = Nothing + } + + makeBasicApiNamespaceDecl name path = + AS.Decl.makeDecl + name + AS.ApiNamespace.ApiNamespace + { AS.ApiNamespace.middlewareConfigFn = dummyExtImport, + AS.ApiNamespace.path = path + } + + makeBasicCrudDecl name entityName = + AS.Decl.makeDecl + name + AS.Crud.Crud + { -- CRUD references testEntity, which is defined below, + -- it needs to be included in the test declarations. + AS.Crud.entity = AS.Core.Ref.Ref entityName, + AS.Crud.operations = + AS.Crud.CrudOperations + { AS.Crud.get = + Just $ + AS.Crud.CrudOperationOptions + { AS.Crud.isPublic = Nothing, + AS.Crud.overrideFn = Nothing + }, + AS.Crud.getAll = Nothing, + AS.Crud.create = Nothing, + AS.Crud.update = Nothing, + AS.Crud.delete = Nothing + } + } + + makeBasicEntityDecl name = + AS.Decl.makeDecl + name + (AS.Entity.makeEntity $ PslM.Body [PslM.ElementField $ makeIdField "id" PslM.String]) + + makeBasicJobDecl name = + AS.Decl.makeDecl + name + AS.Job.Job + { AS.Job.executor = AS.Job.PgBoss, + AS.Job.perform = + AS.Job.Perform + { AS.Job.fn = dummyExtImport, + AS.Job.executorOptions = Nothing + }, + AS.Job.schedule = Nothing, + AS.Job.entities = Nothing + } + + dummyExtImport = + AS.ExtImport.ExtImport + (AS.ExtImport.ExtImportModule "Dummy") + (fromJust $ SP.parseRelFileP "dummy/File") diff --git a/waspc/test/UtilTest.hs b/waspc/test/UtilTest.hs index cf2fc6715..c5f40cc72 100644 --- a/waspc/test/UtilTest.hs +++ b/waspc/test/UtilTest.hs @@ -149,3 +149,14 @@ spec_checksum :: Spec spec_checksum = do it "Correctly calculates checksum of string" $ do checksumFromString "test" `shouldBe` hexFromString "9f86d081884c7d659a2feaa0c55ad015a3bf4f1b2b0b822cd15d6c15b0f00a08" + +spec_findDuplicateElems :: Spec +spec_findDuplicateElems = do + it "Finds duplicate elements in a list" $ do + findDuplicateElems ([1, 2, 3, 4, 5, 1, 2, 3, 4, 5] :: [Int]) `shouldBe` [1, 2, 3, 4, 5] + + it "Returns empty list if there are no duplicates" $ do + findDuplicateElems ([1, 2, 3, 4, 5] :: [Int]) `shouldBe` [] + + it "Returns empty list for empty list" $ do + findDuplicateElems ([] :: [Int]) `shouldBe` []