Code generator CRUD

Compas code-gen also supports generating CRUD routes. It combines the features of the api generator, sql generator and a generated implementation of the necessary events.

TIP

Requires @compas/cli, @compas/stdlib, @compas/store and @compas/code-gen to be installed.

The features

CRUD generation supports quite a variety of features and combinations there of. Let's break them all down;

Route selection:

Any CRUD declaration fully controls which routes are generated, by explicitly enabling them.

const T = new TypeCreator("tag");
const Tdatabase = new TypeCreator("database");

Tdatabase.object("tag")
  .keys({
    key: T.string().searchable().trim().min(3),
    value: T.string(),
  })
  .enableQueries({
    withDates: true,
  });

T.crud("/tag").entity(T.reference("database", "tag")).routes({
  listRoute: true,
  singleRoute: true,
  createRoute: true,
  updateRoute: true,
  deleteRoute: true,
});

Combining all options, it generates the following routes;

  • apiTagList / /tag/list
  • apiTagSingle / /tag/:tagId/single
  • apiTagCreate / /tag/create
  • apiTagUpdate / /tag/:tagId/update
  • apiTagDelete / /tag/:tagId/delete

Filters, sorting and pagination:

The generated list route comes fully equipped with filters, sorting and pagination. The filters are a subset of the filters as supported by the query builder, ie ignoring $raw and $or to prevent SQL injection and too complex filters respectively. Sorting is also based on the current query builder behaviour where you specify the columns to be sorted on in orderBy and a seperated orderBySpec to determine the sort order for that column. And finally pagination is supported via the offset and limit params.

Inline relations

The CRUD generator can also include inline relations in the response, but also allow creating and updating them via their respective routes. This works for both oneToMany relations as for the referenced side of an oneToOne relation. These inline relations can be added via .inlineRelations() like the following;

const T = new TypeCreator("post");
const Tdatabase = new TypeCreator("database");

Tdatabase.object("post")
  .keys({
    title: T.string().searchable(),
  })
  .enableQueries({
    withDates: true,
  })
  .relations(T.oneToMany("tags", T.reference("database", "tag")));

Tdatabase.object("tag")
  .keys({
    key: T.string().searchable().trim().min(3),
    value: T.string(),
  })
  .enableQueries({
    withDates: true,
  })
  .relations(T.manyToOne("post", T.reference("database", "post"), "tags"));

T.crud("/post")
  .entity(T.reference("database", "post"))
  .routes({
    listRoute: true,
    createRoute: true,
    updateRoute: true,
  })
  .inlineRelations(T.crud().fromParent("tags", { name: "tag" }));

The above generates approximately the following type for both the read routes, like apiPostList, as well as for the write routes like apiPostCrete and apiPostUpdate.

type PostItem = {
  id: string;
  title: string,
  tags: { id: string, key: string, value: string }[]
}

When updating an inline relation, all existing values are first removed, before the new values are added. oneToOne relations are mandatory by default, but can be made optional via T.crud().fromParent(...).optional(). Inline relations can be nested as many times as required.

Nested relations

The same thing as above can be done but now with .nestedRelations. This creates a nested route structure.

// Using the same Post -> tags[] relation like above

T.crud("/post")
  .entity(T.reference("database", "post"))
  .routes({
    listRoute: true,
    singleRoute: true,
    createRoute: true,
    updateRoute: true,
    deleteRoute: true,
  })
  .nestedRelations(
    T.crud("/tag").fromParent("tags", { name: "tag" }).routes({
      // Routes need to be enabled
      listRoute: true,
      singleRoute: true,
      createRoute: true,
      updateRoute: true,
      deleteRoute: true,
    }),
  );

This generates the following routes:

  • apiPostList / /post/list
  • apiPostSingle / /post/:postId/single
  • apiPostCreate / /post/create
  • apiPostUpdate / /post/:postId/update
  • apiPostDelete / /post/:postId/delete
  • apiPostTagList / /post/:postId/taglist
  • apiPostTagSingle / /post/:postId/tag/:tagId/single
  • apiPostTagCreate / /post/:postId/tag/create
  • apiPostTagUpdate / /post/:postId/tag/:tagId/update
  • apiPostTagDelete / /post/:postId/tag/:tagId/delete

Appropriate route invalidations for react-query generator are automatically added in all cases. In case a nested relation is used with a oneToOne relation, the list route is automatically disabled, and the extra route params are removed. So /post/:postId/author/:authorId/single is shortened to /post/:postId/author/single.

Modifiers

While calling groupRegisterCrud from the generated crud.js you can pass in various 'modifiers'. These modifiers are all optional and can mutate the passed in context, resolve a user, determine access control and edit the provided 'builders'. They are called after the static validation of params, query and body, but before executing any other logic.

All modifier functions can be async, getting an event as the first argument and the request ctx as the second. The single, update and delete routes also provide the used builder as the third argument. This way you can mutate the executed where clause to prevent unauthorized access.

The list route modifier passes in a countBuilder and a listBuilder as the third and fourth argument respectively. By mutating both, the total stays in sync with the returned values. Note that the result of the count event is used to mutate the listBuilder afterwards to only select the results of the current pagination result.

Another edge case is the create event for a nested oneToOne relation. It's third argument is the same builder as used in the single routes, for checking if the relation already the oneToOne field.

TODO

  • fields readable, writable