ORM and GraphQL server for Java and PostgreSQL
Simplify working with relational data and creating network services. Loppi autogenerates code so you can have a GraphQL schema and database that you can use with type-safety.
Simplify working with relational data and creating network services. Loppi autogenerates code so you can have a GraphQL schema and database that you can use with type-safety.
Example architecture
ORM
public record ProductDTO(long productId, String description, BigDecimal price){}
// Fetches a number of products, ordered by createdDate, filtered by description.
// These are autogenerated: databaseService, ProductScout, ProductResult and productMeta
public ImmutableList<ProductDTO> fetchProducts(int limit)
throws PostgresExecutionException {
ScoutQueryBuilder<ProductDTO, ProductResult> queryBuilder =
databaseService.createScoutQueryBuilder(limit, ProductScout.class,
productScout -> new ProductDTO(
// Include these three columns in the query.
productScout.scoutProductId(),
productScout.scoutDescription(),
productScout.scoutCurrentUnitPrice()
)
);
queryBuilder.tableQueryBuilder()
.orderBy(productMeta.createdAt, OrderByDirection.asc)
.where().like(productMeta.description, "wall clock");
return databaseService.executeScoutQuery(queryBuilder);
}
public record PurchaseOrderDTO(
String name, String address, String phoneNumber, OffsetDateTime createdAt,
List<OrderLineDTO> orderLines){
public record OrderLineDTO(long orderLineId, long productId, int quantity){}
}
// Builds the query for purchase orders and their order lines. The mapping
// function decides which columns are fetched and adds a lateral join for the
// order lines. The MUTATION tab reuses this same method.
public ScoutQueryBuilder<PurchaseOrderDTO, PurchaseOrderResult> createFetchOrdersQuery(
int ordersLimit, int orderLinesLimit) {
ScoutQueryBuilder<PurchaseOrderDTO, PurchaseOrderResult> scoutQueryBuilder =
databaseService.createScoutQueryBuilder(ordersLimit, PurchaseOrderScout.class,
purchaseOrderScout -> {
// Include lateral join for order lines, the number of order lines per
// order is limited, and order lines themselves are ordered by orderLineId
List<OrderLineScout> orderLineScouts = purchaseOrderScout.scoutOrderLines(
orderLinesLimit, queryBuilder -> queryBuilder
.orderBy(orderLineMeta.orderLineId, OrderByDirection.asc)
);
List<PurchaseOrderDTO.OrderLineDTO> orderLines = new ArrayList<>();
for (OrderLineScout orderLineScout : orderLineScouts) {
orderLines.add(new PurchaseOrderDTO.OrderLineDTO(
// Include the scalar columns from the order line table.
orderLineScout.scoutOrderLineId(),
orderLineScout.scoutProductId(),
orderLineScout.scoutQuantity())
);
}
return new PurchaseOrderDTO(
// Include the scalar columns from the purchase order table.
purchaseOrderScout.scoutName(),
purchaseOrderScout.scoutAddress(),
purchaseOrderScout.scoutPhoneNumber(),
purchaseOrderScout.scoutCreatedAt(),
orderLines);
});
scoutQueryBuilder.tableQueryBuilder().orderBy(
purchaseOrderMeta.createdAt, OrderByDirection.asc);
return scoutQueryBuilder;
}
// Fetches purchase orders and their order lines, ordered by createdAt.
public ImmutableList<PurchaseOrderDTO> fetchOrders(int ordersLimit, int orderLinesLimit)
throws PostgresExecutionException {
return databaseService.executeScoutQuery(
createFetchOrdersQuery(ordersLimit, orderLinesLimit));
}
A query is a normal Java method. As you map a row into your own record, the fields you read are the columns and joins Loppi actually fetches. The query and the result are written in one place, so they stay in sync.
Add a field to your record and read its column on the same line. Remove the field and the column drops out of the SQL. There is no separate select list to keep updated.
When a record holds a nested one, Loppi loads it with a lateral join in the same query. Children come back with their parents, so you don't run into the N+1 problem.
You get back the immutable record you defined. No proxies, no lazy loading, and nothing that throws a LazyInitializationException once the method returns.
public record ProductIdAndQuantity(long productId, int quantity){}
// Places a purchase order and corresponding order lines in one transaction.
public ImmutableList<PurchaseOrderDTO> placeOrder(
String name, String address, String phoneNumber,
List<ProductIdAndQuantity> orderLineInfo)
throws PostgresExecutionException {
List<RetailDemoRowToMutate> transaction = new ArrayList<>();
PurchaseOrderToInsert purchaseOrderToInsert =
new PurchaseOrderToInsert(name, address, phoneNumber);
transaction.add(purchaseOrderToInsert);
for (ProductIdAndQuantity productIdAndQuantity : orderLineInfo) {
transaction.add(
new OrderLineToInsert(purchaseOrderToInsert,
productIdAndQuantity.productId(),
productIdAndQuantity.quantity())
);
}
// Reuse createFetchOrdersQuery() from the QUERY 2 tab to return the placed
// order with its order lines, in the same transaction as the insert.
MutationScoutQueryExecutionResult<PurchaseOrderDTO> result =
databaseService.executeMutationScoutQuery(
transaction, createFetchOrdersQuery(1, 50));
return result.getScoutQueryExecutionResult();
}
Put together a graph of the insert and update objects Loppi generates, like an order with its order lines, and pass it to Loppi. It inserts and updates every row in the right tables, in the right order, in a single transaction.
Parent and children go in together. Loppi works out the foreign keys and the order of the writes, so you are not copying generated ids from one insert into the next.
The whole graph commits, or none of it does. You won't be left with a half-written order to clean up.
You fill in the generated insert objects and hand them over. There is no entity state to manage, nothing to attach or detach, and no flush to time.
GraphQL server
// There is a table in the database called "product". This block of code will
// configure a GraphQL query field. ProductMeta is autogenerated.
GraphQLTablePluralQueryConfiguration<ProductResult> productQueryConfiguration =
new GraphQLTablePluralQueryConfiguration<ProductResult>()
.setIncludeField(true)
.setPluralFieldDescription("Returns the available products.")
// The fields that a customer will be allowed to order by in the queries.
.setIncludeOrderBy(Set.of(productMeta.stockCode,
productMeta.productId,
productMeta.currentUnitPrice,
productMeta.createdAt))
// The fields that a customer will be allowed to filter by in the queries.
.setIncludeWhere(Set.of(productMeta.productId,
productMeta.currentUnitPrice,
productMeta.description,
productMeta.stockCode,
productMeta.createdAt))
.setIncludePagination(true);
GraphQLTableQueriesConfiguration<ProductResult> queriesConfiguration =
new GraphQLTableQueriesConfiguration<>(
// The fields that a customer with be allowed to include in the queries.
Set.of(productMeta.productId,
productMeta.description,
productMeta.stockCode,
productMeta.currentUnitPrice,
productMeta.createdAt))
.setIncludePluralTableQuery(productQueryConfiguration);
return new GraphQLTableConfiguration<>(productMeta.getEntityMetadata())
.setIncludeQueries(queriesConfiguration);
# GraphQL SDL of the schema generated
"Returns the available products."
products(
"Limit the number of results."
limit: Int!,
"Order by argument for the result list. Cannot be null."
orderBy: [ProductOrderBy!],
"""This adds certain where-criteria. When next page is selected it works
such that only elements after lastSeenId are returned. When previous page
is selected it only returns elements before lastSeenId. When using
pagination, you must also specify orderBy with exactly one or two entries.
The last entry in the orderBy must be the productId field."""
pagination: ProductPagination,
"""Boolean expression for determining which results should be included.
Cannot be null."""
where: ProductCriteria
): [ProductResult!]!
type ProductResult {
createdAt: OffsetDateTime!
currentUnitPrice: BigDecimal!
description: String!
productId: Int!
stockCode: String!
}
Declare what tables, columns and relations you want to expose, how they are allowed to be ordered and filtered, and how they can be mutated. Fields that do not map directly to database tables can be declared as custom fields.
Adding fields for querying your database is easy, and so is adding custom fields for everything else.
Relationships to join, where or orderBy expression, all with type-safety.
Maintain and secure a handful of fields instead of hundreds of endpoints.
# Fetches the 5 latest purchase orders for the
# current user, and the first 8 orderlines for
# each purchase order.
query myOrders {
purchaseOrders(limit:5, orderBy: {createdAt:desc}){
purchaseOrderId
createdAt
orderStatus
# This fetches orderlines for the purchase
# order, but does not cause multiple fetches,
# round-trips or the N+1 problem.
orderLines(limit: 8 orderBy: { orderLineId: desc})
{
unitPrice
quantity
product {
productId
description
}
}
}
}
{
"data": {
"purchaseOrders": [
{
"purchaseOrderId": 581796,
"createdAt": "2023-08-21T10:38:19.952764+00:00",
"orderStatus": "NEW",
"orderLines": [{
"unitPrice": "0.12", "quantity": 1,
"product": { "productId": 134, "description": "DISCO BALL CHRISTMAS DECORATION" }
}]
},
{
"purchaseOrderId": 581795,
"createdAt": "2023-08-21T10:37:22.637079+00:00",
"orderStatus": "NEW",
"orderLines": [{
"unitPrice": "0.21", "quantity": 1,
"product": { "productId": 28, "description": "ANIMAL STICKERS" }
}]
}
]
}
}
With one schema there are many ways to query it. Fetch multiple root fields, their relations and their relations again. No need for several http requests to get the data for one page.
Don't call a large endpoint and discard most of the data, instead fetch exactly what you need and no more.
One root field, many root fields, relations, it doesn't matter; it's all fetched in one go.
From Loppi to the database, the query or mutation is executed in one round-trip and one transaction.
# Example of the mutations part of an e-commerce GraphQL schema
"Root mutation type."
type MutationRoot {
"""Login to the service. Requires solved captcha, with token sent via
cookie "hCaptchaToken". If login is successful the response will include
a set-cookie for "sessionToken". After four unsuccessful username/password
combinations, you need to solve captcha again."""
login(passwordBcrypt: String!, username: String!): LoginResult!
"""Place an order for purchase. Set customerId to associate order with
logged in account. Maximum order lines: 50."""
placeOrder(purchaseOrderToInsert: PurchaseOrderToInsert!): PurchaseOrderMutationResult!
"Signs out of the service."
signOut: IgnoredResult!
"""Signs up for the service. Requires solved captcha, with token sent
via cookie "hCaptchaToken"."""
signUp(name: String!, passwordBcrypt: String!, username: String!): SignUpResult!
"""Checks with a third party if the submitted token is a valid captcha
response. If successful sets cookie "hCaptchaToken" and returns the token.
Does no mutations"""
verifyCaptcha(token: String!): UUIDTokenResult!
}
// Here is a snippet from how the autogenerated GraphQL code could be handled.
CustomerMutationRootField mutationField =
checkOnlyOneMutation(mutationRequest);
rateLimiter.consumeRegularGraphQL(userInfo.remoteAddress(), 3);
MutationFieldAndCookies authorized = switch (mutationField) {
// These Field types are autogenerated.
case PlaceOrderMutationField placeOrderMutationField ->
orderDomain.handlePlaceOrder(userInfo, placeOrderMutationField);
case SignUpMutationField signUp ->
userDomain.handleSignUp(userInfo, signUp);
case SignOutMutationField signOut ->
loginSessionDomain.handleSignOut(userInfo, signOut);
case LoginMutationField login ->
loginSessionDomain.handleLogin(userInfo, login);
case VerifyCaptchaMutationField verifyCaptchaField ->
captchaVerifier.verifyHCaptchaTokenMutation(verifyCaptchaField);
};
GraphQLExecutionResult executionResult =
graphQLService.executeMutation(List.of(authorized.mutationField()));
public MutationFieldAndCookies handlePlaceOrder(
UserInfo userInfo, PlaceOrderMutationField placeOrderMutationField)
throws GraphQLRequestException {
PurchaseOrderInsertMutationInput input = placeOrderMutationField.input();
PurchaseOrderToInsert purchaseOrderToInsert = input.purchaseOrderToInsert();
// Validate address
String address = purchaseOrderToInsert.getAddress();
if(address.trim().isEmpty()){
throw new GraphQLValidationException("Address cannot be empty.");
}
// Validate name
String name = purchaseOrderToInsert.getName();
if(name.trim().isEmpty()){
throw new GraphQLValidationException("Name cannot be empty.");
}
// Additional code
public MutationFieldAndCookies verifyHCaptchaTokenMutation(
VerifyCaptchaMutationField verifyCaptchaField)
throws GraphQLRequestException {
VerifyCaptchaMutationInput input = verifyCaptchaField.input();
String responseToken = input.token();
if(responseToken.length() > 5_000){
throw new GraphQLRequestException("Invalid captcha token.",
GraphQLErrorTypeCategory.OTHER);
}
// Additional code.
Have the compiler and IDE hold your hand while you are building your application. Instead of spending time writing mapping code, you get all the typing you need automatically from the schema.
Use the types generated to look at what is going on and apply your business logic in plain Java.
Loppi makes most invalid state impossible to represent.
Most objects are immutable and thread-safe, and method calls never accept or return null.