Customize Zendro
Zendro offers multiple different ways to customize it to your specific needs.
Table of contents
GraphQL server
Custom validations
It is possible to add custom asynchronous validation functions to validate your data. For more information see:
Patches
Custom patches allow you to monkey-patch functions and properties of the generated backend code. When running the code generator, a skeleton patch file is automatically created for every data model in /patches. Implementing functions there automatically overrides the default behaviour. An example patch overriding <function_name> could look like this:
data.prototype.<function_name> = function(...) {...}
Single page application
Custom pages
Routes in the application automatically mirror the structure of the pages folder. The default static site contains a dynamic [model] route, with a home page (index.tsx) to display an interactive table of records, and one child route (item.tsx) to display data for a single record.
Overriding a model route with a custom page only requires providing an appropriately named file within the pages folder. Because in Next.js predefined routes take precedence over dynamic routes, all requests for that model will now point to the new page.
In the example below, a custom books.tsx page overrides the default /books route that would otherwise be provided by [model]/index.tsx.
pages
├── [model]
│ ├── index.tsx
│ └── [item].tsx
├── books.tsx
├── index.tsx
└── login.tsx
Next.js resources
To learn more about Next.js, take a look at the following resources:
- Next.js Documentation - learn about Next.js features and API.
- Learn Next.js - an interactive Next.js tutorial.
Add a new storage type
If none of the current storage types fulfill your requirements, it is possible to add a new one to Zendro.
Workflow
- Define some new data models according to the Data models specification. If you would like to introduce associations among data models, the new data model definitions should also include the association definitions. For distributed models and their adapters, see the Distributed data models specification.
- Modify the
storageTypein the new data model definitions. Depending on the kind of access supported by the new storage type, choose one similar existingstorageTypeand use that as a starting point (and updatetargetStorageTypefor defined associations if necessary). For example, if the new storage type supports both read and write access,mongodbis a good match, since all its APIs are implemented. If the new storage type only supports read access,trinois a better fit — its write-access APIs are disabled and throw an error like “Not supported by Trino” when called. - Create a sandbox with the Zendro CLI. Follow steps 1-3 on that page.
- Add a connection in Zendro using the node driver of the new storage type.
- Design the basic CRUD operations for a single record, and the operations for associations between records, in the generated templates for the existing
storageTypeyou started from. - Extend the
single-page-appfor the new storage type as described in Extension in single page application. - (optional) If you would like to submit a pull request for the new storage type to the main branch of Zendro, translate the modified templates into EJS templates in
graphql-server-model-codegen, and add the corresponding tests to the backend.
How to add the connection, APIs, EJS templates, SPA extension and tests in detail is described below.
Connection
- In the
connection.jsfile of the graphql-server repository, introduce the corresponding node driver and implement a setup function for the new storage type. Then extend theaddConnectionInstancesmethod with the implemented setup function. Also extend thecheckConnectionsfunction, which checks connectivity for all storage types. - In
models/index.js, add the new storage type object to themodelsobject definition, which stores models grouped by storage type. Create a new folder undermodelsfor the new models and move them there. Update thestorageTypein thedefinitionobject of each new model (they were generated based on an existing storage type), and, if there are associations, updatetargetStorageTypeas well. - If you would like to use distributed models and adapters, modify the
index.jsfile in themodels/adaptersfolder similarly: add the new storage type object to theadaptersdefinition, and correct thestorageTypeof each new adapter. - In
utils/helper.js, extend theinitializeStorageHandlersForModelsandinitializeStorageHandlersForAdaptersfunctions. These assign the connected node driver (the storage handler, in Zendro’s terms) to the corresponding models or adapters. It is also recommended to extend thecreateIndexesfunction, which greatly improves read/write speed. - Execute
zendro start gqsfor local databases, orzendro dockerize -uto dockerize the app. Since you only need to test the backend at this point, you can comment out thezendro-spaservice and add the container information for the new storage type indocker-compose-dev.yml. If there are no errors in./logs/graphql-server.log(local databases) or the console (dockerized app), the connection between models/adapters and the node driver is working. - Create a new branch for the new storage type in the graphql-server repository, and only commit changes related to the connection. Do not commit model files, adapter files, schema files, validation files or patch files — those are generated by the code generator.
Basic CRUD operations
Schema
Zendro has four existing schemas of operators, one per family of storage types: GenericPrestoSqlOperator, MongodbNeo4jOperator, CassandraOperator and AmazonS3Operator. Considering StorageType compatibility, choose the operator schema most similar to your new storage type and use it as the operator field in the new schema files (stored in the schemas folder). You can also define your own set of search operators.
Resolver & Model
In general it is unnecessary to modify the resolvers in the resolvers folder. You do need to modify some methods in the new model files under models/new_storage_type.
APIs are classified into two categories, read access and write access, and the corresponding methods need to be implemented at the model level.
- Read access
| API at the resolver level | Method at the model level | Note |
|---|---|---|
readOne<model> | readById | data loader implementation |
count<models> | countRecords | |
<models> | readAll | limit-offset based pagination |
<models>Connection | readAllCursor | cursor based pagination |
- Write access
| Method at the resolver level | Method at the model level |
|---|---|
add<model> | addOne |
delete<model> | deleteOne |
update<model> | updateOne |
bulkAdd<model>Csv | bulkAddCsv |
To implement most read-access methods, familiarize yourself with the pagination, order and search arguments in Zendro — see General filter arguments. You’ll then need helper functions to parse these arguments into statements or objects for the new storage type’s node driver. For example, searchConditionsToMongoDb (used by readAllCursor for the mongodb storage type) parses the search arguments into an object accepted by the MongoDB node driver; orderConditionsToMongoDb and cursorPaginationArgumentsToMongoDb do the same for order and pagination. The MongoDB node driver then uses these parsed objects to fetch the corresponding records and return them to Zendro.
Validation
The validations folder contains files for validating records of the new data models. By default you can keep the existing validation files; to add more validation rules, see Custom Validator Function for AJV.
Operations for associations
To introduce operations for associations among data models, implement some functions at the model level according to the association type and the new storage type.
One to one
If the foreign key is stored in the current model, implement the corresponding methods at the model level:
| Method at the resolver level | Method at the model level |
|---|---|
<model>.prototype.add_unique_<assoc> | add_<assocID> |
<model>.prototype.remove_unique_<assoc> | remove_<assocID> |
bulkAssociate<model>With<assocID> | bulkAssociate<model>With<assocID> |
bulkDisAssociate<model>With<assocID> | bulkDisAssociate<model>With<assocID> |
Other relevant methods in the associated models are generated automatically.
Many to one / One to many
For models with a many_to_one association type, implement the following methods at the model level:
| Method at the resolver level | Method at the model level |
|---|---|
<model>.prototype.add_<assoc> | add_<assocID> |
<model>.prototype.remove_<assoc> | remove_<assocID> |
bulkAssociate<model>With<assocID> | bulkAssociate<model>With<assocID> |
bulkDisAssociate<model>With<assocID> | bulkDisAssociate<model>With<assocID> |
For models with a one_to_many association type, the relevant methods are generated automatically.
Many to many
For all models with a many_to_many association type, implement the following methods at the model level:
| Method at the resolver level | Method at the model level |
|---|---|
<model>.prototype.add_<assoc> | add_<assocIDs> |
<model>.prototype.remove_<assoc> | remove_<assocIDs> |
Distributed models and adapters
Code for distributed models and adapters with the ddm-adapter storage type is generated automatically. You still need to implement the methods for adapters of the new storage type, e.g. mongodb-adapter, under models/adapters. The methods to implement for adapters and models are approximately the same, so the lists in Basic CRUD operations and Operations for associations apply here too — with one exception: readAll is never implemented for adapters, since Zendro only supports cursor-based pagination (readAllCursor) for them.
Extension in single page application
- Add the new storage type to the
StorageTypetype insrc/types/models.ts, e.g.mongodbandmongodb-adapter. - Add the configuration for the new storage type in the
getModelApiPrivileges()function insrc/utils/models.ts. This includes the following options:operators([String]): allowed operators for the new storage type.textSearch(boolean): enables the search bar. By default, Zendro uses theilikeoperator for search; if your storage type does not supportilike, disabletextSearch.backwardPagination(boolean): enables backward pagination.sort(boolean): enables sorting by columns.create(boolean): enables record creation.update(boolean): enables record updates.delete(boolean): enables record deletion.bulkAddCsv(boolean): enables bulk-adding records from a CSV file.
- Test the extended SPA. For local databases, run
zendro start spa. If you’re using the dockerized app, first shut it down withzendro dockerize -d, then uncommentzendro_spaindocker-compose-dev.ymland runzendro dockerize -u. If the SPA works as expected, commit the changes from steps 1 and 2 on a new branch.
EJS templates in code generator
If you would like new data model definitions to generate code, implement some EJS templates in graphql-server-model-codegen:
- Create a template file named
create-models-<storageType>in theviewsfolder, e.g.create-models-mongodb. - Migrate the basic CRUD methods from your modified model files into
create-models-<storageType>, replacing the concrete model name and other model-specific variables with EJS variables. See the common EJS variables defined in thegetOptionsmethod offunks.js;create-models-mongodbis a good concrete example to follow. - (optional) If you implemented methods for associations in the new storage type, create a template file named
create-models-fieldMutations-<storageType>inviews/includes, e.g.create-models-fieldMutations-mongodb. - (optional) Migrate the basic association-related methods (i.e. without bulk operations) from your modified model files into
create-models-fieldMutations-<storageType>. Common EJS variables are again defined ingetOptionsinfunks.js;create-models-fieldMutations-mongodbis a good example. - (optional) Extend the
views/includes/bulkAssociations-modelstemplate with bulk-operation methods for associations from your modified model files. - (optional) If you implemented adapters for the new storage type, create a
create-<storageType>-adaptertemplate, e.g.create-mongodb-adapter. - (optional) Migrate the basic CRUD methods from your modified adapter files into
create-<storageType>-adapter. - (optional) If you implemented methods for associations in adapters, migrate them into the
create-adapter-fields-mutationsandbulkAssociations-modelstemplates. - Update
lib/generators-aux.jsto add the default database key for the new storage type. - Update
lib/operators-aux.jsto specify the operator schema for the new storage type. - Update
funks.jsso the code generator can match the new storage type with its templates — search formongodbto find the relevant locations, and add similar code for your new storage type.
Test
To contribute your storage type to the Zendro main branch, commit the new templates and add tests before submitting a pull request.
The backend test frameworks are mocha and chai.
Unit tests
For Zendro, a unit test means that the code generator produces the expected code for a given set of templates. Add the data model definitions to be tested in graphql-server-model-codegen/test/unit_test_misc, the expected code in graphql-server-model-codegen/test/unit_test_misc/test-describe, and the test cases in graphql-server-model-codegen/test/mocha_unit.test. Run unit tests with npm run test-unit. The required unit tests at the model level are:
- Basic CRUD methods: readById, countRecords, readAll, readAllCursor, addOne, deleteOne, updateOne, bulkAddCsv
- Association methods:
- OneToOne: add one association, remove one association, bulkAssociation, bulkDisassociation
- ManyToMany: add associations, remove associations
- ManyToOne: add one association, remove one association, bulkAssociation, bulkDisassociation
Integration tests
Integration tests exercise the API’s functionality. Add the data model definitions to test/integration_test_misc/integration_test_models_instance2, the container configuration for the new storage type to test/integration_test_misc/docker-compose-test.yml (integration tests run via docker and docker-compose), and update test/integration_test_misc/data_models_storage_config2.json with the default connection configuration. If bulkAddCsv is tested, add the corresponding CSV files under test/integration_test_misc. Then create a new integration test file, test/mocha_integration_<storageType>.test.js, e.g. mocha_integration_mongodb.test.js, and register it in test/testenv_cli (search for mocha_integration_mongodb.test.js and add your file under the same if condition). Run the suite with npm run test-integration [-- OPTIONS] (npm run test-integration -- -h lists the available commands). The list of concrete tests:
- Basic CRUD operations: add, update, readOne, delete, CSV bulk upload, limit-offset pagination, cursor-based pagination, sort, table template acquisition, data loader, search
- Operators: like, notLike, iLike, notILike, regexp, notRegexp, iRegexp, notIRegexp, in, notIn, contains, notContains
- Associations:
- ManyToOne: add one association, read one associated record, remove one association, bulkAssociation, bulkDisassociation
- ManyToMany: add associations, read one associated record, remove associations
- OneToOne: add one association, read one associated record, remove one association, bulkAssociation, bulkDisassociation
Note: if the new storage type does not have write access or associations, the corresponding tests are unnecessary. If you add distributed models and adapters, add similar tests for them too — except for readAll, since adapters only implement cursor-based pagination (readAllCursor).