Automatic CRUD (#1197)

This commit is contained in:
Mihovil Ilakovac 2023-06-14 16:55:07 +02:00 committed by GitHub
parent 89dbb49160
commit 3faee611ac
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
78 changed files with 2433 additions and 135 deletions

View File

@ -0,0 +1,99 @@
{{={= =}=}}
import { createAction } from "../actions/core";
import { useAction } from "../actions";
import { createQuery } from "../queries/core";
import { useQuery } from "../queries";
import {
{=# operations.Get =}
GetQueryResolved,
{=/ operations.Get =}
{=# operations.GetAll =}
GetAllQueryResolved,
{=/ operations.GetAll =}
{=# operations.Create =}
CreateActionResolved,
{=/ operations.Create =}
{=# operations.Update =}
UpdateActionResolved,
{=/ operations.Update =}
{=# operations.Delete =}
DeleteActionResolved,
{=/ operations.Delete =}
} from '../../../server/src/crud/{= name =}'
function createCrud() {
{=# operations.Get =}
const crudGetQuery = createQuery<GetQueryResolved>(
'{= fullPath =}',
{=& entitiesArray =}
)
{=/ operations.Get =}
{=# operations.GetAll =}
const crudGetAllQuery = createQuery<GetAllQueryResolved>(
'{= fullPath =}',
{=& entitiesArray =}
)
{=/ operations.GetAll =}
{=# operations.Create =}
const crudCreateAction = createAction<CreateActionResolved>(
'{= fullPath =}',
{=& entitiesArray =}
)
{=/ operations.Create =}
{=# operations.Update =}
const crudUpdateAction = createAction<UpdateActionResolved>(
'{= fullPath =}',
{=& entitiesArray =}
)
{=/ operations.Update =}
{=# operations.Delete =}
const crudDeleteAction = createAction<DeleteActionResolved>(
'{= fullPath =}',
{=& entitiesArray =}
)
{=/ operations.Delete =}
return {
{=# operations.Get =}
get: {
query: crudGetQuery,
useQuery(args: Parameters<GetQueryResolved>[0]) {
return useQuery(crudGetQuery, args);
}
},
{=/ operations.Get =}
{=# operations.GetAll =}
getAll: {
query: crudGetAllQuery,
useQuery() {
return useQuery(crudGetAllQuery);
}
},
{=/ operations.GetAll =}
{=# operations.Create =}
create: {
action: crudCreateAction,
useAction() {
return useAction(crudCreateAction);
}
},
{=/ operations.Create =}
{=# operations.Update =}
update: {
action: crudUpdateAction,
useAction() {
return useAction(crudUpdateAction);
}
},
{=/ operations.Update =}
{=# operations.Delete =}
delete: {
action: crudDeleteAction,
useAction() {
return useAction(crudDeleteAction);
}
},
{=/ operations.Delete =}
}
}
export const {= name =} = createCrud();

View File

@ -0,0 +1,218 @@
{{={= =}=}}
import prisma from "../dbClient.js";
import type {
{=# isAuthEnabled =}
AuthenticatedAction,
AuthenticatedQuery,
{=/ isAuthEnabled =}
{=^ isAuthEnabled =}
Action,
Query,
{=/ isAuthEnabled =}
_{= crud.entityUpper =},
} from "../_types";
import type {
Prisma,
} from "@prisma/client";
import type {
{= crud.entityUpper =},
} from "../entities";
{=# isAuthEnabled =}
import { throwInvalidCredentialsError } from "../core/auth.js";
{=/ isAuthEnabled =}
{=# overrides.GetAll.isDefined =}
{=& overrides.GetAll.importStatement =}
{=/ overrides.GetAll.isDefined =}
{=# overrides.Get.isDefined =}
{=& overrides.Get.importStatement =}
{=/ overrides.Get.isDefined =}
{=# overrides.Create.isDefined =}
{=& overrides.Create.importStatement =}
{=/ overrides.Create.isDefined =}
{=# overrides.Update.isDefined =}
{=& overrides.Update.importStatement =}
{=/ overrides.Update.isDefined =}
{=# overrides.Delete.isDefined =}
{=& overrides.Delete.importStatement =}
{=/ overrides.Delete.isDefined =}
type _WaspEntityTagged = _{= crud.entityUpper =}
type _WaspEntity = {= crud.entityUpper =}
const entities = {
{= crud.entityUpper =}: prisma.{= crud.entityLower =},
}
{=!
// Let's explain this template on the GetAll operation example
=}
{=# crud.operations.GetAll =}
// Get All query
{=!
// 1. We define the type for the operation using "queryType" template variable which is either
// AuthenticatedQuery or Query (it depends on whether auth is enabled or not).
=}
export type GetAllQuery<Input, Output> = {= queryType =}<[_WaspEntityTagged], Input, Output>
{=!
// 2. Then, we either use the default implementation of the operation...
=}
{=^ overrides.GetAll.isDefined =}
type GetAllInput = {}
type GetAllOutput = _WaspEntity[]
const _waspGetAllQuery: GetAllQuery<GetAllInput, GetAllOutput> = ((args, context) => {
{=^ crud.operations.GetAll.isPublic =}
throwIfNotAuthenticated(context)
{=/ crud.operations.GetAll.isPublic =}
return context.entities.{= crud.entityUpper =}.findMany();
});
{=/ overrides.GetAll.isDefined =}
{=!
// ... or the one defined in the overrides by the user. We use the "importIdentifier" property to
// reference the function from the overrides.
=}
{=# overrides.GetAll.isDefined =}
const _waspGetAllQuery = {= overrides.GetAll.importIdentifier =}
{=/ overrides.GetAll.isDefined =}
{=!
// 3. We then define the final type for the operation, which is the type of the function we defined in the previous step.
// It will pick up either the default implementation or the one from the overrides.
=}
export type GetAllQueryResolved = typeof _waspGetAllQuery
{=!
// 4. We define a function that is used as the Express route handler
=}
export async function getAllFn(args, context) {
return (_waspGetAllQuery as any)(args, {
...context,
entities,
});
}
{=/ crud.operations.GetAll =}
{=!
// That's it! It is similar for all other operations.
=}
{=# crud.operations.Get =}
// Get query
export type GetQuery<Input, Output> = {= queryType =}<[_WaspEntityTagged], Input, Output>
{=^ overrides.Get.isDefined =}
type GetInput = Prisma.{= crud.entityUpper =}WhereUniqueInput
type GetOutput = _WaspEntity | null
const _waspGetQuery: GetQuery<GetInput, GetOutput> = ((args, context) => {
{=^ crud.operations.Get.isPublic =}
throwIfNotAuthenticated(context)
{=/ crud.operations.Get.isPublic =}
return context.entities.{= crud.entityUpper =}.findUnique({ where: { id: args.id } });
});
{=/ overrides.Get.isDefined =}
{=# overrides.Get.isDefined =}
const _waspGetQuery = {= overrides.Get.importIdentifier =}
{=/ overrides.Get.isDefined =}
export type GetQueryResolved = typeof _waspGetQuery
export async function getFn(args, context) {
return (_waspGetQuery as any)(args, {
...context,
entities,
});
}
{=/ crud.operations.Get =}
{=# crud.operations.Create =}
// Create action
export type CreateAction<Input, Output>= {= actionType =}<[_WaspEntityTagged], Input, Output>
{=^ overrides.Create.isDefined =}
type CreateInput = Prisma.{= crud.entityUpper =}CreateInput
type CreateOutput = _WaspEntity
const _waspCreateAction: CreateAction<CreateInput, CreateOutput> = ((args, context) => {
{=^ crud.operations.Create.isPublic =}
throwIfNotAuthenticated(context)
{=/ crud.operations.Create.isPublic =}
return context.entities.{= crud.entityUpper =}.create({ data: args });
});
{=/ overrides.Create.isDefined =}
{=# overrides.Create.isDefined =}
const _waspCreateAction = {= overrides.Create.importIdentifier =}
{=/ overrides.Create.isDefined =}
export type CreateActionResolved = typeof _waspCreateAction
export async function createFn(args, context) {
return (_waspCreateAction as any)(args, {
...context,
entities,
});
}
{=/ crud.operations.Create =}
{=# crud.operations.Update =}
// Update action
export type UpdateAction<Input, Output> = {= actionType =}<[_WaspEntityTagged], Input, Output>
{=^ overrides.Update.isDefined =}
type UpdateInput = Prisma.{= crud.entityUpper =}UpdateInput & Prisma.{= crud.entityUpper =}WhereUniqueInput
type UpdateOutput = _WaspEntity
const _waspUpdateAction: UpdateAction<UpdateInput, UpdateOutput> = ((args, context) => {
{=^ crud.operations.Update.isPublic =}
throwIfNotAuthenticated(context)
{=/ crud.operations.Update.isPublic =}
const { {= crud.idFieldName =}: idFieldValue, ...rest } = args
return context.entities.{= crud.entityUpper =}.update({
where: { {= crud.idFieldName =}: idFieldValue },
data: rest,
});
});
{=/ overrides.Update.isDefined =}
{=# overrides.Update.isDefined =}
const _waspUpdateAction = {= overrides.Update.importIdentifier =}
{=/ overrides.Update.isDefined =}
export type UpdateActionResolved = typeof _waspUpdateAction
export async function updateFn(args, context) {
return (_waspUpdateAction as any)(args, {
...context,
entities,
});
}
{=/ crud.operations.Update =}
{=# crud.operations.Delete =}
// Delete action
export type DeleteAction<Input, Output> = {= actionType =}<[_WaspEntityTagged], Input, Output>
{=^ overrides.Delete.isDefined =}
type DeleteInput = Prisma.{= crud.entityUpper =}WhereUniqueInput
type DeleteOutput = _WaspEntity
const _waspDeleteAction: DeleteAction<DeleteInput, DeleteOutput> = ((args, context) => {
{=^ crud.operations.Delete.isPublic =}
throwIfNotAuthenticated(context)
{=/ crud.operations.Delete.isPublic =}
const { {= crud.idFieldName =}: idFieldValue } = args
return context.entities.{= crud.entityUpper =}.delete({ where: { {= crud.idFieldName =}: idFieldValue } });
});
{=/ overrides.Delete.isDefined =}
{=# overrides.Delete.isDefined =}
const _waspDeleteAction = {= overrides.Delete.importIdentifier =}
{=/ overrides.Delete.isDefined =}
export type DeleteActionResolved = typeof _waspDeleteAction
export async function deleteFn(args, context) {
return (_waspDeleteAction as any)(args, {
...context,
entities,
});
}
{=/ crud.operations.Delete =}
function throwIfNotAuthenticated (context) {
{=# isAuthEnabled =}
if (!context.user) {
throwInvalidCredentialsError()
}
{=/ isAuthEnabled =}
{=^ isAuthEnabled =}
// Auth is not enabled
{=/ isAuthEnabled =}
}

View File

@ -0,0 +1,28 @@
{{={= =}=}}
import {
deserialize as superjsonDeserialize,
serialize as superjsonSerialize,
} from 'superjson'
import { handleRejection } from '../utils.js'
export function createOperation (handlerFn) {
return handleRejection(async (req, res) => {
const args = (req.body && superjsonDeserialize(req.body)) || {}
const context = {
{=# isAuthEnabled =}
user: req.user
{=/ isAuthEnabled =}
}
const result = await handlerFn(args, context)
const serializedResult = superjsonSerialize(result)
res.json(serializedResult)
})
}
export function createQuery(handlerFn) {
return createOperation(handlerFn)
}
export function createAction(handlerFn) {
return createOperation(handlerFn)
}

View File

@ -0,0 +1,46 @@
{{={= =}=}}
import express from 'express'
import * as crud from '../../crud/{= crud.name =}.js'
import { createAction, createQuery } from '../../middleware/operations.js'
{=# isAuthEnabled =}
import auth from '../../core/auth.js'
{=/ isAuthEnabled =}
const _waspRouter = express.Router()
{=# isAuthEnabled =}
_waspRouter.use(auth)
{=/ isAuthEnabled =}
{=# crud.operations.Get =}
_waspRouter.post(
'/{= route =}',
createQuery(crud.getFn),
)
{=/ crud.operations.Get =}
{=# crud.operations.GetAll =}
_waspRouter.post(
'/{= route =}',
createQuery(crud.getAllFn),
)
{=/ crud.operations.GetAll =}
{=# crud.operations.Create =}
_waspRouter.post(
'/{= route =}',
createAction(crud.createFn),
)
{=/ crud.operations.Create =}
{=# crud.operations.Update =}
_waspRouter.post(
'/{= route =}',
createAction(crud.updateFn),
)
{=/ crud.operations.Update =}
{=# crud.operations.Delete =}
_waspRouter.post(
'/{= route =}',
createAction(crud.deleteFn),
)
{=/ crud.operations.Delete =}
export const {= crud.name =} = _waspRouter

View File

@ -0,0 +1,12 @@
{{={= =}=}}
import express from 'express'
{=# crudRouters =}
{=& importStatement =}
{=/ crudRouters =}
export const rootCrudRouter = express.Router()
{=# crudRouters =}
rootCrudRouter.use('/{= route =}', {= importIdentifier =})
{=/ crudRouters =}

View File

@ -8,6 +8,9 @@ import auth from './auth/index.js'
{=# areThereAnyCustomApiRoutes =}
import apis from './apis/index.js'
{=/ areThereAnyCustomApiRoutes =}
{=# areThereAnyCrudRoutes =}
import { rootCrudRouter } from './crud/index.js'
{=/ areThereAnyCrudRoutes =}
const router = express.Router()
@ -21,6 +24,9 @@ router.get('/', middleware, function (_req, res, _next) {
router.use('/auth', middleware, auth)
{=/ isAuthEnabled =}
router.use('/{= operationsRouteInRootRouter =}', middleware, operations)
{=# areThereAnyCrudRoutes =}
router.use('/', middleware, rootCrudRouter)
{=/ areThereAnyCrudRoutes =}
{=# areThereAnyCustomApiRoutes =}
// NOTE: Keep user-defined api routes last so they cannot override our routes.
// Additionally, do not add middleware to these routes here. Instead, we add

View File

@ -1,22 +1,5 @@
{{={= =}=}}
import {
deserialize as superjsonDeserialize,
serialize as superjsonSerialize,
} from 'superjson'
import { handleRejection } from '../../utils.js'
{=& operationImportStmt =}
export default handleRejection(async (req, res) => {
const args = (req.body && superjsonDeserialize(req.body)) || {}
const context = {
{=# userEntityLower =}
user: req.user
{=/ userEntityLower =}
}
const result = await {= operationName =}(args, context)
const serializedResult = superjsonSerialize(result)
res.json(serializedResult)
})
import { createAction } from '../../middleware/operations.js'
{=& operation.importStatement =}
export default createAction({= operation.importIdentifier =})

View File

@ -1,28 +1,5 @@
{{={= =}=}}
import {
deserialize as superjsonDeserialize,
serialize as superjsonSerialize,
} from 'superjson'
import { handleRejection } from '../../utils.js'
{=& operationImportStmt =}
import { createQuery } from '../../middleware/operations.js'
{=& operation.importStatement =}
export default handleRejection(async (req, res) => {
{=! TODO: When generating express route for query, generated code would be most human-like if we
generated GET route that uses query arguments.
However, for that, we need to know the types of the arguments so we can cast/parse them.
Also, there is limit on URI length, which could be problem if users want to send some bigger
JSON objects or smth.
So for now we are just going with POST that has JSON in the body -> generated code is not
as human-like as it should be though. =}
const args = (req.body && superjsonDeserialize(req.body)) || {}
const context = {
{=# userEntityLower =}
user: req.user
{=/ userEntityLower =}
}
const result = await {= operationName =}(args, context)
const serializedResult = superjsonSerialize(result)
res.json(serializedResult)
})
export default createQuery({= operation.importIdentifier =})

View File

@ -33,6 +33,7 @@ waspComplexTest = do
<++> addQuery
<++> addApi
<++> addApiNamespace
<++> addCrud
<++> sequence
[ waspCliCompile
]
@ -313,6 +314,33 @@ addEmailSender = do
" },"
]
addCrud :: ShellCommandBuilder [ShellCommand]
addCrud = do
sequence
[ appendToWaspFile taskEntityDecl,
appendToWaspFile crudDecl
]
where
taskEntityDecl =
unlines
[ "entity Task {=psl",
" id Int @id @default(autoincrement())",
" description String",
" isDone Boolean @default(false)",
"psl=}"
]
crudDecl =
unlines
[ "crud tasks {",
" entity: Task,",
" operations: {",
" get: {},",
" getAll: {},",
" create: {},",
" }",
"}"
]
insertCodeIntoWaspFileAfterVersion :: String -> ShellCommandBuilder ShellCommand
insertCodeIntoWaspFileAfterVersion = insertCodeIntoWaspFileAtLineNumber lineNumberInWaspFileAfterVersion
where

View File

@ -28,6 +28,7 @@ waspBuild/.wasp/build/server/src/jobs/core/pgBoss/pgBossJob.js
waspBuild/.wasp/build/server/src/jobs/core/simpleJob.js
waspBuild/.wasp/build/server/src/middleware/globalMiddleware.ts
waspBuild/.wasp/build/server/src/middleware/index.ts
waspBuild/.wasp/build/server/src/middleware/operations.ts
waspBuild/.wasp/build/server/src/queries/types.ts
waspBuild/.wasp/build/server/src/routes/index.js
waspBuild/.wasp/build/server/src/routes/operations/index.js

View File

@ -202,6 +202,13 @@
],
"e658719309f9375f389c5d8d416fc27f9c247049e61188b3e01df954bcec15a4"
],
[
[
"file",
"server/src/middleware/operations.ts"
],
"864c7492c27f6da1e67645fbc358dc803a168852bfd24f2c4dd13fccf6917b07"
],
[
[
"file",

View File

@ -0,0 +1,24 @@
import {
deserialize as superjsonDeserialize,
serialize as superjsonSerialize,
} from 'superjson'
import { handleRejection } from '../utils.js'
export function createOperation (handlerFn) {
return handleRejection(async (req, res) => {
const args = (req.body && superjsonDeserialize(req.body)) || {}
const context = {
}
const result = await handlerFn(args, context)
const serializedResult = superjsonSerialize(result)
res.json(serializedResult)
})
}
export function createQuery(handlerFn) {
return createOperation(handlerFn)
}
export function createAction(handlerFn) {
return createOperation(handlerFn)
}

View File

@ -29,6 +29,7 @@ waspCompile/.wasp/out/server/src/jobs/core/pgBoss/pgBossJob.js
waspCompile/.wasp/out/server/src/jobs/core/simpleJob.js
waspCompile/.wasp/out/server/src/middleware/globalMiddleware.ts
waspCompile/.wasp/out/server/src/middleware/index.ts
waspCompile/.wasp/out/server/src/middleware/operations.ts
waspCompile/.wasp/out/server/src/queries/types.ts
waspCompile/.wasp/out/server/src/routes/index.js
waspCompile/.wasp/out/server/src/routes/operations/index.js

View File

@ -209,6 +209,13 @@
],
"e658719309f9375f389c5d8d416fc27f9c247049e61188b3e01df954bcec15a4"
],
[
[
"file",
"server/src/middleware/operations.ts"
],
"864c7492c27f6da1e67645fbc358dc803a168852bfd24f2c4dd13fccf6917b07"
],
[
[
"file",

View File

@ -0,0 +1,24 @@
import {
deserialize as superjsonDeserialize,
serialize as superjsonSerialize,
} from 'superjson'
import { handleRejection } from '../utils.js'
export function createOperation (handlerFn) {
return handleRejection(async (req, res) => {
const args = (req.body && superjsonDeserialize(req.body)) || {}
const context = {
}
const result = await handlerFn(args, context)
const serializedResult = superjsonSerialize(result)
res.json(serializedResult)
})
}
export function createQuery(handlerFn) {
return createOperation(handlerFn)
}
export function createAction(handlerFn) {
return createOperation(handlerFn)
}

View File

@ -35,6 +35,7 @@ waspComplexTest/.wasp/out/server/src/core/HttpError.js
waspComplexTest/.wasp/out/server/src/core/auth.js
waspComplexTest/.wasp/out/server/src/core/auth/prismaMiddleware.js
waspComplexTest/.wasp/out/server/src/core/auth/validators.ts
waspComplexTest/.wasp/out/server/src/crud/tasks.ts
waspComplexTest/.wasp/out/server/src/dbClient.ts
waspComplexTest/.wasp/out/server/src/dbSeed/types.ts
waspComplexTest/.wasp/out/server/src/email/core/helpers.ts
@ -59,11 +60,14 @@ waspComplexTest/.wasp/out/server/src/jobs/core/pgBoss/pgBossJob.js
waspComplexTest/.wasp/out/server/src/jobs/core/simpleJob.js
waspComplexTest/.wasp/out/server/src/middleware/globalMiddleware.ts
waspComplexTest/.wasp/out/server/src/middleware/index.ts
waspComplexTest/.wasp/out/server/src/middleware/operations.ts
waspComplexTest/.wasp/out/server/src/queries/MySpecialQuery.ts
waspComplexTest/.wasp/out/server/src/queries/types.ts
waspComplexTest/.wasp/out/server/src/routes/apis/index.ts
waspComplexTest/.wasp/out/server/src/routes/auth/index.js
waspComplexTest/.wasp/out/server/src/routes/auth/me.js
waspComplexTest/.wasp/out/server/src/routes/crud/index.ts
waspComplexTest/.wasp/out/server/src/routes/crud/tasks.ts
waspComplexTest/.wasp/out/server/src/routes/index.js
waspComplexTest/.wasp/out/server/src/routes/operations/MySpecialAction.js
waspComplexTest/.wasp/out/server/src/routes/operations/MySpecialQuery.js
@ -106,6 +110,7 @@ waspComplexTest/.wasp/out/web-app/src/auth/pages/createAuthRequiredPage.jsx
waspComplexTest/.wasp/out/web-app/src/auth/types.ts
waspComplexTest/.wasp/out/web-app/src/auth/useAuth.ts
waspComplexTest/.wasp/out/web-app/src/config.js
waspComplexTest/.wasp/out/web-app/src/crud/tasks.ts
waspComplexTest/.wasp/out/web-app/src/entities/index.ts
waspComplexTest/.wasp/out/web-app/src/ext-src/App.jsx
waspComplexTest/.wasp/out/web-app/src/ext-src/Main.css

View File

@ -18,7 +18,7 @@
"file",
"db/schema.prisma"
],
"ca888a6ed14f8ad3980e4aa1c35d88a7268d8936134f78efc5a19add7abe9fc0"
"cbea4d60a2c1bef984008597eefc64540e95048d7a43cb718303f5628d386ae1"
],
[
[
@ -88,7 +88,7 @@
"file",
"server/src/_types/index.ts"
],
"d959101b32583258a92f136a8ac169f977a61822dfd51b8dfe205a7f0ec73a57"
"9e8542f1b36712bbf3d633400377fed4da6fd15e69394ae521935c33cbdbbe36"
],
[
[
@ -102,7 +102,7 @@
"file",
"server/src/_types/taggedEntities.ts"
],
"ac5570e21d89fbb8418a2fc26cbbec7b189c13b4bfab5f0b006d356411b8f5a2"
"1d95c918d216f7cc79f1a861c29eb251794e1f1ea2ce53452a81928429a21430"
],
[
[
@ -230,6 +230,13 @@
],
"d7cfe22168d66e0d346c2aec33573c0346c9f0f5c854d3f204bed5b4c315da87"
],
[
[
"file",
"server/src/crud/tasks.ts"
],
"4b27b90bd0f46b570ad53cb121259e32661829ee081a1990ba65ab16921f16ae"
],
[
[
"file",
@ -291,7 +298,7 @@
"file",
"server/src/entities/index.ts"
],
"3dc318116ab03fc779f408442a2608011ea1f3d8f9a28109f27041b7d22ef3d1"
"783fbc250d0628073328625ff299f120ac8bef45232c10b7d7b5897d42b788c1"
],
[
[
@ -398,6 +405,13 @@
],
"e658719309f9375f389c5d8d416fc27f9c247049e61188b3e01df954bcec15a4"
],
[
[
"file",
"server/src/middleware/operations.ts"
],
"64eeed927f46f6d6eba143023f25fb9ac4cd81d6b68c9a7067306ad28a3eda92"
],
[
[
"file",
@ -433,26 +447,40 @@
],
"705f77d8970a8367981c9a89601c6d5b12e998c23970ae1735b376dd0826ef10"
],
[
[
"file",
"server/src/routes/crud/index.ts"
],
"b66ff949dbdaa892a354f3a60dc59ce229323ef55fad38239f8b335ec1e71b8d"
],
[
[
"file",
"server/src/routes/crud/tasks.ts"
],
"cb5a53c503c946083cc46b2500a4eab49b3bfa867d3bcc1cae50355c0796369c"
],
[
[
"file",
"server/src/routes/index.js"
],
"57c6074cfb790ea019efbab199e860e70248a3e758419312939ba63c7a84a42c"
"b589a916a4ded52b75af42e299a506cbeb4d56ce2e87bec29f211c3e33865049"
],
[
[
"file",
"server/src/routes/operations/MySpecialAction.js"
],
"19eea333d3f3f559b93b49f34a3a6f474d86b4d8b83c70f4bb456ef44e361eef"
"6bda2fb808fcc69fedb8f69aa08ceea94c668abec42643e15f59a3c161854bbb"
],
[
[
"file",
"server/src/routes/operations/MySpecialQuery.js"
],
"f8a7ce6ab0c320f24e76d12c5ba01891d8c408c80f07bbf8a6471fafb29abc91"
"af52108997cac2f416819822a9b7d2615121270a1c5886b5df7e03ae5cf1fc09"
],
[
[
@ -734,12 +762,19 @@
],
"b06175129dd8be8ca6c307fafa02646d47233c4e26fdfeea6d7802d02e9513f3"
],
[
[
"file",
"web-app/src/crud/tasks.ts"
],
"2f4ca60123e3fe999dbe4814182b7d09d0039db120e5c260ccc824806561b5e7"
],
[
[
"file",
"web-app/src/entities/index.ts"
],
"642ab14a4d5b92152ba458b210502adea70fa7a6d1d6af35108a71236a33e89b"
"cfb9f8237f80aea777840621702d03803172b005b3aaee2fc0be6c9f1b1c8414"
],
[
[

View File

@ -26,3 +26,9 @@ model SocialLogin {
@@unique([provider, providerId, userId])
}
model Task {
id Int @id @default(autoincrement())
description String
isDone Boolean @default(false)
}

View File

@ -1 +1 @@
ca888a6ed14f8ad3980e4aa1c35d88a7268d8936134f78efc5a19add7abe9fc0
cbea4d60a2c1bef984008597eefc64540e95048d7a43cb718303f5628d386ae1

View File

@ -64,6 +64,7 @@ type EntityMap<Entities extends _Entity[]> = {
type PrismaDelegate = {
"User": typeof prisma.user,
"SocialLogin": typeof prisma.socialLogin,
"Task": typeof prisma.task,
}
type Context<Entities extends _Entity[]> = Expand<{

View File

@ -8,14 +8,17 @@ import {
type EntityName,
type User,
type SocialLogin,
type Task,
} from '../entities'
export type _User = WithName<User, "User">
export type _SocialLogin = WithName<SocialLogin, "SocialLogin">
export type _Task = WithName<Task, "Task">
export type _Entity =
| _User
| _SocialLogin
| _Task
| never
type WithName<E extends Entity, Name extends EntityName> =

View File

@ -0,0 +1,81 @@
import prisma from "../dbClient.js";
import type {
AuthenticatedAction,
AuthenticatedQuery,
_Task,
} from "../_types";
import type {
Prisma,
} from "@prisma/client";
import type {
Task,
} from "../entities";
import { throwInvalidCredentialsError } from "../core/auth.js";
type _WaspEntityTagged = _Task
type _WaspEntity = Task
const entities = {
Task: prisma.task,
}
// Get All query
export type GetAllQuery<Input, Output> = AuthenticatedQuery<[_WaspEntityTagged], Input, Output>
type GetAllInput = {}
type GetAllOutput = _WaspEntity[]
const _waspGetAllQuery: GetAllQuery<GetAllInput, GetAllOutput> = ((args, context) => {
throwIfNotAuthenticated(context)
return context.entities.Task.findMany();
});
export type GetAllQueryResolved = typeof _waspGetAllQuery
export async function getAllFn(args, context) {
return (_waspGetAllQuery as any)(args, {
...context,
entities,
});
}
// Get query
export type GetQuery<Input, Output> = AuthenticatedQuery<[_WaspEntityTagged], Input, Output>
type GetInput = Prisma.TaskWhereUniqueInput
type GetOutput = _WaspEntity | null
const _waspGetQuery: GetQuery<GetInput, GetOutput> = ((args, context) => {
throwIfNotAuthenticated(context)
return context.entities.Task.findUnique({ where: { id: args.id } });
});
export type GetQueryResolved = typeof _waspGetQuery
export async function getFn(args, context) {
return (_waspGetQuery as any)(args, {
...context,
entities,
});
}
// Create action
export type CreateAction<Input, Output>= AuthenticatedAction<[_WaspEntityTagged], Input, Output>
type CreateInput = Prisma.TaskCreateInput
type CreateOutput = _WaspEntity
const _waspCreateAction: CreateAction<CreateInput, CreateOutput> = ((args, context) => {
throwIfNotAuthenticated(context)
return context.entities.Task.create({ data: args });
});
export type CreateActionResolved = typeof _waspCreateAction
export async function createFn(args, context) {
return (_waspCreateAction as any)(args, {
...context,
entities,
});
}
function throwIfNotAuthenticated (context) {
if (!context.user) {
throwInvalidCredentialsError()
}
}

View File

@ -1,19 +1,23 @@
import {
type User,
type SocialLogin,
type Task,
} from "@prisma/client"
export {
type User,
type SocialLogin,
type Task,
} from "@prisma/client"
export type Entity =
| User
| SocialLogin
| Task
| never
export type EntityName =
| "User"
| "SocialLogin"
| "Task"
| never

View File

@ -0,0 +1,25 @@
import {
deserialize as superjsonDeserialize,
serialize as superjsonSerialize,
} from 'superjson'
import { handleRejection } from '../utils.js'
export function createOperation (handlerFn) {
return handleRejection(async (req, res) => {
const args = (req.body && superjsonDeserialize(req.body)) || {}
const context = {
user: req.user
}
const result = await handlerFn(args, context)
const serializedResult = superjsonSerialize(result)
res.json(serializedResult)
})
}
export function createQuery(handlerFn) {
return createOperation(handlerFn)
}
export function createAction(handlerFn) {
return createOperation(handlerFn)
}

View File

@ -0,0 +1,7 @@
import express from 'express'
import { tasks } from './tasks.js'
export const rootCrudRouter = express.Router()
rootCrudRouter.use('/tasks', tasks)

View File

@ -0,0 +1,23 @@
import express from 'express'
import * as crud from '../../crud/tasks.js'
import { createAction, createQuery } from '../../middleware/operations.js'
import auth from '../../core/auth.js'
const _waspRouter = express.Router()
_waspRouter.use(auth)
_waspRouter.post(
'/get',
createQuery(crud.getFn),
)
_waspRouter.post(
'/get-all',
createQuery(crud.getAllFn),
)
_waspRouter.post(
'/create',
createAction(crud.createFn),
)
export const tasks = _waspRouter

View File

@ -3,6 +3,7 @@ import operations from './operations/index.js'
import { globalMiddlewareConfigForExpress } from '../middleware/index.js'
import auth from './auth/index.js'
import apis from './apis/index.js'
import { rootCrudRouter } from './crud/index.js'
const router = express.Router()
@ -14,6 +15,7 @@ router.get('/', middleware, function (_req, res, _next) {
router.use('/auth', middleware, auth)
router.use('/operations', middleware, operations)
router.use('/', middleware, rootCrudRouter)
// NOTE: Keep user-defined api routes last so they cannot override our routes.
// Additionally, do not add middleware to these routes here. Instead, we add
// it later to allow for middleware customization.

View File

@ -1,19 +1,4 @@
import {
deserialize as superjsonDeserialize,
serialize as superjsonSerialize,
} from 'superjson'
import { handleRejection } from '../../utils.js'
import { createAction } from '../../middleware/operations.js'
import MySpecialAction from '../../actions/MySpecialAction.js'
export default handleRejection(async (req, res) => {
const args = (req.body && superjsonDeserialize(req.body)) || {}
const context = {
user: req.user
}
const result = await MySpecialAction(args, context)
const serializedResult = superjsonSerialize(result)
res.json(serializedResult)
})
export default createAction(MySpecialAction)

View File

@ -1,18 +1,4 @@
import {
deserialize as superjsonDeserialize,
serialize as superjsonSerialize,
} from 'superjson'
import { handleRejection } from '../../utils.js'
import { createQuery } from '../../middleware/operations.js'
import MySpecialQuery from '../../queries/MySpecialQuery.js'
export default handleRejection(async (req, res) => {
const args = (req.body && superjsonDeserialize(req.body)) || {}
const context = {
user: req.user
}
const result = await MySpecialQuery(args, context)
const serializedResult = superjsonSerialize(result)
res.json(serializedResult)
})
export default createQuery(MySpecialQuery)

View File

@ -0,0 +1,46 @@
import { createAction } from "../actions/core";
import { useAction } from "../actions";
import { createQuery } from "../queries/core";
import { useQuery } from "../queries";
import {
GetQueryResolved,
GetAllQueryResolved,
CreateActionResolved,
} from '../../../server/src/crud/tasks'
function createCrud() {
const crudGetQuery = createQuery<GetQueryResolved>(
'tasks/get',
['Task']
)
const crudGetAllQuery = createQuery<GetAllQueryResolved>(
'tasks/get-all',
['Task']
)
const crudCreateAction = createAction<CreateActionResolved>(
'tasks/create',
['Task']
)
return {
get: {
query: crudGetQuery,
useQuery(args: Parameters<GetQueryResolved>[0]) {
return useQuery(crudGetQuery, args);
}
},
getAll: {
query: crudGetAllQuery,
useQuery() {
return useQuery(crudGetAllQuery);
}
},
create: {
action: crudCreateAction,
useAction() {
return useAction(crudCreateAction);
}
},
}
}
export const tasks = createCrud();

View File

@ -1,14 +1,17 @@
import {
User,
SocialLogin,
Task,
} from '@prisma/client'
export type {
User,
SocialLogin,
Task,
} from '@prisma/client'
export type Entity =
| User
| SocialLogin
| Task
| never

View File

@ -91,3 +91,18 @@ apiNamespace fooBarNamespace {
path: "/bar"
}
entity Task {=psl
id Int @id @default(autoincrement())
description String
isDone Boolean @default(false)
psl=}
crud tasks {
entity: Task,
operations: {
get: {},
getAll: {},
create: {},
}
}

View File

@ -32,6 +32,7 @@ waspJob/.wasp/out/server/src/jobs/core/pgBoss/pgBossJob.js
waspJob/.wasp/out/server/src/jobs/core/simpleJob.js
waspJob/.wasp/out/server/src/middleware/globalMiddleware.ts
waspJob/.wasp/out/server/src/middleware/index.ts
waspJob/.wasp/out/server/src/middleware/operations.ts
waspJob/.wasp/out/server/src/queries/types.ts
waspJob/.wasp/out/server/src/routes/index.js
waspJob/.wasp/out/server/src/routes/operations/index.js

View File

@ -223,6 +223,13 @@
],
"e658719309f9375f389c5d8d416fc27f9c247049e61188b3e01df954bcec15a4"
],
[
[
"file",
"server/src/middleware/operations.ts"
],
"864c7492c27f6da1e67645fbc358dc803a168852bfd24f2c4dd13fccf6917b07"
],
[
[
"file",

View File

@ -0,0 +1,24 @@
import {
deserialize as superjsonDeserialize,
serialize as superjsonSerialize,
} from 'superjson'
import { handleRejection } from '../utils.js'
export function createOperation (handlerFn) {
return handleRejection(async (req, res) => {
const args = (req.body && superjsonDeserialize(req.body)) || {}
const context = {
}
const result = await handlerFn(args, context)
const serializedResult = superjsonSerialize(result)
res.json(serializedResult)
})
}
export function createQuery(handlerFn) {
return createOperation(handlerFn)
}
export function createAction(handlerFn) {
return createOperation(handlerFn)
}

View File

@ -34,6 +34,7 @@ waspMigrate/.wasp/out/server/src/jobs/core/pgBoss/pgBossJob.js
waspMigrate/.wasp/out/server/src/jobs/core/simpleJob.js
waspMigrate/.wasp/out/server/src/middleware/globalMiddleware.ts
waspMigrate/.wasp/out/server/src/middleware/index.ts
waspMigrate/.wasp/out/server/src/middleware/operations.ts
waspMigrate/.wasp/out/server/src/queries/types.ts
waspMigrate/.wasp/out/server/src/routes/index.js
waspMigrate/.wasp/out/server/src/routes/operations/index.js

View File

@ -209,6 +209,13 @@
],
"e658719309f9375f389c5d8d416fc27f9c247049e61188b3e01df954bcec15a4"
],
[
[
"file",
"server/src/middleware/operations.ts"
],
"864c7492c27f6da1e67645fbc358dc803a168852bfd24f2c4dd13fccf6917b07"
],
[
[
"file",

View File

@ -0,0 +1,24 @@
import {
deserialize as superjsonDeserialize,
serialize as superjsonSerialize,
} from 'superjson'
import { handleRejection } from '../utils.js'
export function createOperation (handlerFn) {
return handleRejection(async (req, res) => {
const args = (req.body && superjsonDeserialize(req.body)) || {}
const context = {
}
const result = await handlerFn(args, context)
const serializedResult = superjsonSerialize(result)
res.json(serializedResult)
})
}
export function createQuery(handlerFn) {
return createOperation(handlerFn)
}
export function createAction(handlerFn) {
return createOperation(handlerFn)
}

View File

@ -0,0 +1,3 @@
/.wasp/
/.env.server
/.env.client

View File

@ -0,0 +1 @@
File marking the root of Wasp project.

View File

@ -0,0 +1,69 @@
app crudTesting {
wasp: {
version: "^0.10.4"
},
head: [
"<link rel=\"stylesheet\" href=\"https://unpkg.com/mvp.css@1.12/mvp.css\">"
],
title: "crud-testing",
auth: {
userEntity: User,
methods: {
usernameAndPassword: {},
},
onAuthFailedRedirectTo: "/login",
},
}
route RootRoute { path: "/", to: MainPage }
page MainPage {
component: import Main from "@client/MainPage.tsx",
authRequired: true,
}
route LoginRoute { path: "/login", to: LoginPage }
page LoginPage {
component: import { LoginPage } from "@client/LoginPage.tsx",
}
route SignupRoute { path: "/signup", to: SignupPage }
page SignupPage {
component: import { SignupPage } from "@client/SignupPage.tsx",
}
route DetailRoute { path: "/:id", to: DetailPage }
page DetailPage {
component: import Main from "@client/DetailPage.tsx",
authRequired: true,
}
entity User {=psl
id Int @id @default(autoincrement())
username String @unique
password String
tasks Task[]
psl=}
// TODO: validate the fields
// Maybe delegate to Prisma
entity Task {=psl
id Int @id @default(autoincrement())
title String
userId Int
user User @relation(fields: [userId], references: [id])
psl=}
crud tasks {
entity: Task,
operations: {
get: {},
getAll: {
overrideFn: import { getAllTasks } from "@server/tasks.js",
},
create: {
overrideFn: import { createTask } from "@server/tasks.js",
},
update: {},
delete: {},
},
}

View File

@ -0,0 +1,17 @@
-- CreateTable
CREATE TABLE "User" (
"id" INTEGER NOT NULL PRIMARY KEY AUTOINCREMENT,
"username" TEXT NOT NULL,
"password" TEXT NOT NULL
);
-- CreateTable
CREATE TABLE "Task" (
"id" INTEGER NOT NULL PRIMARY KEY AUTOINCREMENT,
"title" TEXT NOT NULL,
"userId" INTEGER NOT NULL,
CONSTRAINT "Task_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User" ("id") ON DELETE RESTRICT ON UPDATE CASCADE
);
-- CreateIndex
CREATE UNIQUE INDEX "User_username_key" ON "User"("username");

View File

@ -0,0 +1,3 @@
# Please do not edit this file manually
# It should be added in your version-control system (i.e. Git)
provider = "sqlite"

View File

@ -0,0 +1,3 @@
# Ignore editor tmp files
**/*~
**/#*#

View File

@ -0,0 +1,36 @@
import "./Main.css";
import React from "react";
import { Link, useParams } from "react-router-dom";
import { tasks as tasksCrud } from "@wasp/crud/tasks";
const DetailPage = () => {
const { id } = useParams<{ id: string }>();
const { data: task, isLoading } = tasksCrud.get.useQuery({
id: parseInt(id, 10),
});
return (
<div className="container">
<main>
<h1>Tasks master</h1>
<div className="tasks">
{isLoading && <div>Loading...</div>}
{task && (
<div key={task.id} className="task">
<>
<div className="task__title">
{JSON.stringify(task, null, 2)}
</div>
</>
</div>
)}
</div>
<Link to="/">Return</Link>
</main>
</div>
);
};
export default DetailPage;

View File

@ -0,0 +1,12 @@
import { LoginForm } from "@wasp/auth/forms/Login";
export const LoginPage = () => {
return (
<div className="container">
<main>
<h1>Login</h1>
<LoginForm />
</main>
</div>
);
};

View File

@ -0,0 +1,44 @@
* {
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
box-sizing: border-box;
}
body {
margin: 0;
padding: 0;
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", "Roboto", "Oxygen",
"Ubuntu", "Cantarell", "Fira Sans", "Droid Sans", "Helvetica Neue",
sans-serif;
}
.container {
min-height: 100vh;
display: flex;
flex-direction: column;
justify-content: center;
align-items: center;
}
main {
padding: 5rem 0;
flex: 1;
display: flex;
flex-direction: column;
justify-content: center;
align-items: center;
}
.task {
background: #eee;
border-radius: 4px;
padding: 0.5rem 1rem;
}
.task + .task {
margin-top: 0.5rem;
}
.error {
color: red;
}

View File

@ -0,0 +1,121 @@
import "./Main.css";
import React, { useState } from "react";
import { Link } from "react-router-dom";
import { tasks as tasksCrud } from "@wasp/crud/tasks";
import { User, Task } from "@wasp/entities";
const MainPage = ({ user }: { user: User }) => {
const { data: tasks, isLoading } = tasksCrud.getAll.useQuery();
const createTask = tasksCrud.create.useAction();
const deleteTask = tasksCrud.delete.useAction();
const updateTask = tasksCrud.update.useAction();
const [newTaskTitle, setNewTaskTitle] = useState("");
const [editTaskTitle, setEditTaskTitle] = useState("");
const [error, setError] = useState("");
const [isEditing, setIsEditing] = useState<number | null>(null);
async function handleCreateTask(e: React.FormEvent) {
setError("");
e.preventDefault();
try {
await createTask({
title: newTaskTitle,
});
} catch (err: unknown) {
setError(`Error creating task: ${err as any}`);
}
setNewTaskTitle("");
}
async function handleUpdateTask(e: React.FormEvent) {
setError("");
e.preventDefault();
try {
await updateTask({ id: isEditing!, title: editTaskTitle });
} catch (err: unknown) {
setError("Error updating task.");
}
setIsEditing(null);
setEditTaskTitle("");
}
function handleStartEditing(task: Task) {
setIsEditing(task.id);
setEditTaskTitle(task.title);
}
async function handleTaskDelete(task: { id: number }) {
try {
if (!confirm("Are you sure you want to delete this task?")) {
return;
}
await deleteTask({ id: task.id });
} catch (err: unknown) {
setError("Error deleting task.");
}
}
return (
<div className="container">
<main>
<h1>Tasks master</h1>
<div className="error">{error}</div>
<div className="tasks">
{isLoading && <div>Loading...</div>}
{tasks?.map((task) => (
<div key={task.id} className="task">
{task.id === isEditing ? (
<>
<form className="new-task-form">
<label htmlFor="title">Title</label>
<input
type="text"
required
value={editTaskTitle}
onChange={(e) => setEditTaskTitle(e.target.value)}
/>
<button type="submit" onClick={handleUpdateTask}>
Update task
</button>
</form>
</>
) : (
<>
<div className="task__title">
<Link to={`/${task.id}`}>
{JSON.stringify(task, null, 2)}
</Link>
</div>
<button onClick={() => handleTaskDelete(task)}>Delete</button>
<a onClick={() => handleStartEditing(task)}>
<i>Edit</i>
</a>
</>
)}
</div>
))}
{tasks?.length === 0 && <div>No tasks yet.</div>}
</div>
<form className="new-task-form">
<label htmlFor="title">Title</label>
<input
type="text"
required
value={newTaskTitle}
onChange={(e) => setNewTaskTitle(e.target.value)}
/>
<button type="submit" onClick={handleCreateTask}>
Create task
</button>
</form>
</main>
</div>
);
};
export default MainPage;

View File

@ -0,0 +1,12 @@
import { SignupForm } from "@wasp/auth/forms/Signup";
export const SignupPage = () => {
return (
<div className="container">
<main>
<h1>Signup</h1>
<SignupForm />
</main>
</div>
);
};

View File

@ -0,0 +1,55 @@
// =============================== IMPORTANT =================================
//
// This file is only used for Wasp IDE support. You can change it to configure
// your IDE checks, but none of these options will affect the TypeScript
// compiler. Proper TS compiler configuration in Wasp is coming soon :)
{
"compilerOptions": {
// JSX support
"jsx": "preserve",
"strict": true,
// Allow default imports.
"esModuleInterop": true,
"lib": [
"dom",
"dom.iterable",
"esnext"
],
"allowJs": true,
// Wasp needs the following settings enable IDE support in your source
// files. Editing them might break features like import autocompletion and
// definition lookup. Don't change them unless you know what you're doing.
//
// The relative path to the generated web app's root directory. This must be
// set to define the "paths" option.
"baseUrl": "../../.wasp/out/web-app/",
"paths": {
// Resolve all "@wasp" imports to the generated source code.
"@wasp/*": [
"src/*"
],
// Resolve all non-relative imports to the correct node module. Source:
// https://www.typescriptlang.org/docs/handbook/module-resolution.html#path-mapping
"*": [
// Start by looking for the definiton inside the node modules root
// directory...
"node_modules/*",
// ... If that fails, try to find it inside definitely-typed type
// definitions.
"node_modules/@types/*"
]
},
// Correctly resolve types: https://www.typescriptlang.org/tsconfig#typeRoots
"typeRoots": [
"../../.wasp/out/web-app/node_modules/@types"
],
// Since this TS config is used only for IDE support and not for
// compilation, the following directory doesn't exist. We need to specify
// it to prevent this error:
// https://stackoverflow.com/questions/42609768/typescript-error-cannot-write-file-because-it-would-overwrite-input-file
"outDir": "phantom"
},
"exclude": [
"phantom"
],
}

View File

@ -0,0 +1 @@
/// <reference types="../../.wasp/out/web-app/node_modules/vite/client" />

Binary file not shown.

After

Width:  |  Height:  |  Size: 24 KiB

View File

@ -0,0 +1,52 @@
import HttpError from "@wasp/core/HttpError.js";
import type { GetQuery, GetAllQuery, CreateAction } from "@wasp/crud/tasks";
import { Task, User } from "@wasp/entities";
export const getTask = (async (args, context) => {
return context.entities.Task.findUnique({
where: { id: args.id },
include: {
user: { select: { username: true } },
},
});
}) satisfies GetQuery<
{ id: Task["id"] },
| (Task & {
user: Pick<User, "username">;
})
| null
>;
export const getAllTasks = (async (args, context) => {
return context.entities.Task.findMany({
orderBy: { id: "desc" },
select: {
id: true,
title: true,
user: {
select: {
username: true,
},
},
},
});
}) satisfies GetAllQuery<{}, {}>;
export const createTask = (async (args, context) => {
if (!context.user) {
throw new HttpError(401, "You must be logged in to create a task.");
}
if (!args.title) {
throw new HttpError(400, "Task title is required.");
}
return context.entities.Task.create({
data: {
title: args.title!,
user: {
connect: {
id: context.user.id,
},
},
},
});
}) satisfies CreateAction<{ title: Task["title"] }, Task>;

View File

@ -0,0 +1,48 @@
// =============================== IMPORTANT =================================
//
// This file is only used for Wasp IDE support. You can change it to configure
// your IDE checks, but none of these options will affect the TypeScript
// compiler. Proper TS compiler configuration in Wasp is coming soon :)
{
"compilerOptions": {
// Allows default imports.
"esModuleInterop": true,
"allowJs": true,
"strict": true,
// Wasp needs the following settings enable IDE support in your source
// files. Editing them might break features like import autocompletion and
// definition lookup. Don't change them unless you know what you're doing.
//
// The relative path to the generated web app's root directory. This must be
// set to define the "paths" option.
"baseUrl": "../../.wasp/out/server/",
"paths": {
// Resolve all "@wasp" imports to the generated source code.
"@wasp/*": [
"src/*"
],
// Resolve all non-relative imports to the correct node module. Source:
// https://www.typescriptlang.org/docs/handbook/module-resolution.html#path-mapping
"*": [
// Start by looking for the definiton inside the node modules root
// directory...
"node_modules/*",
// ... If that fails, try to find it inside definitely-typed type
// definitions.
"node_modules/@types/*"
]
},
// Correctly resolve types: https://www.typescriptlang.org/tsconfig#typeRoots
"typeRoots": [
"../../.wasp/out/server/node_modules/@types"
],
// Since this TS config is used only for IDE support and not for
// compilation, the following directory doesn't exist. We need to specify
// it to prevent this error:
// https://stackoverflow.com/questions/42609768/typescript-error-cannot-write-file-because-it-would-overwrite-input-file
"outDir": "phantom",
},
"exclude": [
"phantom"
],
}

View File

@ -0,0 +1,28 @@
{
"compilerOptions": {
// Enable default imports in TypeScript.
"esModuleInterop": true,
"allowJs": true,
// The following settings enable IDE support in user-provided source files.
// Editing them might break features like import autocompletion and
// definition lookup. Don't change them unless you know what you're doing.
//
// The relative path to the generated web app's root directory. This must be
// set to define the "paths" option.
"baseUrl": "../../.wasp/out/server/",
"paths": {
// Resolve all non-relative imports to the correct node module. Source:
// https://www.typescriptlang.org/docs/handbook/module-resolution.html#path-mapping
"*": [
// Start by looking for the definiton inside the node modules root
// directory...
"node_modules/*",
// ... If that fails, try to find it inside definitely-typed type
// definitions.
"node_modules/@types/*"
]
},
// Correctly resolve types: https://www.typescriptlang.org/tsconfig#typeRoots
"typeRoots": ["../../.wasp/out/server/node_modules/@types"]
}
}

View File

@ -16,6 +16,7 @@ import Wasp.AppSpec.ApiNamespace (ApiNamespace)
import Wasp.AppSpec.App (App)
import Wasp.AppSpec.App.Db (DbSystem)
import Wasp.AppSpec.App.EmailSender (EmailProvider)
import Wasp.AppSpec.Crud (Crud)
import Wasp.AppSpec.Entity (Entity)
import Wasp.AppSpec.Job (Job, JobExecutor)
import Wasp.AppSpec.Page (Page)
@ -33,6 +34,7 @@ makeDeclType ''Job
makeEnumType ''HttpMethod
makeDeclType ''Api
makeDeclType ''ApiNamespace
makeDeclType ''Crud
makeDeclType ''App
{- ORMOLU_DISABLE -}
@ -54,5 +56,6 @@ stdTypes =
TD.addDeclType @Api $
TD.addDeclType @ApiNamespace $
TD.addEnumType @EmailProvider $
TD.addDeclType @Crud $
TD.empty
{- ORMOLU_ENABLE -}

View File

@ -19,6 +19,7 @@ module Wasp.AppSpec
asAbsWaspProjectDirFile,
getApp,
getApiNamespaces,
getCruds,
)
where
@ -33,6 +34,7 @@ import Wasp.AppSpec.App (App)
import Wasp.AppSpec.ConfigFile (ConfigFileRelocator (..))
import Wasp.AppSpec.Core.Decl (Decl, IsDecl, takeDecls)
import Wasp.AppSpec.Core.Ref (Ref, refName)
import Wasp.AppSpec.Crud (Crud)
import Wasp.AppSpec.Entity (Entity)
import qualified Wasp.AppSpec.ExternalCode as ExternalCode
import Wasp.AppSpec.Job (Job)
@ -96,6 +98,9 @@ getApis = getDecls
getApiNamespaces :: AppSpec -> [(String, ApiNamespace)]
getApiNamespaces = getDecls
getCruds :: AppSpec -> [(String, Crud)]
getCruds = getDecls
getEntities :: AppSpec -> [(String, Entity)]
getEntities = getDecls
@ -117,10 +122,13 @@ resolveRef :: (IsDecl d) => AppSpec -> Ref d -> (String, d)
resolveRef spec ref =
fromMaybe
( error $
"Failed to resolve declaration reference: " ++ refName ref ++ "."
"Failed to resolve declaration reference: "
++ refName ref
++ "."
++ " This should never happen, as Analyzer should ensure all references in AppSpec are valid."
)
$ find ((== refName ref) . fst) $ getDecls spec
$ find ((== refName ref) . fst) $
getDecls spec
doesConfigFileExist :: AppSpec -> Path' (Rel WaspProjectDir) File' -> Bool
doesConfigFileExist spec file =

View File

@ -0,0 +1,41 @@
{-# LANGUAGE DeriveDataTypeable #-}
module Wasp.AppSpec.Crud
( Crud (..),
CrudOperations (..),
CrudOperation (..),
CrudOperationOptions (..),
)
where
import Data.Data (Data)
import Wasp.AppSpec.Core.Decl (IsDecl)
import Wasp.AppSpec.Core.Ref (Ref)
import Wasp.AppSpec.Entity (Entity)
import Wasp.AppSpec.ExtImport (ExtImport)
data Crud = Crud
{ entity :: Ref Entity,
operations :: CrudOperations
}
deriving (Show, Eq, Data)
instance IsDecl Crud
data CrudOperations = CrudOperations
{ get :: Maybe CrudOperationOptions,
getAll :: Maybe CrudOperationOptions,
create :: Maybe CrudOperationOptions,
update :: Maybe CrudOperationOptions,
delete :: Maybe CrudOperationOptions
}
deriving (Show, Eq, Data)
data CrudOperationOptions = CrudOperationOptions
{ isPublic :: Maybe Bool,
overrideFn :: Maybe ExtImport
}
deriving (Show, Eq, Data)
data CrudOperation = Get | GetAll | Create | Update | Delete
deriving (Show, Eq, Ord, Data)

View File

@ -5,6 +5,8 @@ module Wasp.AppSpec.Entity
Entity,
getFields,
getPslModelBody,
getIdField,
getIdBlockAttribute,
)
where
@ -13,6 +15,7 @@ import Wasp.AppSpec.Core.Decl (IsDecl)
import Wasp.AppSpec.Entity.Field (Field)
import qualified Wasp.AppSpec.Entity.Field as Field
import qualified Wasp.Psl.Ast.Model as PslModel
import Wasp.Psl.Util (findIdBlockAttribute, findIdField)
data Entity = Entity
{ fields :: ![Field],
@ -38,3 +41,9 @@ getFields = fields
getPslModelBody :: Entity -> PslModel.Body
getPslModelBody = pslModelBody
getIdField :: Entity -> Maybe PslModel.Field
getIdField = findIdField . getPslModelBody
getIdBlockAttribute :: Entity -> Maybe PslModel.Attribute
getIdBlockAttribute = findIdBlockAttribute . getPslModelBody

View File

@ -6,12 +6,13 @@ module Wasp.AppSpec.Valid
getApp,
isAuthEnabled,
doesUserEntityContainField,
getIdFieldFromCrudEntity,
)
where
import Control.Monad (unless)
import Data.List (find, group, groupBy, intercalate, sort, sortBy)
import Data.Maybe (isJust)
import Data.Maybe (fromJust, isJust)
import Text.Read (readMaybe)
import Text.Regex.TDFA ((=~))
import Wasp.AppSpec (AppSpec)
@ -25,10 +26,13 @@ import qualified Wasp.AppSpec.App.Auth as Auth
import qualified Wasp.AppSpec.App.Db as AS.Db
import qualified Wasp.AppSpec.App.Wasp as Wasp
import Wasp.AppSpec.Core.Decl (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
import qualified Wasp.AppSpec.Page as Page
import Wasp.AppSpec.Util (isPgBossJobExecutorUsed)
import Wasp.Generator.Crud (crudDeclarationToOperationsList)
import qualified Wasp.Psl.Ast.Model as PslModel
import qualified Wasp.SemanticVersion as SV
import qualified Wasp.Version as WV
@ -54,7 +58,8 @@ validateAppSpec spec =
validateExternalAuthEntityHasCorrectFieldsIfExternalAuthIsUsed spec,
validateDbIsPostgresIfPgBossUsed spec,
validateApiRoutesAreUnique spec,
validateApiNamespacePathsAreUnique spec
validateApiNamespacePathsAreUnique spec,
validateCrudOperations spec
]
validateExactlyOneAppExists :: AppSpec -> Maybe ValidationError
@ -74,7 +79,8 @@ validateWaspVersion :: String -> [ValidationError]
validateWaspVersion specWaspVersionStr = eitherUnitToErrorList $ do
specWaspVersionRange <- parseWaspVersionRange specWaspVersionStr
unless (SV.isVersionInRange WV.waspVersion specWaspVersionRange) $
Left $ incompatibleVersionError WV.waspVersion specWaspVersionRange
Left $
incompatibleVersionError WV.waspVersion specWaspVersionRange
where
-- TODO: Use version range parser from SemanticVersion when it is fully implemented.
@ -250,6 +256,33 @@ validateApiNamespacePathsAreUnique spec =
namespacePaths = AS.ApiNamespace.path . snd <$> AS.getApiNamespaces spec
duplicatePaths = map head $ filter ((> 1) . length) (group . sort $ namespacePaths)
validateCrudOperations :: AppSpec -> [ValidationError]
validateCrudOperations spec =
concat
[ concatMap checkIfAtLeastOneOperationIsUsedForCrud cruds,
concatMap checkIfSimpleIdFieldIsDefinedForEntity cruds
]
where
cruds = AS.getCruds spec
checkIfAtLeastOneOperationIsUsedForCrud :: (String, AS.Crud.Crud) -> [ValidationError]
checkIfAtLeastOneOperationIsUsedForCrud (crudName, crud) =
if not . null $ crudOperations
then []
else [GenericValidationError $ "CRUD \"" ++ crudName ++ "\" must have at least one operation defined."]
where
crudOperations = crudDeclarationToOperationsList crud
checkIfSimpleIdFieldIsDefinedForEntity :: (String, AS.Crud.Crud) -> [ValidationError]
checkIfSimpleIdFieldIsDefinedForEntity (crudName, crud) = case (maybeIdField, maybeIdBlockAttribute) of
(Just _, Nothing) -> []
(Nothing, Just _) -> [GenericValidationError $ "Entity referenced by \"" ++ crudName ++ "\" CRUD declaration must have an ID field (marked with @id attribute) and not a composite ID (defined with @@id attribute)."]
_missingIdFieldWithoutBlockIdAttributeDefined -> [GenericValidationError $ "Entity referenced by \"" ++ crudName ++ "\" CRUD declaration must have an ID field (marked with @id attribute)."]
where
maybeIdField = Entity.getIdField entity
maybeIdBlockAttribute = Entity.getIdBlockAttribute entity
(_, entity) = AS.resolveRef spec (AS.Crud.entity crud)
-- | This function assumes that @AppSpec@ it operates on was validated beforehand (with @validateAppSpec@ function).
-- TODO: It would be great if we could ensure this at type level, but we decided that was too much work for now.
-- Check https://github.com/wasp-lang/wasp/pull/455 for considerations on this and analysis of different approaches.
@ -281,3 +314,10 @@ doesUserEntityContainField spec fieldName = do
let userEntity = snd $ AS.resolveRef spec (Auth.userEntity auth)
let userEntityFields = Entity.getFields userEntity
Just $ any (\field -> Entity.Field.fieldName field == fieldName) userEntityFields
-- | This function assumes that @AppSpec@ it operates on was validated beforehand (with @validateAppSpec@ function).
-- We validated that entity field exists, so we can safely use fromJust here.
getIdFieldFromCrudEntity :: AppSpec -> AS.Crud.Crud -> PslModel.Field
getIdFieldFromCrudEntity spec crud = fromJust $ Entity.getIdField crudEntity
where
crudEntity = snd $ AS.resolveRef spec (AS.Crud.entity crud)

View File

@ -0,0 +1,71 @@
{-# LANGUAGE TupleSections #-}
{-# OPTIONS_GHC -Wno-unrecognised-pragmas #-}
module Wasp.Generator.Crud
( getCrudOperationJson,
getCrudFilePath,
makeCrudOperationKeyAndJsonPair,
crudDeclarationToOperationsList,
)
where
import Data.Aeson (object, (.=))
import qualified Data.Aeson as Aeson
import qualified Data.Aeson.Types as Aeson.Types
import Data.Maybe (catMaybes, fromJust, fromMaybe)
import qualified Data.Text as T
import StrongPath (File', Path', Rel)
import qualified StrongPath as SP
import qualified Wasp.AppSpec as AS
import qualified Wasp.AppSpec.Crud as AS.Crud
import Wasp.Generator.Common (makeJsArrayFromHaskellList)
import qualified Wasp.Generator.Crud.Routes as Routes
import qualified Wasp.Psl.Ast.Model as PslModel
import qualified Wasp.Util as Util
getCrudOperationJson :: String -> AS.Crud.Crud -> PslModel.Field -> Aeson.Value
getCrudOperationJson crudOperationName crud idField =
object
[ "name" .= crudOperationName,
"operations" .= object (map getDataForOperation crudOperations),
"entityUpper" .= crudEntityName,
"entityLower" .= Util.toLowerFirst crudEntityName,
"entitiesArray" .= makeJsArrayFromHaskellList [crudEntityName],
"idFieldName" .= PslModel._name idField
]
where
crudEntityName = AS.refName $ AS.Crud.entity crud
crudOperations = crudDeclarationToOperationsList crud
getDataForOperation :: (AS.Crud.CrudOperation, AS.Crud.CrudOperationOptions) -> Aeson.Types.Pair
getDataForOperation (operation, options) =
makeCrudOperationKeyAndJsonPair
operation
( object
[ "route" .= Routes.getRoute operation,
"fullPath" .= Routes.makeFullPath crudOperationName operation,
"isPublic" .= fromMaybe False (AS.Crud.isPublic options)
]
)
getCrudFilePath :: String -> String -> Path' (Rel r) File'
getCrudFilePath crudName ext = fromJust (SP.parseRelFile (crudName ++ "." ++ ext))
crudDeclarationToOperationsList :: AS.Crud.Crud -> [(AS.Crud.CrudOperation, AS.Crud.CrudOperationOptions)]
crudDeclarationToOperationsList crud =
catMaybes
[ fmap (AS.Crud.Get,) (AS.Crud.get $ AS.Crud.operations crud),
fmap (AS.Crud.GetAll,) (AS.Crud.getAll $ AS.Crud.operations crud),
fmap (AS.Crud.Create,) (AS.Crud.create $ AS.Crud.operations crud),
fmap (AS.Crud.Update,) (AS.Crud.update $ AS.Crud.operations crud),
fmap (AS.Crud.Delete,) (AS.Crud.delete $ AS.Crud.operations crud)
]
-- Produces a pair of the operation name and arbitrary json value.
-- For example, for operation CrudOperation.Get and json value { "route": "get" },
-- this function will produce a pair ("Get", { "route": "get" }).
makeCrudOperationKeyAndJsonPair :: AS.Crud.CrudOperation -> Aeson.Value -> Aeson.Types.Pair
makeCrudOperationKeyAndJsonPair operation json = key .= json
where
key = T.pack . show $ operation

View File

@ -0,0 +1,18 @@
module Wasp.Generator.Crud.Routes where
import Data.List (intercalate)
import qualified Wasp.AppSpec.Crud as AS.Crud
getRoute :: AS.Crud.CrudOperation -> String
getRoute operation = case operation of
AS.Crud.Get -> "get"
AS.Crud.GetAll -> "get-all"
AS.Crud.Create -> "create"
AS.Crud.Update -> "update"
AS.Crud.Delete -> "delete"
makeFullPath :: String -> AS.Crud.CrudOperation -> String
makeFullPath crudOperationName crudOperation = intercalate "/" [getCrudOperationRouterRoute crudOperationName, getRoute crudOperation]
getCrudOperationRouterRoute :: String -> String
getCrudOperationRouterRoute crudOperationName = crudOperationName

View File

@ -32,7 +32,7 @@ import StrongPath
relfile,
(</>),
)
import Wasp.AppSpec (AppSpec, getApis)
import Wasp.AppSpec (AppSpec)
import qualified Wasp.AppSpec as AS
import qualified Wasp.AppSpec.App as AS.App
import qualified Wasp.AppSpec.App.Auth as AS.App.Auth
@ -56,6 +56,7 @@ import Wasp.Generator.ServerGenerator.Auth.OAuthAuthG (depsRequiredByPassport)
import Wasp.Generator.ServerGenerator.AuthG (genAuth)
import qualified Wasp.Generator.ServerGenerator.Common as C
import Wasp.Generator.ServerGenerator.ConfigG (genConfigFile)
import Wasp.Generator.ServerGenerator.CrudG (genCrud)
import Wasp.Generator.ServerGenerator.Db.Seed (genDbSeed, getPackageJsonPrismaSeedField)
import Wasp.Generator.ServerGenerator.EmailSenderG (depsRequiredByEmail, genEmailSender)
import Wasp.Generator.ServerGenerator.ExternalCodeGenerator (extServerCodeGeneratorStrategy, extSharedCodeGeneratorStrategy)
@ -89,6 +90,7 @@ genServer spec =
<++> genEnvValidationScript
<++> genExportedTypesDir spec
<++> genApis spec
<++> genCrud spec
where
genFileCopy = return . C.mkTmplFd
@ -258,18 +260,23 @@ genRoutesDir :: AppSpec -> Generator [FileDraft]
genRoutesDir spec =
-- TODO(martin): We will probably want to extract "routes" path here same as we did with "src", to avoid hardcoding,
-- but I did not bother with it yet since it is used only here for now.
return
[ C.mkTmplFdWithDstAndData
(C.asTmplFile [relfile|src/routes/index.js|])
(C.asServerFile [relfile|src/routes/index.js|])
( Just $
object
[ "operationsRouteInRootRouter" .= (operationsRouteInRootRouter :: String),
"isAuthEnabled" .= (isAuthEnabled spec :: Bool),
"areThereAnyCustomApiRoutes" .= (not . null $ getApis spec)
]
)
]
sequence [genRoutesIndex spec]
genRoutesIndex :: AppSpec -> Generator FileDraft
genRoutesIndex spec =
return $
C.mkTmplFdWithDstAndData
(C.asTmplFile [relfile|src/routes/index.js|])
(C.asServerFile [relfile|src/routes/index.js|])
(Just tmplData)
where
tmplData =
object
[ "operationsRouteInRootRouter" .= (operationsRouteInRootRouter :: String),
"isAuthEnabled" .= (isAuthEnabled spec :: Bool),
"areThereAnyCustomApiRoutes" .= (not . null $ AS.getApis spec),
"areThereAnyCrudRoutes" .= (not . null $ AS.getCruds spec)
]
genTypesAndEntitiesDirs :: AppSpec -> Generator [FileDraft]
genTypesAndEntitiesDirs spec =
@ -383,9 +390,10 @@ genExportedTypesDir spec =
genMiddleware :: AppSpec -> Generator [FileDraft]
genMiddleware spec =
return
[ C.mkTmplFd [relfile|src/middleware/index.ts|],
C.mkTmplFdWithData [relfile|src/middleware/globalMiddleware.ts|] (Just tmplData)
sequence
[ return $ C.mkTmplFd [relfile|src/middleware/index.ts|],
return $ C.mkTmplFdWithData [relfile|src/middleware/globalMiddleware.ts|] (Just tmplData),
genOperationsMiddleware spec
]
where
tmplData =
@ -403,3 +411,13 @@ genMiddleware spec =
"importStatement" .= maybe "" fst maybeGlobalMidlewareConfigFnImports,
"importAlias" .= globalMiddlewareConfigFnAlias
]
genOperationsMiddleware :: AppSpec -> Generator FileDraft
genOperationsMiddleware spec =
return $
C.mkTmplFdWithDstAndData
(C.asTmplFile [relfile|src/middleware/operations.ts|])
(C.asServerFile [relfile|src/middleware/operations.ts|])
(Just tmplData)
where
tmplData = object ["isAuthEnabled" .= (isAuthEnabled spec :: Bool)]

View File

@ -0,0 +1,117 @@
module Wasp.Generator.ServerGenerator.CrudG
( genCrud,
)
where
import Data.Aeson (object, (.=))
import qualified Data.Aeson
import qualified Data.Aeson.Types as Aeson.Types
import Data.Maybe (fromJust)
import StrongPath (reldir, reldirP, relfile, (</>))
import qualified StrongPath as SP
import Wasp.AppSpec (AppSpec)
import qualified Wasp.AppSpec as AS
import qualified Wasp.AppSpec.App as AS.App
import qualified Wasp.AppSpec.App.Auth as AS.Auth
import qualified Wasp.AppSpec.Crud as AS.Crud
import Wasp.AppSpec.Valid (getApp, getIdFieldFromCrudEntity, isAuthEnabled)
import Wasp.Generator.Crud
( crudDeclarationToOperationsList,
getCrudFilePath,
getCrudOperationJson,
makeCrudOperationKeyAndJsonPair,
)
import qualified Wasp.Generator.Crud.Routes as Routes
import Wasp.Generator.FileDraft (FileDraft)
import Wasp.Generator.Monad (Generator)
import qualified Wasp.Generator.ServerGenerator.Common as C
import Wasp.Generator.ServerGenerator.JsImport (extImportToImportJson)
import qualified Wasp.JsImport as JI
import Wasp.Util ((<++>))
genCrud :: AppSpec -> Generator [FileDraft]
genCrud spec =
if areThereAnyCruds
then
sequence [genCrudIndexRoute cruds]
<++> genCrudRoutes spec cruds
<++> genCrudOperations spec cruds
else return []
where
cruds = AS.getCruds spec
areThereAnyCruds = not . null $ cruds
genCrudIndexRoute :: [(String, AS.Crud.Crud)] -> Generator FileDraft
genCrudIndexRoute cruds = return $ C.mkTmplFdWithData tmplPath (Just tmplData)
where
tmplPath = [relfile|src/routes/crud/index.ts|]
tmplData = object ["crudRouters" .= map getCrudRouterData cruds]
getCrudRouterData :: (String, AS.Crud.Crud) -> Data.Aeson.Value
getCrudRouterData (name, _) =
object
[ "importStatement" .= importStatement,
"importIdentifier" .= importIdentifier,
"route" .= Routes.getCrudOperationRouterRoute name
]
where
(importStatement, importIdentifier) =
JI.getJsImportStmtAndIdentifier
JI.JsImport
{ JI._name = JI.JsImportField name,
JI._path = fromJust . SP.relFileToPosix $ getCrudFilePath name "js",
JI._importAlias = Nothing
}
genCrudRoutes :: AppSpec -> [(String, AS.Crud.Crud)] -> Generator [FileDraft]
genCrudRoutes spec cruds = return $ map genCrudRoute cruds
where
genCrudRoute :: (String, AS.Crud.Crud) -> FileDraft
genCrudRoute (name, crud) = C.mkTmplFdWithDstAndData tmplPath destPath (Just tmplData)
where
tmplPath = [relfile|src/routes/crud/_crud.ts|]
destPath = C.serverSrcDirInServerRootDir </> [reldir|routes/crud|] </> getCrudFilePath name "ts"
tmplData =
object
[ "crud" .= getCrudOperationJson name crud idField,
"isAuthEnabled" .= isAuthEnabled spec
]
-- We validated in analyzer that entity field exists, so we can safely use fromJust here.
idField = getIdFieldFromCrudEntity spec crud
genCrudOperations :: AppSpec -> [(String, AS.Crud.Crud)] -> Generator [FileDraft]
genCrudOperations spec cruds = return $ map genCrudOperation cruds
where
genCrudOperation :: (String, AS.Crud.Crud) -> FileDraft
genCrudOperation (name, crud) = C.mkTmplFdWithDstAndData tmplPath destPath (Just tmplData)
where
tmplPath = [relfile|src/crud/_operations.ts|]
destPath = C.serverSrcDirInServerRootDir </> [reldir|crud|] </> getCrudFilePath name "ts"
tmplData =
object
[ "crud" .= getCrudOperationJson name crud idField,
"isAuthEnabled" .= isAuthEnabled spec,
"userEntityUpper" .= maybeUserEntity,
"overrides" .= object overrides,
"queryType" .= queryTsType,
"actionType" .= actionTsType
]
idField = getIdFieldFromCrudEntity spec crud
maybeUserEntity = AS.refName . AS.Auth.userEntity <$> maybeAuth
maybeAuth = AS.App.auth $ snd $ getApp spec
queryTsType :: String
queryTsType = if isAuthEnabled spec then "AuthenticatedQuery" else "Query"
actionTsType :: String
actionTsType = if isAuthEnabled spec then "AuthenticatedAction" else "Action"
overrides :: [Aeson.Types.Pair]
overrides = map operationToOverrideImport crudOperations
crudOperations = crudDeclarationToOperationsList crud
operationToOverrideImport :: (AS.Crud.CrudOperation, AS.Crud.CrudOperationOptions) -> Aeson.Types.Pair
operationToOverrideImport (operation, options) = makeCrudOperationKeyAndJsonPair operation importJson
where
importJson = extImportToImportJson [reldirP|../|] (AS.Crud.overrideFn options)

View File

@ -7,18 +7,15 @@ module Wasp.Generator.ServerGenerator.OperationsRoutesG
where
import Data.Aeson (object, (.=))
import qualified Data.Aeson as Aeson
import Data.Maybe (fromJust, fromMaybe, isJust)
import StrongPath (Dir, File', Path, Path', Posix, Rel, reldir, reldirP, relfile, (</>))
import qualified StrongPath as SP
import Wasp.AppSpec (AppSpec)
import qualified Wasp.AppSpec as AS
import qualified Wasp.AppSpec.Action as AS.Action
import qualified Wasp.AppSpec.App as AS.App
import qualified Wasp.AppSpec.App.Auth as AS.Auth
import qualified Wasp.AppSpec.Operation as AS.Operation
import qualified Wasp.AppSpec.Query as AS.Query
import Wasp.AppSpec.Valid (getApp, isAuthEnabled)
import Wasp.AppSpec.Valid (isAuthEnabled)
import Wasp.Generator.Common (ServerRootDir)
import Wasp.Generator.FileDraft (FileDraft)
import Wasp.Generator.Monad (Generator, GeneratorError (GenericGeneratorError), logAndThrowGeneratorError)
@ -30,42 +27,37 @@ import qualified Wasp.Util as U
genOperationsRoutes :: AppSpec -> Generator [FileDraft]
genOperationsRoutes spec =
sequence . concat $
[ map (genActionRoute spec) (AS.getActions spec),
map (genQueryRoute spec) (AS.getQueries spec),
[ map genActionRoute (AS.getActions spec),
map genQueryRoute (AS.getQueries spec),
[genOperationsRouter spec]
]
genActionRoute :: AppSpec -> (String, AS.Action.Action) -> Generator FileDraft
genActionRoute spec (actionName, action) = genOperationRoute spec op tmplFile
genActionRoute :: (String, AS.Action.Action) -> Generator FileDraft
genActionRoute (actionName, action) = genOperationRoute op tmplFile
where
op = AS.Operation.ActionOp actionName action
tmplFile = C.asTmplFile [relfile|src/routes/operations/_action.js|]
genQueryRoute :: AppSpec -> (String, AS.Query.Query) -> Generator FileDraft
genQueryRoute spec (queryName, query) = genOperationRoute spec op tmplFile
genQueryRoute :: (String, AS.Query.Query) -> Generator FileDraft
genQueryRoute (queryName, query) = genOperationRoute op tmplFile
where
op = AS.Operation.QueryOp queryName query
tmplFile = C.asTmplFile [relfile|src/routes/operations/_query.js|]
genOperationRoute :: AppSpec -> AS.Operation.Operation -> Path' (Rel C.ServerTemplatesDir) File' -> Generator FileDraft
genOperationRoute spec operation tmplFile = return $ C.mkTmplFdWithDstAndData tmplFile dstFile (Just tmplData)
genOperationRoute :: AS.Operation.Operation -> Path' (Rel C.ServerTemplatesDir) File' -> Generator FileDraft
genOperationRoute operation tmplFile = return $ C.mkTmplFdWithDstAndData tmplFile dstFile (Just tmplData)
where
dstFile = operationsRoutesDirInServerRootDir </> operationRouteFileInOperationsRoutesDir operation
baseTmplData =
tmplData =
object
[ "operationName" .= (operationImportIdentifier :: String),
"operationImportStmt" .= (operationImportStmt :: String)
[ "operation"
.= object
[ "importIdentifier" .= (operationImportIdentifier :: String),
"importStatement" .= (operationImportStmt :: String)
]
]
tmplData = case AS.App.auth (snd $ getApp spec) of
Nothing -> baseTmplData
Just auth ->
U.jsonSet
"userEntityLower"
(Aeson.toJSON (U.toLowerFirst $ AS.refName $ AS.Auth.userEntity auth))
baseTmplData
pathToOperationFile =
relPosixPathFromOperationsRoutesDirToSrcDir
</> fromJust (SP.relFileToPosix $ operationFileInSrcDir operation)

View File

@ -38,6 +38,7 @@ import Wasp.Generator.Monad (Generator)
import qualified Wasp.Generator.NpmDependencies as N
import Wasp.Generator.WebAppGenerator.AuthG (genAuth)
import qualified Wasp.Generator.WebAppGenerator.Common as C
import Wasp.Generator.WebAppGenerator.CrudG (genCrud)
import Wasp.Generator.WebAppGenerator.ExternalCodeGenerator
( extClientCodeGeneratorStrategy,
extSharedCodeGeneratorStrategy,
@ -72,6 +73,7 @@ genWebApp spec = do
<++> genDotEnv spec
<++> genUniversalDir
<++> genEnvValidationScript
<++> genCrud spec
where
genFileCopy = return . C.mkTmplFd

View File

@ -0,0 +1,35 @@
module Wasp.Generator.WebAppGenerator.CrudG
( genCrud,
)
where
import Data.Maybe (fromJust)
import StrongPath (reldir, relfile, (</>))
import qualified StrongPath as SP
import Wasp.AppSpec (AppSpec, getCruds)
import qualified Wasp.AppSpec.Crud as AS.Crud
import Wasp.AppSpec.Valid (getIdFieldFromCrudEntity)
import Wasp.Generator.Crud (getCrudOperationJson)
import Wasp.Generator.FileDraft (FileDraft)
import Wasp.Generator.Monad (Generator)
import qualified Wasp.Generator.WebAppGenerator.Common as C
genCrud :: AppSpec -> Generator [FileDraft]
genCrud spec =
if areThereAnyCruds
then genCrudOperations spec cruds
else return []
where
cruds = getCruds spec
areThereAnyCruds = not $ null cruds
genCrudOperations :: AppSpec -> [(String, AS.Crud.Crud)] -> Generator [FileDraft]
genCrudOperations spec cruds = return $ map genCrudOperation cruds
where
genCrudOperation :: (String, AS.Crud.Crud) -> FileDraft
genCrudOperation (name, crud) = C.mkTmplFdWithDstAndData tmplPath destPath (Just tmplData)
where
tmplPath = [relfile|src/crud/_crud.ts|]
destPath = C.webAppSrcDirInWebAppRootDir </> [reldir|crud|] </> fromJust (SP.parseRelFile (name ++ ".ts"))
tmplData = getCrudOperationJson name crud idField
idField = getIdFieldFromCrudEntity spec crud

View File

@ -0,0 +1,28 @@
module Wasp.Psl.Util where
import Data.Foldable (find)
import qualified Wasp.Psl.Ast.Model as PslModel
findIdField :: PslModel.Body -> Maybe PslModel.Field
findIdField (PslModel.Body elements) = find isIdField fields
where
fields = [field | (PslModel.ElementField field) <- elements]
isIdField :: PslModel.Field -> Bool
isIdField PslModel.Field {_attrs = attrs} = any (\attr -> PslModel._attrName attr == attrNameAssociatedWitIdField) attrs
-- We define an ID field as a field that has the @id attribute.
attrNameAssociatedWitIdField :: String
attrNameAssociatedWitIdField = "id"
findIdBlockAttribute :: PslModel.Body -> Maybe PslModel.Attribute
findIdBlockAttribute (PslModel.Body elements) = find isIdBlockAttribute attributes
where
attributes = [attr | (PslModel.ElementBlockAttribute attr) <- elements]
isIdBlockAttribute :: PslModel.Attribute -> Bool
isIdBlockAttribute PslModel.Attribute {_attrName = attrName} = attrName == idBlockAttributeName
-- We define the ID block attribute as an attribute with the name @@id.
idBlockAttributeName :: String
idBlockAttributeName = "id"

View File

@ -0,0 +1,47 @@
module AppSpec.EntityTest where
import Test.Tasty.Hspec
import Wasp.AppSpec.Entity (getIdField)
import qualified Wasp.AppSpec.Entity as Entity
import qualified Wasp.Psl.Ast.Model as PslModel
spec_AppSpecEntityTest :: Spec
spec_AppSpecEntityTest = do
describe "getIdField" $ do
it "gets primary field from entity when it exists" $ do
getIdField entityWithIdField `shouldBe` Just idField
it "returns Nothing if primary field doesn't exist" $ do
getIdField entityWithoutIdField `shouldBe` Nothing
where
entityWithIdField =
Entity.makeEntity $
PslModel.Body
[ PslModel.ElementField idField,
PslModel.ElementField someOtherField
]
entityWithoutIdField =
Entity.makeEntity $
PslModel.Body
[ PslModel.ElementField someOtherField
]
idField =
PslModel.Field
{ PslModel._name = "id",
PslModel._type = PslModel.Int,
PslModel._attrs =
[ PslModel.Attribute
{ PslModel._attrName = "id",
PslModel._attrArgs = []
}
],
PslModel._typeModifiers = []
}
someOtherField =
PslModel.Field
{ PslModel._name = "description",
PslModel._type = PslModel.String,
PslModel._attrs = [],
PslModel._typeModifiers = []
}

View File

@ -0,0 +1,168 @@
{-# LANGUAGE PartialTypeSignatures #-}
module Generator.CrudTest where
import Data.Aeson (KeyValue ((.=)), Value, object)
import Data.Aeson.Types (Pair)
import StrongPath (relfileP)
import qualified StrongPath as SP
import Test.Tasty.Hspec
import qualified Wasp.AppSpec.Core.Ref as AS.Core.Ref
import qualified Wasp.AppSpec.Crud as AS.Crud
import qualified Wasp.AppSpec.ExtImport as AS.ExtImport
import Wasp.Generator.Crud (getCrudOperationJson)
import Wasp.Psl.Ast.Model (Field (_typeModifiers))
import qualified Wasp.Psl.Ast.Model as PslModel
spec_GeneratorCrudTest :: Spec
spec_GeneratorCrudTest = do
describe "getCrudOperationJson" $ do
it "returns empty operations list when no operations are defined" $ do
getCrudOperationJson
crudOperationsName
crudWithoutOperations
primaryEntityField
`shouldBe` mkOperationsJson []
it "adds JSON for defined operations" $ do
getCrudOperationJson
crudOperationsName
crudWithoutOperations
{ AS.Crud.operations =
AS.Crud.CrudOperations
{ get = defaultCrudOperationOptions,
getAll = defaultCrudOperationOptions,
create = defaultCrudOperationOptions,
update = Nothing,
delete = Nothing
}
}
primaryEntityField
`shouldBe` mkOperationsJson
[ "Get" .= mkOperationJson "get" "tasks/get" NotPublic,
"GetAll" .= mkOperationJson "get-all" "tasks/get-all" NotPublic,
"Create" .= mkOperationJson "create" "tasks/create" NotPublic
]
it "returns proper JSON for public operations" $ do
getCrudOperationJson
crudOperationsName
AS.Crud.Crud
{ entity = AS.Core.Ref.Ref crudOperationEntityName,
operations =
AS.Crud.CrudOperations
{ get = publicCrudOperationOptions,
getAll = privateCrudOperationOptions,
create = publicCrudOperationOptions,
update = Nothing,
delete = Nothing
}
}
primaryEntityField
`shouldBe` mkOperationsJson
[ "Get" .= mkOperationJson "get" "tasks/get" Public,
"GetAll" .= mkOperationJson "get-all" "tasks/get-all" NotPublic,
"Create" .= mkOperationJson "create" "tasks/create" Public
]
it "allows overrides of operations" $ do
getCrudOperationJson
crudOperationsName
crudWithoutOperations
{ AS.Crud.operations =
AS.Crud.CrudOperations
{ get =
Just
( AS.Crud.CrudOperationOptions
{ isPublic = Just True,
overrideFn =
Just $
AS.ExtImport.ExtImport
{ AS.ExtImport.name = AS.ExtImport.ExtImportField "getTask",
AS.ExtImport.path = SP.castRel [relfileP|bla/tasks.js|]
}
}
),
getAll = privateCrudOperationOptions,
create = publicCrudOperationOptions,
update = Nothing,
delete = Nothing
}
}
primaryEntityField
`shouldBe` mkOperationsJson
[ "Get" .= mkOperationJson "get" "tasks/get" Public,
"GetAll" .= mkOperationJson "get-all" "tasks/get-all" NotPublic,
"Create" .= mkOperationJson "create" "tasks/create" Public
]
where
crudOperationsName = "tasks"
crudOperationEntityName = "Task"
primaryEntityField =
PslModel.Field
{ PslModel._name = "id",
PslModel._type = PslModel.Int,
PslModel._attrs =
[ PslModel.Attribute
{ PslModel._attrName = "id",
PslModel._attrArgs = []
}
],
PslModel._typeModifiers = []
}
crudWithoutOperations =
AS.Crud.Crud
{ entity = AS.Core.Ref.Ref crudOperationEntityName,
operations =
AS.Crud.CrudOperations
{ get = Nothing,
getAll = Nothing,
create = Nothing,
update = Nothing,
delete = Nothing
}
}
defaultCrudOperationOptions =
Just
( AS.Crud.CrudOperationOptions
{ isPublic = Nothing,
overrideFn = Nothing
}
)
publicCrudOperationOptions =
Just
( AS.Crud.CrudOperationOptions
{ isPublic = Just True,
overrideFn = Nothing
}
)
privateCrudOperationOptions =
Just
( AS.Crud.CrudOperationOptions
{ isPublic = Just False,
overrideFn = Nothing
}
)
mkOperationsJson :: [Pair] -> Value
mkOperationsJson operations =
object
[ "name" .= crudOperationsName,
"operations" .= object operations,
"entitiesArray" .= ("['Task']" :: String),
"idFieldName" .= ("id" :: String),
"entityLower" .= ("task" :: String),
"entityUpper" .= ("Task" :: String)
]
mkOperationJson :: String -> String -> IsOperationPublic -> Value
mkOperationJson route fullPath isPublic =
object
[ "route" .= route,
"fullPath" .= fullPath,
"isPublic" .= case isPublic of
Public -> True
NotPublic -> False
]
data IsOperationPublic = Public | NotPublic

View File

@ -187,6 +187,7 @@ library
Wasp.AppSpec.Action
Wasp.AppSpec.Api
Wasp.AppSpec.ApiNamespace
Wasp.AppSpec.Crud
Wasp.AppSpec.App
Wasp.AppSpec.App.Auth
Wasp.AppSpec.App.Auth.PasswordReset
@ -252,6 +253,8 @@ library
Wasp.Generator.AuthProviders.OAuth
Wasp.Generator.AuthProviders.Local
Wasp.Generator.AuthProviders.Email
Wasp.Generator.Crud
Wasp.Generator.Crud.Routes
Wasp.Generator.ServerGenerator
Wasp.Generator.ServerGenerator.JsImport
Wasp.Generator.ServerGenerator.ApiRoutesG
@ -270,6 +273,7 @@ library
Wasp.Generator.ServerGenerator.OperationsRoutesG
Wasp.Generator.ServerGenerator.Setup
Wasp.Generator.ServerGenerator.Start
Wasp.Generator.ServerGenerator.CrudG
Wasp.Generator.Setup
Wasp.Generator.Start
Wasp.Generator.Templates
@ -290,6 +294,7 @@ library
Wasp.Generator.WebAppGenerator.Setup
Wasp.Generator.WebAppGenerator.Start
Wasp.Generator.WebAppGenerator.Test
Wasp.Generator.WebAppGenerator.CrudG
Wasp.Generator.WriteFileDrafts
Wasp.Node.Version
Wasp.Project
@ -305,6 +310,7 @@ library
Wasp.Psl.Ast.Model
Wasp.Psl.Generator.Model
Wasp.Psl.Parser.Model
Wasp.Psl.Util
Wasp.SemanticVersion
Wasp.Util
Wasp.Util.Network.Socket
@ -479,6 +485,7 @@ test-suite waspc-test
Analyzer.TypeCheckerTest
AnalyzerTest
AppSpec.ValidTest
AppSpec.EntityTest
ErrorTest
FilePath.ExtraTest
Fixtures
@ -491,6 +498,7 @@ test-suite waspc-test
Generator.WebAppGeneratorTest
Generator.WriteFileDraftsTest
Generator.JsImportTest
Generator.CrudTest
Generator.CommonTest
Psl.Common.ModelTest
Psl.Generator.ModelTest

239
web/docs/guides/crud.md Normal file
View File

@ -0,0 +1,239 @@
---
title: Automatic CRUD
---
import ImgWithCaption from '../../blog/components/ImgWithCaption'
For a specific [Entity](/docs/language/features#entity), you can tell Wasp to automatically instantiate server-side logic ([Queries](/docs/language/features#query) and [Actions](/docs/language/features#action)) for creating, reading, updating and deleting such entities.
## Defining new CRUD operations
Let's say we have a `Task` entity. We want to have `getAll` and `get` queries for it, and also `create` and `update` actions. We do this by creating new `crud` declaration in Wasp, named `Tasks`, that uses default implementation for `getAll`, `get` and `update`, while specifying a custom implementation for `create`. We also configured `getAll` to be publicly available (no auth needed).
```wasp title="main.wasp"
crud Tasks { // crud name here is "Tasks"
entity: Task,
operations: {
getAll: {
isPublic: true, // optional, defaults to false
},
get: {},
create: {
overrideFn: import { createTask } from "@server/tasks.js", // optional
},
update: {},
},
}
```
Result of this is that the queries and actions we just specified are now available in our Wasp app!
## Example: simple tasks app
We'll see an example app with auth and CRUD operations for some `Task` entity.
<ImgWithCaption alt="Automatic CRUD with Wasp" source="img/crud-guide.gif" caption="We are building a simple tasks app with username based auth"/>
### Creating the app
We can start by running `wasp new tasksCrudApp` and then we'll add the following to our `main.wasp` file:
```wasp title="main.wasp"
app tasksCrudApp {
wasp: {
version: "^0.11.0"
},
title: "Tasks Crud App",
// We enabled auth and set the auth method to username and password
auth: {
userEntity: User,
methods: {
usernameAndPassword: {},
},
onAuthFailedRedirectTo: "/login",
},
}
entity User {=psl
id Int @id @default(autoincrement())
username String @unique
password String
tasks Task[]
psl=}
// We defined a Task entity on which we'll enable CRUD later on
entity Task {=psl
id Int @id @default(autoincrement())
description String
isDone Boolean
userId Int
user User @relation(fields: [userId], references: [id])
psl=}
// Tasks app routes
route RootRoute { path: "/", to: MainPage }
page MainPage {
component: import Main from "@client/MainPage.tsx",
authRequired: true,
}
route LoginRoute { path: "/login", to: LoginPage }
page LoginPage {
component: import { LoginPage } from "@client/LoginPage.tsx",
}
route SignupRoute { path: "/signup", to: SignupPage }
page SignupPage {
component: import { SignupPage } from "@client/SignupPage.tsx",
}
```
We can then run `wasp db migrate-dev` to create the database and run the migrations.
### Adding CRUD to the `Task` entity ✨
We add the following to our Wasp file to enable automatic CRUD for `Task`:
```wasp title="main.wasp"
// ...
crud Tasks {
entity: Task,
operations: {
getAll: {},
create: {
overrideFn: import { createTask } from "@server/tasks.js",
},
},
}
```
You'll notice that we enabled only `getAll` and `create` operations. This means that only these operations will be available. We also overrode the `create` operation with a custom implementation. This means that the `create` operation will not be generated, but instead, the `createTask` function from `@server/tasks.js` will be used.
### Implementing the `create` operation
We have the following implementation in `src/server/tasks.js`:
```ts title="src/server/tasks.ts"
import type { CreateAction } from '@wasp/crud/Tasks'
import type { Task } from '@wasp/entities'
import HttpError from '@wasp/core/HttpError.js';
export const createTask: CreateAction<
{ description: string; isDone: boolean },
Task,
> = async (args, context) => {
if (!context.user) {
throw new HttpError(401, 'User not authenticated.')
}
const { description, isDone } = args
const { Task } = context.entities
return await Task.create({
data: {
description,
isDone,
// Connect the task to the user that is creating it
user: {
connect: {
id: context.user.id,
},
},
},
})
}
```
We made a custom `create` operation because we want to make sure that the task is connected to the user that is creating it. By default, the `create` operation would not do that. Read more about the [default implementations](/docs/language/features#default-crud-operations-implementations).
### Using the generated CRUD operations
And let's use the generated operations in our client code:
```jsx title="pages/MainPage.jsx"
import { Tasks } from "@wasp/crud/Tasks";
import { useState } from "react";
// Default CSS that comes with Wasp for the main page
import "./Main.css";
export const MainPage = () => {
const { data: tasks, isLoading, error } = Tasks.getAll.useQuery();
const createAction = Tasks.create.useAction();
const [taskDescription, setTaskDescription] = useState("");
function handleCreateTask() {
createAction({ description: taskDescription, isDone: false });
setTaskDescription("");
}
if (isLoading) return <div>Loading...</div>;
if (error) return <div>Error: {error.message}</div>;
return (
<div className="container">
<main>
<div>
<input
value={taskDescription}
onChange={(e) => setTaskDescription(e.target.value)}
/>
<button onClick={handleCreateTask}>Create task</button>
</div>
<ul>
{tasks.map((task) => (
<li key={task.id}>{task.description}</li>
))}
</ul>
</main>
</div>
);
};
```
And here are the login and signup pages:
```jsx title="src/client/LoginPage.jsx"
import { LoginForm } from '@wasp/auth/forms/Login'
import { Link } from "react-router-dom"
export function LoginPage() {
return (
<div>
<h1>Login</h1>
<LoginForm />
<div>
<Link to="/signup">Create an account</Link>
</div>
</div>
)
}
```
```jsx title="src/client/SignupPage.jsx"
import { SignupForm } from '@wasp/auth/forms/Signup'
export function SignupPage() {
return (
<div>
<h1>Signup</h1>
<SignupForm />
</div>
)
}
```
That's it. You can now run `wasp start` and see the app in action.
You should see a login page and a signup page. After you log in, you should see a page with a list of tasks and a form to create new tasks.
### Future of CRUD operations in Wasp
CRUD operations currently have a limited set of knowledge about the business logic they are implementing. For example, they don't know that a task should be connected to the user that is creating it. This is why we had to override the `create` operation in the example above.
Another thing, they are not aware of the authorization rules. For example, they don't know that a user should not be able to create a task for another user. In the future, we will be adding role-based authorization to Wasp, and we plan to make CRUD operations aware of the authorization rules.
Another issue is input validation and sanitization. For example, we might want to make sure that the task description is not empty.
To conclude, CRUD operations are a mechanism for getting a backend up and running quickly, but it depends on the information it can get from the Wasp app. The more information that it can pick up from your app, the more powerful it will be out of the box. We plan on supporting CRUD operations and growing them to become the easiest way to create your backend.

View File

@ -602,13 +602,178 @@ try {
}
```
### CRUD operations on top of entities
:::caution Early preview
This feature is currently in early preview. It doesn't contain all the planned features.
In the future iterations of Wasp we plan on supporting:
- **authorization** that will allow you to specify which users can perform which operations
- **validation** of input data (e.g. using Zod schema validation)
:::
For a specific [Entity](/docs/language/features#entity), you can tell Wasp to automatically instantiate server-side logic ([Queries](/docs/language/features#query) and [Actions](/docs/language/features#action)) for creating, reading, updating and deleting such entities.
#### Which operations are supported?
If we create CRUD operations for an entity named `Task`,
```wasp title="main.wasp"
crud Tasks { // crud name here is "Tasks"
entity: Task,
operations: {
getAll: {
isPublic: true, // optional, defaults to false
},
get: {},
create: {
overrideFn: import { createTask } from "@server/tasks.js", // optional
},
update: {},
},
}
```
Wasp will give you the following default implementations:
**getAll** - returns all entities
```js
// ...
// If the operation is not public, Wasp checks if an authenticated user
// is making the request.
return Task.findMany()
```
**get** - returns one entity by id field
```js
// ...
// Wasp uses the field marked with `@id` in Prisma schema as the id field.
return Task.findUnique({ where: { id: args.id } })
```
**create** - creates a new entity
```js
// ...
return Task.create({ data: args.data })
```
**update** - updates an existing entity
```js
// ...
// Wasp uses the field marked with `@id` in Prisma schema as the id field.
return Task.update({ where: { id: args.id }, data: args.data })
```
**delete** - deletes an existing entity
```js
// ...
// Wasp uses the field marked with `@id` in Prisma schema as the id field.
return Task.delete({ where: { id: args.id } })
```
:::info Current Limitations
In the default `create` and `update` implementations, we are saving all of the data that the client sends to the server. This is not always desirable, i.e. in the case when the client should not be able to modify all of the data in the entity.
[In the future](#/docs/guides/crud#future-of-crud-operations-in-wasp), we are planning to add validation of action input, where only the data that the user is allowed to change will be saved.
For now, the solution is to provide an override function. You can override the default implementation by using the `overrideFn` option and implementing the validation logic yourself.
:::
#### CRUD declaration
The CRUD declaration works on top of an existing entity declaration. It is declared as follows:
```wasp title="main.wasp"
crud Tasks { // crud name here is "Tasks"
entity: Task,
operations: {
getAll: {
isPublic: true, // optional, defaults to false
},
get: {},
create: {
overrideFn: import { createTask } from "@server/tasks.js", // optional
},
update: {},
},
}
```
It has the following fields:
- `entity: Entity` - the entity to which the CRUD operations will be applied.
- `operations: { [operationName]: CrudOperationOptions }` - the operations to be generated. The key is the name of the operation, and the value is the operation configuration.
- The possible values for `operationName` are:
- `getAll`
- `get`
- `create`
- `update`
- `delete`
- `CrudOperationOptions` can have the following fields:
- `isPublic: bool` - Whether the operation is public or not. If it is public, no auth is required to access it. If it is not public, it will be available only to authenticated users. Defaults to `false`.
- `overrideFn: ServerImport` - The import statement of the optional override implementation in Node.js.
#### Defining the overrides
Like with actions and queries, you can define the implementation in a Javascript/Typescript file. The overrides are functions that take the following arguments:
- `args` - The arguments of the operation i.e. the data that's sent from the client.
- `context` - Context contains the `user` making the request and the `entities` object containing the entity that's being operated on.
You can also import types for each of the functions you want to override from `@wasp/crud/{crud name}`. The available types are:
- `GetAllQuery`
- `GetQuery`
- `CreateAction`
- `UpdateAction`
- `DeleteAction`
If you have a CRUD named `Tasks`, you would import the types like this:
```ts
import type { GetAllQuery, GetQuery, CreateAction, UpdateAction, DeleteAction } from '@wasp/crud/Tasks'
// Each of the types is a generic type, so you can use it like this:
export const getAllOverride: GetAllQuery<Input, Output> = async (args, context) => {
// ...
}
```
We are showing an example of an override in the [CRUD guide](/docs/guides/crud).
#### Using the CRUD operations in client code
On the client, you import the CRUD operations from `@wasp/crud/{crud name}`. The names of the imports are the same as the names of the operations. For example, if you have a CRUD called `Tasks`, you would import the operations like this:
```jsx title="SomePage.jsx"
import { Tasks } from '@wasp/crud/Tasks'
```
You can then access the operations like this:
```jsx title="SomePage.jsx"
const { data } = Tasks.getAll.useQuery()
const { data } = Tasks.get.useQuery({ id: 1 })
const createAction = Tasks.create.useAction()
const updateAction = Tasks.update.useAction()
const deleteAction = Tasks.delete.useAction()
// The CRUD operations are using the existing actions and queries
// under the hood, so all the options are available as before.
```
Check out the [CRUD guide](/docs/guides/crud) to see how to use the CRUD operations in client code.
## APIs
In Wasp, the default client-server interaction mechanism is through [Operations](#queries-and-actions-aka-operations). However, if you need a specific URL method/path, or a specific response, Operations may not be suitable for you. For these cases, you can use an `api`! Best of all, they should look and feel very familiar.
### API
APIs are used to tie a JS function to an HTTP (method, path) pair. They are distinct from Operations, and have no client-side helpers (like `useQuery`).
APIs are used to tie a JS function to an HTTP (method, path) pair. They are distinct from Operations and have no client-side helpers (like `useQuery`).
To create a Wasp API, you must:
1. Define the APIs NodeJS implementation
@ -731,7 +896,7 @@ export const fooBar : FooBar = (req, res, context) => {
The object `context.entities.Task` exposes `prisma.task` from [Prisma's CRUD API](https://www.prisma.io/docs/reference/tools-and-interfaces/prisma-client/crud).
### `apiNamespace`
### apiNamespace
An `apiNamespace` is a simple declaration used to apply some `middlewareConfigFn` to all APIs under some specific path. For example:

View File

@ -74,9 +74,11 @@ While fundamental types are here to be basic building blocks of a language, and
- **page**
- **query**
- **route**
- **crud**
- Enum types
- **DbSystem**
- **HttpMethod**
- **JobExecutor**
- **EmailProvider**
For more details about each of the domain types, both regarding their body types and what they mean, check the [Features](/language/features.md) section.

View File

@ -390,3 +390,32 @@ and use it to type your seed function like this:
```ts
export const devSeedSimple: DbSeedFn = async (prismaClient) => { ... }
```
## CRUD operations on entities
For a specific [Entity](/docs/language/features#entity), you can tell Wasp to automatically instantiate server-side logic ([Queries](/docs/language/features#query) and [Actions](/docs/language/features#action)) for creating, reading, updating and deleting such entities.
Read more about CRUD operations in Wasp [here](/docs/language/features#crud-operations).
### Using types for CRUD operations overrides
If you writing the override implementation in Typescript, you'll have access to generated types. The overrides are functions that take the following arguments:
- `args` - The arguments of the operation i.e. the data that's sent from the client.
- `context` - Context containing the `user` making the request and the `entities` object containing the entity that's being operated on.
You can types for each of the functions you want to override from `@wasp/crud/{crud name}`. The types that are available are:
- `GetAllQuery`
- `GetQuery`
- `CreateAction`
- `UpdateAction`
- `DeleteAction`
If you have a CRUD named `Tasks`, you would import the types like this:
```ts
import type { GetAllQuery, GetQuery, CreateAction, UpdateAction, DeleteAction } from '@wasp/crud/Tasks'
// Each of the types is a generic type, so you can use it like this:
export const getAllOverride: GetAllQuery<Input, Output> = async (args, context) => {
// ...
}
```

View File

@ -62,6 +62,7 @@ module.exports = {
"guides/testing",
"guides/sending-emails",
"guides/middleware-customization",
"guides/crud",
],
},
{

View File

@ -57,7 +57,7 @@ module.exports = (Prism) => {
alias: "plain",
},
"declaration-type": {
pattern: /\b(action|apiNamespace|api|app|entity|job|page|query|route)\b/,
pattern: /\b(action|apiNamespace|api|app|entity|job|page|query|route|crud)\b/,
alias: "keyword",
},
"class-name": {

Binary file not shown.

After

Width:  |  Height:  |  Size: 216 KiB