Database
Two opt-in data layers: SQL over any JDBC DataSource (Sql, Migrations) and MongoDB (Mongo, Doc, Schema). Each loads only when its driver is on the classpath.
SQL: setup
Add spring-boot-starter-jdbc and a driver, set spring.datasource.url, and the DataSource bean Spring Boot creates is bound to Sql at startup. Migrations under db/migration run first.
spring:
datasource:
url: ${DATABASE_URL:jdbc:postgresql://localhost:5432/myapp}
username: ${DATABASE_USER:myapp}
password: ${DATABASE_PASSWORD:}SQL: queries to records
The SQL is yours; the mapping is the framework's. A row maps to a record by column name, ignoring case and underscores, so created_at fills createdAt.
import jweb.Sql;
record User(Long id, String name, String email, LocalDateTime createdAt) {}
List<User> users = Sql.query("select * from users where email like ?", "%@example.com").list(User.class);
Optional<User> one = Sql.query("select * from users where id = ?", 7).first(User.class);
long n = Sql.query("select count(*) from users").scalar(Long.class);
List<User> named = Sql.query("select * from users where name = :name", Map.of("name", "Ada")).list(User.class);
int changed = Sql.update("update users set name = ? where id = ?", "Ada", 7);
long id = Sql.insert("insert into users (name, email) values (?, ?)", "Ada", "ada@example.com").key(Long.class);
Sql.transaction(() -> {
Sql.update("update accounts set balance = balance - ? where id = ?", 50, 1);
Sql.update("update accounts set balance = balance + ? where id = ?", 50, 2);
});SQL: a table through a record
Columns are the record's components in snake_case; the component named id is the key. Forms and tables meet on the record: Form.of renders it, Form.bind validates it, the table stores it.
import jweb.Sql;
record Todo(Long id, String title, boolean done) {}
Sql.Table<Todo> todos = Sql.table("todos", Todo.class);
Todo t = todos.insert(new Todo(null, "Write docs", false)); // the copy returned carries the generated id
todos.update(new Todo(t.id(), t.title(), true));
Optional<Todo> maybe = todos.find(t.id());
List<Todo> open = todos.where("done = ? order by id", false); // everything after `where`
long left = todos.count("done = ?", false);
todos.delete(t.id());SQL: migrations
Versioned files under src/main/resources/db/migration, applied once each in order at startup and remembered in jweb_migrations with a checksum. The name is Flyway's convention, so the files move to Flyway unchanged if an app ever wants it.
-- db/migration/V1__create_todos.sql
create table todos (
id bigint generated always as identity primary key,
title varchar(200) not null,
done boolean not null default false
);import jweb.Migrations;
import javax.sql.DataSource;
DataSource dataSource = Sql.dataSource();
Migrations.Report report = Migrations.migrate(dataSource); // what this call applied
List<Migrations.Migration> pending = Migrations.pending(dataSource);
List<Migrations.Applied> history = Migrations.applied(dataSource);MongoDB: connection
Add org.mongodb:mongodb-driver-sync and set jweb.data.enabled: true to connect at startup; or connect by hand.
import static jweb.Mongo.*;
// Connect with URI and database name
Mongo.connect("mongodb://localhost:27017", "mydb");
// Or use environment variables (MONGO_URI, MONGO_DB)
Mongo.connect();Documents
Use Doc for dynamic document creation and field access.
import static jweb.Doc.*;
// Create a document
Doc user = Doc.of("users")
.set("name", "John")
.set("email", "john@example.com")
.set("age", 25);
// Save to database
Mongo.save(user);
// Access fields
String name = user.getString("name");
int age = user.getInt("age");Queries
Fluent query builder with filters, sorting, and pagination.
String id = "507f1f77bcf86cd799439011";
// Find by ID
Doc user = Mongo.findById("users", id);
// Find with filters
List<Doc> users = Mongo.find("users")
.where("age").gte(18)
.where("status", "active")
.orderBy("name")
.limit(10)
.toList();
// Find first match
Doc admin = Mongo.find("users")
.where("role", "admin")
.first();Updates
String id = "507f1f77bcf86cd799439011";
String visitorId = "507f191e810c19729de860ea";
// Update fields
Mongo.update("users")
.where("id", id)
.set("name", "Jane")
.set("age", 26)
.execute();
// Increment a field
Mongo.update("users")
.where("id", visitorId)
.inc("visitCount", 1)
.execute();Deletes
String userId = "507f1f77bcf86cd799439011";
// Delete by ID
Mongo.delete("users")
.where("id", userId)
.execute();
// Delete multiple
Mongo.delete("users")
.where("status", "inactive")
.executeAll();Schema Definition
Define schemas for validation and automatic timestamps.
import static jweb.Schema.*;
Schema.collection("users")
.id("id")
.string("name").required()
.string("email").required().unique()
.integer("age").min(0).max(150)
.list("roles")
.timestamps()
.register();File Storage (Storage)
Where the app keeps the files its users give it: a directory in development, any S3-compatible bucket in production (Amazon, Cloudflare R2, MinIO, Backblaze, Spaces, GCS) — the same calls, and the framework signs the bucket's requests itself. The app serves the local store at /files with ETags, Cache-Control and byte ranges; signed URLs expire.
import jweb.Storage;
import jweb.FileUpload;
import jweb.UploadedFile;
import static jweb.El.*;
String avatarKey = "avatars/3f2a9c.png";
byte[] csv = "a,b\n".getBytes();
// an upload under a generated key: avatars/<32 hex>.png — keep the key on the user
UploadedFile file = FileUpload.getFile(req, "avatar");
Storage.Stored avatar = Storage.upload("avatars", file);
// the URL a browser fetches it from
Element picture = img(src(Storage.url(avatarKey)), alt("Avatar"));
// any bytes, any key
Storage.put("exports/7.csv", csv, "text/csv");
Optional<byte[]> bytes = Storage.bytes("exports/7.csv");
List<Storage.Stored> exports = Storage.list("exports/");
Storage.delete("exports/7.csv");
// a private document: a link that stops working after ten minutes
Element link = a(href(Storage.url("invoices/7.pdf", Duration.ofMinutes(10))), "Download");jweb:
storage:
dir: storage # the local store (default)
access: public # or signed: every URL expires and is signed under jweb.auth.secret
s3:
bucket: ${S3_BUCKET:} # set → the bucket
region: us-east-1
endpoint: # blank = Amazon; an R2 / MinIO address otherwise
access-key: ${S3_ACCESS_KEY:}
secret-key: ${S3_SECRET_KEY:}Admin and Scaffolding
A back office for the tables an app keeps through Sql.table: a page per table that lists, searches and pages the rows, a form to add or edit one (the record is the form), and a delete. The pages sit in the page table, so the layout wraps them and the guard stands in front of every admin route.
import jweb.Admin;
import jweb.Auth;
import jweb.Sql;
record Todo(Long id, @Form.Required String title, boolean done) {}
Sql.Table<Todo> todos = Sql.table("todos", Todo.class);
Admin.mount(app, "/admin", todos).title("Back office").guard(Auth.requireRole("admin"));
List<Todo> page = todos.page(2, 50); // the helpers behind it, for your own pages
List<Todo> hits = todos.search("docs", 1, 50);jweb new shop # a project with H2 under ./data and db/migration
jweb generate resource Product name:string! price:double inStock:boolean
# → models/Product.java (the row and the form), V1__create_products.sql,
# ProductListPage, ProductFormPage, ProductRoutes (/products, /products/new, /products/:id)