This demo application is based on the Chinook music store sample database.
Demonstrated functionality
-
Custom column type
-
Database functions
-
Custom table cell renderer
-
See TrackTablePanel and AlbumTablePanel
-
-
Custom table cell editor
-
See TrackCellEditorFactory in TrackTablePanel
-
-
Custom input fields
-
-
Used in AlbumEditPanel
-
-
-
Used in TrackEditPanel and TrackTablePanel
-
-
Custom condition panel
-
Customizing the table popup menu
-
See TrackTablePanel and PlaylistTablePanel
-
-
Custom condition model operators
-
See TrackConditionModelFactory in TrackTableModel
-
-
Custom detail panel layout
-
See InvoicePanel and PlaylistPanel
-
-
Edit panel in dialog
-
See PlaylistPanel and PlaylistTrackTablePanel
-
-
Language selection
-
See ChinookAppPanel
-
-
Custom shortcut key
-
See ChinookAppPanel
-
-
Overridden resources
-
Text field selector
-
Search field selector
-
Lookup table cleanup
-
Persistance layer used in a web service
-
Table joins
-
Custom condition
-
Non-embedded edit panel
-
Custom edit control
-
Database migrations
-
MigrationManager - Hybrid JDBC/Entity approach for schema versioning
-
-
Distribution and packaging
-
Distribution - JLink/JPackage configuration with modular dependencies
-
Note
|
For the Gradle build configuration see Build section. |
This tutorial assumes you have at least skimmed the Domain model part of the Codion manual.
Domain model
API
public interface Chinook {
DomainType DOMAIN = domainType("ChinookImpl");
The domain API sections below continue the Chinook class.
Implementation
public final class ChinookImpl extends DomainModel {
public ChinookImpl() {
super(DOMAIN);
add(artist(), album(), employee(), customer(), genre(), preferences(), mediaType(),
track(), invoice(), invoiceLine(), playlist(), playlistTrack());
add(Customer.REPORT, classPathReport(ChinookImpl.class, "customer_report.jasper"));
add(Track.RAISE_PRICE, new RaisePriceFunction());
add(Invoice.UPDATE_TOTALS, new UpdateTotalsFunction());
add(Playlist.RANDOM_PLAYLIST, new CreateRandomPlaylistFunction(entities()));
}
@Override
public void configure(Database database) throws DatabaseException {
MigrationManager.migrate(database);
}
The domain implementation sections below continue the ChinookImpl class.
Customers

Customer
SQL
CREATE TABLE CHINOOK.CUSTOMER
(
ID LONG GENERATED BY DEFAULT AS IDENTITY,
FIRSTNAME VARCHAR(40) NOT NULL,
LASTNAME VARCHAR(20) NOT NULL,
COMPANY VARCHAR(80),
ADDRESS VARCHAR(70),
CITY VARCHAR(40),
STATE VARCHAR(40),
COUNTRY VARCHAR(40),
POSTALCODE VARCHAR(10),
PHONE VARCHAR(24),
FAX VARCHAR(24),
EMAIL VARCHAR(60) NOT NULL,
SUPPORTREP_ID LONG,
CONSTRAINT PK_CUSTOMER PRIMARY KEY (ID),
CONSTRAINT FK_EMPLOYEE_CUSTOMER FOREIGN KEY (SUPPORTREP_ID) REFERENCES CHINOOK.EMPLOYEE(ID)
);
Domain
API
interface Customer {
EntityType TYPE = DOMAIN.entityType("chinook.customer", Customer.class.getName());
Column<Long> ID = TYPE.longColumn("id");
Column<String> FIRSTNAME = TYPE.stringColumn("firstname");
Column<String> LASTNAME = TYPE.stringColumn("lastname");
Column<String> COMPANY = TYPE.stringColumn("company");
Column<String> ADDRESS = TYPE.stringColumn("address");
Column<String> CITY = TYPE.stringColumn("city");
Column<String> STATE = TYPE.stringColumn("state");
Column<String> COUNTRY = TYPE.stringColumn("country");
Column<String> POSTALCODE = TYPE.stringColumn("postalcode");
Column<String> PHONE = TYPE.stringColumn("phone");
Column<String> FAX = TYPE.stringColumn("fax");
Column<String> EMAIL = TYPE.stringColumn("email");
Column<Long> SUPPORTREP_ID = TYPE.longColumn("supportrep_id");
ForeignKey SUPPORTREP_FK = TYPE.foreignKey("supportrep_fk", SUPPORTREP_ID, Employee.ID);
JRReportType REPORT = JasperReports.reportType("customer_report");
}
final class CustomerStringFactory
implements Function<Entity, String>, Serializable {
@Serial
private static final long serialVersionUID = 1;
private static final String LANGUAGE = Locale.getDefault().getLanguage();
@Override
public String apply(Entity customer) {
return switch (LANGUAGE) {
case "en" -> new StringBuilder()
.append(customer.get(Customer.LASTNAME))
.append(", ")
.append(customer.get(Customer.FIRSTNAME))
.toString();
case "is" -> new StringBuilder()
.append(customer.get(Customer.FIRSTNAME))
.append(" ")
.append(customer.get(Customer.LASTNAME))
.toString();
default -> throw new IllegalArgumentException("Unsupported language: " + LANGUAGE);
};
}
}
Implementation
EntityDefinition customer() {
return Customer.TYPE.define(
Customer.ID.define()
.primaryKey(),
Customer.LASTNAME.define()
.column()
.searchable(true)
.nullable(false)
.maximumLength(20),
Customer.FIRSTNAME.define()
.column()
.searchable(true)
.nullable(false)
.maximumLength(40),
Customer.COMPANY.define()
.column()
.maximumLength(80),
Customer.ADDRESS.define()
.column()
.maximumLength(70),
Customer.CITY.define()
.column()
.maximumLength(40),
Customer.STATE.define()
.column()
.maximumLength(40),
Customer.COUNTRY.define()
.column()
.maximumLength(40),
Customer.POSTALCODE.define()
.column()
.maximumLength(10),
Customer.PHONE.define()
.column()
.maximumLength(24),
Customer.FAX.define()
.column()
.maximumLength(24),
Customer.EMAIL.define()
.column()
.searchable(true)
.nullable(false)
.maximumLength(60),
Customer.SUPPORTREP_ID.define()
.column(),
Customer.SUPPORTREP_FK.define()
.foreignKey()
.attributes(Employee.FIRSTNAME, Employee.LASTNAME))
.keyGenerator(identity())
.validator(new EmailValidator(Customer.EMAIL))
.orderBy(ascending(Customer.LASTNAME, Customer.FIRSTNAME))
.stringFactory(new CustomerStringFactory())
.build();
}
UI
CustomerPanel
package is.codion.demos.chinook.ui;
import is.codion.demos.chinook.domain.api.Chinook.Invoice;
import is.codion.swing.framework.model.SwingEntityModel;
import is.codion.swing.framework.ui.EntityPanel;
public final class CustomerPanel extends EntityPanel {
public CustomerPanel(SwingEntityModel customerModel) {
super(customerModel,
new CustomerEditPanel(customerModel.editModel()),
new CustomerTablePanel(customerModel.tableModel()));
detailPanels().add(new InvoicePanel(customerModel.detailModels().get(Invoice.TYPE)));
}
}
CustomerEditPanel
package is.codion.demos.chinook.ui;
import is.codion.demos.chinook.domain.api.Chinook.Customer;
import is.codion.swing.common.ui.control.Control;
import is.codion.swing.common.ui.key.KeyEvents;
import is.codion.swing.framework.model.SwingEntityEditModel;
import is.codion.swing.framework.ui.EntityEditPanel;
import javax.swing.JPanel;
import javax.swing.JTextField;
import java.awt.event.ActionEvent;
import static is.codion.swing.common.ui.component.Components.flexibleGridLayoutPanel;
import static is.codion.swing.common.ui.component.Components.gridLayoutPanel;
import static is.codion.swing.common.ui.dialog.Dialogs.listSelectionDialog;
import static is.codion.swing.common.ui.layout.Layouts.flexibleGridLayout;
import static java.awt.event.InputEvent.CTRL_DOWN_MASK;
import static java.awt.event.KeyEvent.VK_SPACE;
public final class CustomerEditPanel extends EntityEditPanel {
public CustomerEditPanel(SwingEntityEditModel editModel) {
super(editModel);
}
@Override
protected void initializeUI() {
focus().initial().set(Customer.FIRSTNAME);
createTextField(Customer.FIRSTNAME)
.columns(6);
createTextField(Customer.LASTNAME)
.columns(6);
createTextField(Customer.EMAIL)
.columns(12);
createTextField(Customer.COMPANY)
.columns(12);
createTextField(Customer.ADDRESS)
.columns(12);
createTextField(Customer.CITY)
.columns(8);
createTextField(Customer.POSTALCODE)
.columns(4);
createTextField(Customer.STATE)
.columns(4)
.upperCase(true)
// CTRL-SPACE displays a dialog for selecting
// a State from existing column values
.keyEvent(KeyEvents.builder(VK_SPACE)
.modifiers(CTRL_DOWN_MASK)
.action(Control.action(this::selectState)));
createTextField(Customer.COUNTRY)
.columns(8);
createTextField(Customer.PHONE)
.columns(12);
createTextField(Customer.FAX)
.columns(12);
createComboBox(Customer.SUPPORTREP_FK)
.preferredWidth(120);
JPanel firstLastNamePanel = gridLayoutPanel(1, 2)
.add(createInputPanel(Customer.FIRSTNAME))
.add(createInputPanel(Customer.LASTNAME))
.build();
JPanel cityPostalCodePanel = flexibleGridLayoutPanel(1, 2)
.add(createInputPanel(Customer.CITY))
.add(createInputPanel(Customer.POSTALCODE))
.build();
JPanel stateCountryPanel = flexibleGridLayoutPanel(1, 2)
.add(createInputPanel(Customer.STATE))
.add(createInputPanel(Customer.COUNTRY))
.build();
setLayout(flexibleGridLayout(4, 3));
add(firstLastNamePanel);
addInputPanel(Customer.EMAIL);
addInputPanel(Customer.COMPANY);
addInputPanel(Customer.ADDRESS);
add(cityPostalCodePanel);
add(stateCountryPanel);
addInputPanel(Customer.PHONE);
addInputPanel(Customer.FAX);
addInputPanel(Customer.SUPPORTREP_FK);
}
private void selectState(ActionEvent event) {
JTextField stateField = (JTextField) event.getSource();
listSelectionDialog(editModel().connection().select(Customer.STATE))
.owner(stateField)
.selectSingle()
.ifPresent(stateField::setText);
}
}
CustomerTablePanel
package is.codion.demos.chinook.ui;
import is.codion.demos.chinook.domain.api.Chinook.Customer;
import is.codion.framework.domain.entity.Entity;
import is.codion.swing.common.ui.control.Control;
import is.codion.swing.common.ui.dialog.Dialogs;
import is.codion.swing.framework.model.SwingEntityTableModel;
import is.codion.swing.framework.ui.EntityTablePanel;
import is.codion.swing.framework.ui.icon.FrameworkIcons;
import net.sf.jasperreports.engine.JasperPrint;
import net.sf.jasperreports.swing.JRViewer;
import java.awt.Dimension;
import java.util.Collection;
import java.util.HashMap;
import java.util.Map;
import java.util.ResourceBundle;
import static is.codion.swing.framework.ui.EntityTablePanel.ControlKeys.PRINT;
import static java.util.ResourceBundle.getBundle;
public final class CustomerTablePanel extends EntityTablePanel {
private static final ResourceBundle BUNDLE = getBundle(CustomerTablePanel.class.getName());
public CustomerTablePanel(SwingEntityTableModel tableModel) {
super(tableModel, config -> config
// Otherwise the table refresh button is only
// visible when the condition panel is visible
.refreshButtonVisible(RefreshButtonVisible.ALWAYS));
}
@Override
protected void setupControls() {
// Assign a custom report action to the standard PRINT control,
// which is then made available in the popup menu and on the toolbar
control(PRINT).set(Control.builder()
.command(this::viewCustomerReport)
.caption(BUNDLE.getString("customer_report"))
.smallIcon(FrameworkIcons.instance().print())
.enabled(tableModel().selection().empty().not())
.build());
}
private void viewCustomerReport() {
Dialogs.progressWorkerDialog(this::fillCustomerReport)
.owner(this)
.title(BUNDLE.getString("customer_report"))
.onResult(this::viewReport)
.execute();
}
private JasperPrint fillCustomerReport() {
Collection<Long> customerIDs =
Entity.values(Customer.ID,
tableModel().selection().items().get());
Map<String, Object> reportParameters = new HashMap<>();
reportParameters.put("CUSTOMER_IDS", customerIDs);
return tableModel().connection()
.report(Customer.REPORT, reportParameters);
}
private void viewReport(JasperPrint customerReport) {
Dialogs.componentDialog(new JRViewer(customerReport))
.owner(this)
.modal(false)
.title(BUNDLE.getString("customer_report"))
.size(new Dimension(800, 600))
.show();
}
}
Invoice
SQL
CREATE TABLE CHINOOK.INVOICE
(
ID LONG GENERATED BY DEFAULT AS IDENTITY,
CUSTOMER_ID LONG NOT NULL,
INVOICEDATE DATE NOT NULL,
BILLINGADDRESS VARCHAR(70),
BILLINGCITY VARCHAR(40),
BILLINGSTATE VARCHAR(40),
BILLINGCOUNTRY VARCHAR(40),
BILLINGPOSTALCODE VARCHAR(10),
TOTAL DECIMAL(10, 2),
CONSTRAINT PK_INVOICE PRIMARY KEY (ID),
CONSTRAINT FK_CUSTOMER_INVOICE FOREIGN KEY (CUSTOMER_ID) REFERENCES CHINOOK.CUSTOMER(ID)
);
Domain
API
interface Invoice {
EntityType TYPE = DOMAIN.entityType("chinook.invoice", Invoice.class.getName());
Column<Long> ID = TYPE.longColumn("id");
Column<Long> CUSTOMER_ID = TYPE.longColumn("customer_id");
Column<LocalDate> DATE = TYPE.localDateColumn("invoicedate");
Column<String> BILLINGADDRESS = TYPE.stringColumn("billingaddress");
Column<String> BILLINGCITY = TYPE.stringColumn("billingcity");
Column<String> BILLINGSTATE = TYPE.stringColumn("billingstate");
Column<String> BILLINGCOUNTRY = TYPE.stringColumn("billingcountry");
Column<String> BILLINGPOSTALCODE = TYPE.stringColumn("billingpostalcode");
Column<BigDecimal> TOTAL = TYPE.bigDecimalColumn("total");
Column<BigDecimal> CALCULATED_TOTAL = TYPE.bigDecimalColumn("calculated_total");
ForeignKey CUSTOMER_FK = TYPE.foreignKey("customer_fk", CUSTOMER_ID, Customer.ID);
FunctionType<EntityConnection, Collection<Long>, Collection<Entity>> UPDATE_TOTALS = functionType("chinook.update_totals");
ValueSupplier<LocalDate> DATE_DEFAULT_VALUE = LocalDate::now;
}
Implementation
EntityDefinition invoice() {
return Invoice.TYPE.define(
Invoice.ID.define()
.primaryKey(),
Invoice.CUSTOMER_ID.define()
.column()
.nullable(false),
Invoice.CUSTOMER_FK.define()
.foreignKey()
.attributes(Customer.FIRSTNAME, Customer.LASTNAME, Customer.EMAIL),
Invoice.DATE.define()
.column()
.nullable(false)
.defaultValue(Invoice.DATE_DEFAULT_VALUE)
.localeDateTimePattern(LocaleDateTimePattern.builder()
.delimiterDot()
.yearFourDigits()
.build()),
Invoice.BILLINGADDRESS.define()
.column()
.maximumLength(70),
Invoice.BILLINGCITY.define()
.column()
.maximumLength(40),
Invoice.BILLINGSTATE.define()
.column()
.maximumLength(40),
Invoice.BILLINGCOUNTRY.define()
.column()
.maximumLength(40),
Invoice.BILLINGPOSTALCODE.define()
.column()
.maximumLength(10),
Invoice.TOTAL.define()
.column()
.maximumFractionDigits(2),
Invoice.CALCULATED_TOTAL.define()
.subquery("""
SELECT SUM(unitprice * quantity)
FROM chinook.invoiceline
WHERE invoice_id = invoice.id""")
.maximumFractionDigits(2))
.keyGenerator(identity())
.orderBy(OrderBy.builder()
.ascending(Invoice.CUSTOMER_ID)
.descending(Invoice.DATE)
.build())
.stringFactory(Invoice.ID)
.build();
}
UpdateTotalsFunction
private static final class UpdateTotalsFunction implements DatabaseFunction<EntityConnection, Collection<Long>, Collection<Entity>> {
@Override
public Collection<Entity> execute(EntityConnection connection,
Collection<Long> invoiceIds) {
Collection<Entity> invoices =
connection.select(where(Invoice.ID.in(invoiceIds))
.forUpdate()
.build());
return connection.updateSelect(invoices.stream()
.map(UpdateTotalsFunction::updateTotal)
.filter(Entity::modified)
.toList());
}
private static Entity updateTotal(Entity invoice) {
invoice.set(Invoice.TOTAL, invoice.optional(Invoice.CALCULATED_TOTAL).orElse(BigDecimal.ZERO));
return invoice;
}
}
Model
InvoiceModel
package is.codion.demos.chinook.model;
import is.codion.framework.db.EntityConnectionProvider;
import is.codion.framework.domain.entity.Entity;
import is.codion.swing.framework.model.SwingEntityModel;
import java.util.Collection;
import static javax.swing.SwingUtilities.invokeLater;
public final class InvoiceModel extends SwingEntityModel {
public InvoiceModel(EntityConnectionProvider connectionProvider) {
super(new InvoiceEditModel(connectionProvider));
InvoiceLineEditModel invoiceLineEditModel = new InvoiceLineEditModel(connectionProvider);
SwingEntityModel invoiceLineModel = new SwingEntityModel(invoiceLineEditModel);
detailModels().add(link(invoiceLineModel)
// Prevents accidentally adding a new invoice line to the previously selected invoice,
// since the selected foreign key value persists when the master selection is cleared by default.
.clearValueOnEmptySelection(true)
// Usually the UI is responsible for activating the detail model link for the currently
// active (or visible) detail panel, but since the InvoiceLine panel is embedded in the
// InvoiceEditPanel, we simply activate the link here.
.active(true)
.build());
// We listen for when invoice totals are updated by the edit model,
// and replace the invoices in the table model with the updated ones.
// Note the use of invokeLater() since the event is triggered during
// update, which happens in a background thread, so we have to update
// the table data on the Event Dispatch Thread.
invoiceLineEditModel.totalsUpdated().addConsumer(this::onTotalsUpdated);
}
private void onTotalsUpdated(Collection<Entity> updatedInvoices) {
invokeLater(() -> tableModel().replace(updatedInvoices));
}
}
InvoiceEditModel
package is.codion.demos.chinook.model;
import is.codion.framework.db.EntityConnectionProvider;
import is.codion.framework.domain.entity.Entity;
import is.codion.framework.domain.entity.attribute.Attribute;
import is.codion.swing.framework.model.SwingEntityEditModel;
import java.util.Collection;
import static is.codion.demos.chinook.domain.api.Chinook.Customer;
import static is.codion.demos.chinook.domain.api.Chinook.Invoice;
public final class InvoiceEditModel extends SwingEntityEditModel {
public InvoiceEditModel(EntityConnectionProvider connectionProvider) {
super(Invoice.TYPE, connectionProvider);
// By default, foreign key values persist when the model
// is cleared, here we disable that for CUSTOMER_FK
editor().value(Invoice.CUSTOMER_FK).persist().set(false);
// We populate the invoice address fields with
// the customer address when the customer is edited
editor().value(Invoice.CUSTOMER_FK).edited().addConsumer(this::setAddress);
}
// Override to update the billing address when the invoice customer is changed.
// This method is called when editing happens outside of the edit model,
// such as in a table, via an editable table cell or multi item editing
@Override
public <T> void applyEdit(Collection<Entity> invoices, Attribute<T> attribute, T value) {
super.applyEdit(invoices, attribute, value);
if (attribute.equals(Invoice.CUSTOMER_FK)) {
Entity customer = (Entity) value;
invoices.forEach(invoice -> {
// Set the billing address
invoice.set(Invoice.BILLINGADDRESS, customer.get(Customer.ADDRESS));
invoice.set(Invoice.BILLINGCITY, customer.get(Customer.CITY));
invoice.set(Invoice.BILLINGPOSTALCODE, customer.get(Customer.POSTALCODE));
invoice.set(Invoice.BILLINGSTATE, customer.get(Customer.STATE));
invoice.set(Invoice.BILLINGCOUNTRY, customer.get(Customer.COUNTRY));
});
}
}
private void setAddress(Entity customer) {
editor().value(Invoice.BILLINGADDRESS).set(customer == null ? null : customer.get(Customer.ADDRESS));
editor().value(Invoice.BILLINGCITY).set(customer == null ? null : customer.get(Customer.CITY));
editor().value(Invoice.BILLINGPOSTALCODE).set(customer == null ? null : customer.get(Customer.POSTALCODE));
editor().value(Invoice.BILLINGSTATE).set(customer == null ? null : customer.get(Customer.STATE));
editor().value(Invoice.BILLINGCOUNTRY).set(customer == null ? null : customer.get(Customer.COUNTRY));
}
}
UI
InvoicePanel
package is.codion.demos.chinook.ui;
import is.codion.demos.chinook.domain.api.Chinook.InvoiceLine;
import is.codion.swing.framework.model.SwingEntityModel;
import is.codion.swing.framework.ui.EntityPanel;
public final class InvoicePanel extends EntityPanel {
public InvoicePanel(SwingEntityModel invoiceModel) {
super(invoiceModel,
new InvoiceEditPanel(invoiceModel.editModel(),
invoiceModel.detailModels().get(InvoiceLine.TYPE)),
new InvoiceTablePanel(invoiceModel.tableModel()),
// The InvoiceLine panel is embedded in InvoiceEditPanel,
// so this panel doesn't need a detail panel layout.
config -> config.detailLayout(DetailLayout.NONE));
InvoiceEditPanel editPanel = (InvoiceEditPanel) editPanel();
// We still add the InvoiceLine panel as a detail panel for keyboard navigation
detailPanels().add(editPanel.invoiceLinePanel());
}
}
InvoiceEditPanel
package is.codion.demos.chinook.ui;
import is.codion.demos.chinook.domain.api.Chinook.Customer;
import is.codion.demos.chinook.domain.api.Chinook.Invoice;
import is.codion.demos.chinook.domain.api.Chinook.InvoiceLine;
import is.codion.swing.framework.model.SwingEntityEditModel;
import is.codion.swing.framework.model.SwingEntityModel;
import is.codion.swing.framework.ui.EntityEditPanel;
import is.codion.swing.framework.ui.EntityPanel;
import is.codion.swing.framework.ui.component.EntitySearchField;
import is.codion.swing.framework.ui.component.EntitySearchField.Selector;
import is.codion.swing.framework.ui.component.EntitySearchField.TableSelector;
import javax.swing.JPanel;
import java.awt.BorderLayout;
import java.awt.Dimension;
import java.util.function.Function;
import static is.codion.swing.common.ui.component.Components.flexibleGridLayoutPanel;
import static is.codion.swing.common.ui.component.Components.gridLayoutPanel;
import static is.codion.swing.common.ui.layout.Layouts.borderLayout;
import static javax.swing.BorderFactory.createTitledBorder;
public final class InvoiceEditPanel extends EntityEditPanel {
private final EntityPanel invoiceLinePanel;
public InvoiceEditPanel(SwingEntityEditModel editModel, SwingEntityModel invoiceLineModel) {
super(editModel, config ->
// We want this edit panel to keep displaying a newly inserted invoice,
// since we will continue to work with it, by adding invoice lines for example
config.clearAfterInsert(false));
this.invoiceLinePanel = createInvoiceLinePanel(invoiceLineModel);
}
@Override
protected void initializeUI() {
focus().initial().set(Invoice.CUSTOMER_FK);
createSearchField(Invoice.CUSTOMER_FK)
.columns(14)
// We add a custom selector factory, creating a selector which
// displays a table instead of a list when selecting a customer
.selectorFactory(new CustomerSelectorFactory());
createTemporalFieldPanel(Invoice.DATE)
.columns(6);
createTextField(Invoice.BILLINGADDRESS)
.columns(12)
.selectAllOnFocusGained(true);
createTextField(Invoice.BILLINGCITY)
.columns(8)
.selectAllOnFocusGained(true);
createTextField(Invoice.BILLINGPOSTALCODE)
.columns(4)
.selectAllOnFocusGained(true);
createTextField(Invoice.BILLINGSTATE)
.columns(4)
.selectAllOnFocusGained(true);
createTextField(Invoice.BILLINGCOUNTRY)
.columns(8)
.selectAllOnFocusGained(true);
JPanel customerDatePanel = flexibleGridLayoutPanel(1, 2)
.add(createInputPanel(Invoice.CUSTOMER_FK))
.add(createInputPanel(Invoice.DATE))
.build();
JPanel cityPostalCodePanel = flexibleGridLayoutPanel(1, 2)
.add(createInputPanel(Invoice.BILLINGCITY))
.add(createInputPanel(Invoice.BILLINGPOSTALCODE))
.build();
JPanel stateCountryPanel = flexibleGridLayoutPanel(1, 2)
.add(createInputPanel(Invoice.BILLINGSTATE))
.add(createInputPanel(Invoice.BILLINGCOUNTRY))
.build();
JPanel cityPostalCodeStateCountryPanel = gridLayoutPanel(1, 2)
.add(cityPostalCodePanel)
.add(stateCountryPanel)
.build();
JPanel centerPanel = gridLayoutPanel(4, 1)
.add(customerDatePanel)
.add(createInputPanel(Invoice.BILLINGADDRESS))
.add(cityPostalCodeStateCountryPanel)
.build();
invoiceLinePanel.setBorder(createTitledBorder(editModel().entities().definition(InvoiceLine.TYPE).caption()));
invoiceLinePanel.initialize();
setLayout(borderLayout());
add(centerPanel, BorderLayout.CENTER);
add(invoiceLinePanel, BorderLayout.EAST);
}
EntityPanel invoiceLinePanel() {
return invoiceLinePanel;
}
private static EntityPanel createInvoiceLinePanel(SwingEntityModel invoiceLineModel) {
// Here we construct the InvoiceLine panel, which will
// be embedded in this edit panel, see initializeUI().
InvoiceLineTablePanel invoiceLineTablePanel =
new InvoiceLineTablePanel(invoiceLineModel.tableModel());
InvoiceLineEditPanel invoiceLineEditPanel =
new InvoiceLineEditPanel(invoiceLineModel.editModel(),
invoiceLineTablePanel.table().searchField());
return new EntityPanel(invoiceLineModel,
invoiceLineEditPanel, invoiceLineTablePanel, config ->
// We don't include controls so that no buttons appear on this panel
config.includeControls(false));
}
private static final class CustomerSelectorFactory implements Function<EntitySearchField, Selector> {
@Override
public Selector apply(EntitySearchField searchField) {
// We use the TableSelector, provided by EntitySearchField,
// configuring the the visible table columns, the sorting and size
TableSelector selector = EntitySearchField.tableSelector(searchField);
selector.table().columnModel().visible().set(Customer.LASTNAME, Customer.FIRSTNAME, Customer.EMAIL);
selector.table().model().sort().ascending(Customer.LASTNAME, Customer.FIRSTNAME);
selector.preferredSize(new Dimension(500, 300));
return selector;
}
}
}
InvoiceTablePanel
package is.codion.demos.chinook.ui;
import is.codion.common.model.condition.TableConditionModel;
import is.codion.demos.chinook.domain.api.Chinook.Invoice;
import is.codion.framework.domain.entity.attribute.Attribute;
import is.codion.swing.common.ui.component.table.ConditionPanel;
import is.codion.swing.common.ui.component.table.FilterTableColumnModel;
import is.codion.swing.common.ui.component.table.TableConditionPanel;
import is.codion.swing.framework.model.SwingEntityTableModel;
import is.codion.swing.framework.ui.EntityTablePanel;
import java.util.Map;
import java.util.function.Consumer;
import static is.codion.swing.common.ui.component.table.ConditionPanel.ConditionView.SIMPLE;
public final class InvoiceTablePanel extends EntityTablePanel {
public InvoiceTablePanel(SwingEntityTableModel tableModel) {
super(tableModel, config -> config
// The TOTAL column is updated automatically when invoice lines are updated,
// see InvoiceLineEditModel, so we don't want it to be editable via the popup menu.
.editable(attributes -> attributes.remove(Invoice.TOTAL))
// The factory providing our custom condition panel.
.conditionPanelFactory(new InvoiceConditionPanelFactory(tableModel))
// Start with the SIMPLE condition panel view.
.conditionView(SIMPLE));
}
private static final class InvoiceConditionPanelFactory implements TableConditionPanel.Factory<Attribute<?>> {
private final SwingEntityTableModel tableModel;
private InvoiceConditionPanelFactory(SwingEntityTableModel tableModel) {
this.tableModel = tableModel;
}
@Override
public TableConditionPanel<Attribute<?>> create(TableConditionModel<Attribute<?>> tableConditionModel,
Map<Attribute<?>, ConditionPanel<?>> conditionPanels,
FilterTableColumnModel<Attribute<?>> columnModel,
Consumer<TableConditionPanel<Attribute<?>>> onPanelInitialized) {
return new InvoiceConditionPanel(tableModel, conditionPanels, columnModel, onPanelInitialized);
}
}
}
InvoiceConditionPanel
package is.codion.demos.chinook.ui;
import is.codion.common.Operator;
import is.codion.common.item.Item;
import is.codion.common.model.condition.ConditionModel;
import is.codion.demos.chinook.domain.api.Chinook.Invoice;
import is.codion.framework.domain.entity.Entity;
import is.codion.framework.domain.entity.EntityDefinition;
import is.codion.framework.domain.entity.attribute.Attribute;
import is.codion.framework.model.EntityTableConditionModel;
import is.codion.framework.model.ForeignKeyConditionModel;
import is.codion.swing.common.ui.component.Components;
import is.codion.swing.common.ui.component.table.ConditionPanel;
import is.codion.swing.common.ui.component.table.ConditionPanel.ConditionView;
import is.codion.swing.common.ui.component.table.FilterTableColumnModel;
import is.codion.swing.common.ui.component.table.FilterTableConditionPanel;
import is.codion.swing.common.ui.component.table.TableConditionPanel;
import is.codion.swing.common.ui.component.text.NumberField;
import is.codion.swing.common.ui.component.value.ComponentValue;
import is.codion.swing.common.ui.control.Controls;
import is.codion.swing.common.ui.key.KeyEvents;
import is.codion.swing.framework.model.SwingEntityTableModel;
import is.codion.swing.framework.ui.component.EntitySearchField;
import javax.swing.JComponent;
import javax.swing.JPanel;
import javax.swing.JSpinner;
import javax.swing.SpinnerListModel;
import javax.swing.SwingConstants;
import java.awt.BorderLayout;
import java.awt.event.InputEvent;
import java.awt.event.KeyEvent;
import java.time.LocalDate;
import java.time.Month;
import java.time.YearMonth;
import java.time.format.TextStyle;
import java.util.Arrays;
import java.util.Collection;
import java.util.HashMap;
import java.util.List;
import java.util.Locale;
import java.util.Map;
import java.util.ResourceBundle;
import java.util.function.Consumer;
import java.util.stream.Stream;
import static is.codion.swing.common.ui.component.Components.borderLayoutPanel;
import static is.codion.swing.common.ui.component.Components.flexibleGridLayoutPanel;
import static is.codion.swing.common.ui.component.table.ConditionPanel.ConditionView.ADVANCED;
import static is.codion.swing.common.ui.component.table.FilterTableConditionPanel.filterTableConditionPanel;
import static is.codion.swing.common.ui.control.Control.command;
import static java.time.Month.DECEMBER;
import static java.time.Month.JANUARY;
import static java.util.Objects.requireNonNull;
import static java.util.ResourceBundle.getBundle;
import static javax.swing.BorderFactory.createEmptyBorder;
import static javax.swing.BorderFactory.createTitledBorder;
import static javax.swing.border.TitledBorder.CENTER;
import static javax.swing.border.TitledBorder.DEFAULT_POSITION;
final class InvoiceConditionPanel extends TableConditionPanel<Attribute<?>> {
private static final ResourceBundle BUNDLE = getBundle(InvoiceConditionPanel.class.getName());
private final FilterTableConditionPanel<Attribute<?>> advancedConditionPanel;
private final SimpleConditionPanel simpleConditionPanel;
InvoiceConditionPanel(SwingEntityTableModel tableModel,
Map<Attribute<?>, ConditionPanel<?>> conditionPanels,
FilterTableColumnModel<Attribute<?>> columnModel,
Consumer<TableConditionPanel<Attribute<?>>> onPanelInitialized) {
super(tableModel.queryModel().condition().conditionModel(),
attribute -> columnModel.column(attribute).getHeaderValue().toString());
setLayout(new BorderLayout());
tableModel.queryModel().condition().persist().add(Invoice.DATE);
this.simpleConditionPanel = new SimpleConditionPanel(tableModel);
this.advancedConditionPanel = filterTableConditionPanel(tableModel.queryModel().condition().conditionModel(),
conditionPanels, columnModel, onPanelInitialized);
view().link(advancedConditionPanel.view());
}
@Override
public Map<Attribute<?>, ConditionPanel<?>> panels() {
Map<Attribute<?>, ConditionPanel<?>> conditionPanels =
new HashMap<>(advancedConditionPanel.panels());
conditionPanels.putAll(simpleConditionPanel.panels());
return conditionPanels;
}
@Override
public Map<Attribute<?>, ConditionPanel<?>> selectable() {
return view().isEqualTo(ADVANCED) ? advancedConditionPanel.selectable() : simpleConditionPanel.panels();
}
@Override
public ConditionPanel<?> panel(Attribute<?> attribute) {
if (view().isNotEqualTo(ADVANCED)) {
return simpleConditionPanel.panel(attribute);
}
return advancedConditionPanel.panel(attribute);
}
@Override
public Controls controls() {
return advancedConditionPanel.controls();
}
@Override
protected void onViewChanged(ConditionView conditionView) {
removeAll();
switch (conditionView) {
case SIMPLE:
add(simpleConditionPanel, BorderLayout.CENTER);
simpleConditionPanel.activate();
break;
case ADVANCED:
add(advancedConditionPanel, BorderLayout.CENTER);
if (simpleConditionPanel.customerConditionPanel.isFocused()) {
advancedConditionPanel.panel(Invoice.CUSTOMER_FK).requestInputFocus();
}
else if (simpleConditionPanel.dateConditionPanel.isFocused()) {
advancedConditionPanel.panel(Invoice.DATE).requestInputFocus();
}
break;
default:
break;
}
revalidate();
}
private static final class SimpleConditionPanel extends JPanel {
private final Map<Attribute<?>, ConditionPanel<?>> conditionPanels = new HashMap<>();
private final CustomerConditionPanel customerConditionPanel;
private final DateConditionPanel dateConditionPanel;
private SimpleConditionPanel(SwingEntityTableModel tableModel) {
super(new BorderLayout());
setBorder(createEmptyBorder(5, 5, 5, 5));
EntityTableConditionModel entityConditionModel = tableModel.queryModel().condition();
ForeignKeyConditionModel customerConditionModel = entityConditionModel.get(Invoice.CUSTOMER_FK);
customerConditionPanel = new CustomerConditionPanel(customerConditionModel, tableModel.entityDefinition());
dateConditionPanel = new DateConditionPanel(entityConditionModel.get(Invoice.DATE));
dateConditionPanel.yearValue.addListener(tableModel.items()::refresh);
dateConditionPanel.monthValue.addListener(tableModel.items()::refresh);
conditionPanels.put(Invoice.CUSTOMER_FK, customerConditionPanel);
conditionPanels.put(Invoice.DATE, dateConditionPanel);
initializeUI();
}
private void initializeUI() {
add(borderLayoutPanel()
.westComponent(borderLayoutPanel()
.westComponent(customerConditionPanel)
.centerComponent(dateConditionPanel)
.build())
.build(), BorderLayout.CENTER);
}
private Map<Attribute<?>, ConditionPanel<?>> panels() {
return conditionPanels;
}
private ConditionPanel<?> panel(Attribute<?> attribute) {
requireNonNull(attribute);
ConditionPanel<?> conditionPanel = panels().get(attribute);
if (conditionPanel == null) {
throw new IllegalStateException("No condition panel available for " + attribute);
}
return conditionPanel;
}
private void activate() {
customerConditionPanel.model().operator().set(Operator.IN);
dateConditionPanel.model().operator().set(Operator.BETWEEN);
customerConditionPanel.requestInputFocus();
}
private static final class CustomerConditionPanel extends ConditionPanel<Entity> {
private final EntitySearchField searchField;
private CustomerConditionPanel(ForeignKeyConditionModel conditionModel, EntityDefinition definition) {
super(conditionModel);
setLayout(new BorderLayout());
setBorder(createTitledBorder(createEmptyBorder(), definition.attributes().definition(Invoice.CUSTOMER_FK).caption()));
conditionModel.operands().in().value().link(conditionModel.operands().equal());
searchField = EntitySearchField.builder(conditionModel.inSearchModel())
.multiSelection()
.columns(25)
.build();
add(searchField, BorderLayout.CENTER);
}
@Override
public Collection<JComponent> components() {
return List.of(searchField);
}
@Override
public void requestInputFocus() {
searchField.requestFocusInWindow();
}
@Override
protected void onViewChanged(ConditionView conditionView) {}
private boolean isFocused() {
return searchField.hasFocus();
}
}
private static final class DateConditionPanel extends ConditionPanel<LocalDate> {
private final ComponentValue<Integer, NumberField<Integer>> yearValue = Components.integerField()
.value(LocalDate.now().getYear())
.listener(this::updateCondition)
.focusable(false)
.columns(4)
.horizontalAlignment(SwingConstants.CENTER)
.buildValue();
private final ComponentValue<Month, JSpinner> monthValue = Components.<Month>itemSpinner(new MonthSpinnerModel())
.listener(this::updateCondition)
.editable(false)
.columns(3)
.horizontalAlignment(SwingConstants.LEFT)
.keyEvent(KeyEvents.builder(KeyEvent.VK_UP)
.modifiers(InputEvent.CTRL_DOWN_MASK)
.action(command(this::incrementYear)))
.keyEvent(KeyEvents.builder(KeyEvent.VK_DOWN)
.modifiers(InputEvent.CTRL_DOWN_MASK)
.action(command(this::decrementYear)))
.buildValue();
private DateConditionPanel(ConditionModel<LocalDate> conditionModel) {
super(conditionModel);
model().operator().set(Operator.BETWEEN);
updateCondition();
initializeUI();
}
@Override
protected void onViewChanged(ConditionView conditionView) {}
private void initializeUI() {
setLayout(new BorderLayout());
add(flexibleGridLayoutPanel(1, 2)
.add(borderLayoutPanel()
.centerComponent(yearValue.component())
.border(createTitledBorder(createEmptyBorder(),
BUNDLE.getString("year"), CENTER, DEFAULT_POSITION))
.build())
.add(borderLayoutPanel()
.centerComponent(monthValue.component())
.border(createTitledBorder(createEmptyBorder(),
BUNDLE.getString("month")))
.build())
.build(), BorderLayout.CENTER);
}
private void incrementYear() {
yearValue.map(year -> year + 1);
}
private void decrementYear() {
yearValue.map(year -> year - 1);
}
@Override
public Collection<JComponent> components() {
return List.of(yearValue.component(), monthValue.component());
}
@Override
public void requestInputFocus() {
monthValue.component().requestFocusInWindow();
}
private void updateCondition() {
model().operands().lower().set(lower());
model().operands().upper().set(upper());
}
private LocalDate lower() {
int year = yearValue.optional().orElse(LocalDate.now().getYear());
Month month = monthValue.optional().orElse(JANUARY);
return LocalDate.of(year, month, 1);
}
private LocalDate upper() {
int year = yearValue.optional().orElse(LocalDate.now().getYear());
Month month = monthValue.optional().orElse(DECEMBER);
YearMonth yearMonth = YearMonth.of(year, month);
return LocalDate.of(year, month, yearMonth.lengthOfMonth());
}
private boolean isFocused() {
return yearValue.component().hasFocus() || monthValue.component().hasFocus();
}
private static final class MonthSpinnerModel extends SpinnerListModel {
private MonthSpinnerModel() {
super(createMonthsList());
}
private static List<Item<Month>> createMonthsList() {
return Stream.concat(Stream.of(Item.<Month>item(null, "")), Arrays.stream(Month.values())
.map(month -> Item.item(month, month.getDisplayName(TextStyle.SHORT, Locale.getDefault()))))
.toList();
}
}
}
}
}
Invoice Line
SQL
CREATE TABLE CHINOOK.INVOICELINE
(
ID LONG GENERATED BY DEFAULT AS IDENTITY,
INVOICE_ID LONG NOT NULL,
TRACK_ID LONG NOT NULL,
UNITPRICE DOUBLE NOT NULL,
QUANTITY INTEGER NOT NULL,
CONSTRAINT PK_INVOICELINE PRIMARY KEY (ID),
CONSTRAINT FK_TRACK_INVOICELINE FOREIGN KEY (TRACK_ID) REFERENCES CHINOOK.TRACK(ID),
CONSTRAINT FK_INVOICE_INVOICELINE FOREIGN KEY (INVOICE_ID) REFERENCES CHINOOK.INVOICE(ID),
CONSTRAINT UK_INVOICELINE_INVOICE_TRACK UNIQUE (INVOICE_ID, TRACK_ID)
);
Domain
API
interface InvoiceLine {
EntityType TYPE = DOMAIN.entityType("chinook.invoiceline", InvoiceLine.class.getName());
Column<Long> ID = TYPE.longColumn("id");
Column<Long> INVOICE_ID = TYPE.longColumn("invoice_id");
Column<Long> TRACK_ID = TYPE.longColumn("track_id");
Column<BigDecimal> UNITPRICE = TYPE.bigDecimalColumn("unitprice");
Column<Integer> QUANTITY = TYPE.integerColumn("quantity");
Column<BigDecimal> TOTAL = TYPE.bigDecimalColumn("total");
ForeignKey INVOICE_FK = TYPE.foreignKey("invoice_fk", INVOICE_ID, Invoice.ID);
ForeignKey TRACK_FK = TYPE.foreignKey("track_fk", TRACK_ID, Track.ID);
}
final class InvoiceLineTotalProvider
implements DerivedAttribute.Provider<BigDecimal> {
@Serial
private static final long serialVersionUID = 1;
@Override
public BigDecimal get(SourceValues values) {
Integer quantity = values.get(InvoiceLine.QUANTITY);
BigDecimal unitPrice = values.get(InvoiceLine.UNITPRICE);
if (unitPrice == null || quantity == null) {
return null;
}
return unitPrice.multiply(BigDecimal.valueOf(quantity));
}
}
Implementation
EntityDefinition invoiceLine() {
return InvoiceLine.TYPE.define(
InvoiceLine.ID.define()
.primaryKey(),
InvoiceLine.INVOICE_ID.define()
.column()
.nullable(false),
InvoiceLine.INVOICE_FK.define()
.foreignKey()
.referenceDepth(0)
.hidden(true),
InvoiceLine.TRACK_ID.define()
.column()
.nullable(false),
InvoiceLine.TRACK_FK.define()
.foreignKey()
.attributes(Track.NAME, Track.UNITPRICE),
InvoiceLine.UNITPRICE.define()
.column()
.nullable(false),
InvoiceLine.QUANTITY.define()
.column()
.nullable(false)
.defaultValue(1),
InvoiceLine.TOTAL.define()
.derived(InvoiceLine.QUANTITY, InvoiceLine.UNITPRICE)
.provider(new InvoiceLineTotalProvider()))
.keyGenerator(identity())
.build();
}
Model
InvoiceLineEditModel
package is.codion.demos.chinook.model;
import is.codion.common.event.Event;
import is.codion.common.observable.Observer;
import is.codion.demos.chinook.domain.api.Chinook.Invoice;
import is.codion.demos.chinook.domain.api.Chinook.InvoiceLine;
import is.codion.demos.chinook.domain.api.Chinook.Track;
import is.codion.framework.db.EntityConnection;
import is.codion.framework.db.EntityConnectionProvider;
import is.codion.framework.domain.entity.Entity;
import is.codion.swing.framework.model.SwingEntityEditModel;
import java.util.Collection;
import static is.codion.framework.db.EntityConnection.transaction;
import static is.codion.framework.domain.entity.Entity.distinct;
import static is.codion.framework.domain.entity.Entity.primaryKeys;
public final class InvoiceLineEditModel extends SwingEntityEditModel {
private final Event<Collection<Entity>> totalsUpdatedEvent = Event.event();
public InvoiceLineEditModel(EntityConnectionProvider connectionProvider) {
super(InvoiceLine.TYPE, connectionProvider);
// We populate the unit price when the track is edited
editor().value(InvoiceLine.TRACK_FK).edited().addConsumer(this::setUnitPrice);
}
Observer<Collection<Entity>> totalsUpdated() {
return totalsUpdatedEvent.observer();
}
@Override
protected Collection<Entity> insert(Collection<Entity> invoiceLines, EntityConnection connection) {
// Use a transaction to update the invoice totals when an invoice line is inserted
return transaction(connection, () -> updateTotals(connection.insertSelect(invoiceLines), connection));
}
@Override
protected Collection<Entity> update(Collection<Entity> invoiceLines, EntityConnection connection) {
// Use a transaction to update the invoice totals when an invoice line is updated
return transaction(connection, () -> updateTotals(connection.updateSelect(invoiceLines), connection));
}
@Override
protected void delete(Collection<Entity> invoiceLines, EntityConnection connection) {
// Use a transaction to update the invoice totals when an invoice line is deleted
transaction(connection, () -> {
connection.delete(primaryKeys(invoiceLines));
updateTotals(invoiceLines, connection);
});
}
private void setUnitPrice(Entity track) {
editor().value(InvoiceLine.UNITPRICE).set(track == null ? null : track.get(Track.UNITPRICE));
}
private Collection<Entity> updateTotals(Collection<Entity> invoiceLines, EntityConnection connection) {
// Get the IDs of the invoices that need their totals updated
Collection<Long> invoiceIds = distinct(InvoiceLine.INVOICE_ID, invoiceLines);
// Execute the UPDATE_TOTALS function, which returns the updated invoices
Collection<Entity> updatedInvoices = connection.execute(Invoice.UPDATE_TOTALS, invoiceIds);
// Trigger the update totals event with the updated invoices, see InvoiceModel
totalsUpdatedEvent.accept(updatedInvoices);
return invoiceLines;
}
}
InvoiceLineEditModelTest
package is.codion.demos.chinook.model;
import is.codion.common.user.User;
import is.codion.demos.chinook.domain.ChinookImpl;
import is.codion.demos.chinook.domain.api.Chinook.Customer;
import is.codion.demos.chinook.domain.api.Chinook.Invoice;
import is.codion.demos.chinook.domain.api.Chinook.InvoiceLine;
import is.codion.demos.chinook.domain.api.Chinook.Track;
import is.codion.framework.db.EntityConnection;
import is.codion.framework.db.EntityConnectionProvider;
import is.codion.framework.db.local.LocalEntityConnectionProvider;
import is.codion.framework.domain.entity.Entities;
import is.codion.framework.domain.entity.Entity;
import org.junit.jupiter.api.Test;
import java.math.BigDecimal;
import java.time.LocalDate;
import static is.codion.framework.domain.entity.condition.Condition.key;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertNull;
public final class InvoiceLineEditModelTest {
@Test
void updateTotals() {
try (EntityConnectionProvider connectionProvider = createConnectionProvider()) {
EntityConnection connection = connectionProvider.connection();
Entity invoice = createInvoice(connection);
assertNull(invoice.get(Invoice.TOTAL));
Entity battery = connection.selectSingle(Track.NAME.equalToIgnoreCase("battery"));
InvoiceLineEditModel editModel = new InvoiceLineEditModel(connectionProvider);
editModel.editor().value(InvoiceLine.INVOICE_FK).set(invoice);
editModel.editor().value(InvoiceLine.TRACK_FK).set(battery);
Entity invoiceLineBattery = editModel.insert();
invoice = connection.selectSingle(key(invoice.primaryKey()));
assertEquals(battery.get(Track.UNITPRICE), invoice.get(Invoice.TOTAL));
Entity orion = connection.selectSingle(Track.NAME.equalToIgnoreCase("orion"));
editModel.editor().defaults();
editModel.editor().value(InvoiceLine.INVOICE_FK).set(invoice);
editModel.editor().value(InvoiceLine.TRACK_FK).set(orion);
editModel.insert();
invoice = connection.selectSingle(key(invoice.primaryKey()));
assertEquals(battery.get(Track.UNITPRICE).add(orion.get(Track.UNITPRICE)), invoice.get(Invoice.TOTAL));
Entity theCallOfKtulu = connection.selectSingle(Track.NAME.equalToIgnoreCase("the call of ktulu"));
theCallOfKtulu.set(Track.UNITPRICE, BigDecimal.valueOf(2));
theCallOfKtulu = connection.updateSelect(theCallOfKtulu);
editModel.editor().set(invoiceLineBattery);
editModel.editor().value(InvoiceLine.TRACK_FK).set(theCallOfKtulu);
editModel.update();
invoice = connection.selectSingle(key(invoice.primaryKey()));
assertEquals(orion.get(Track.UNITPRICE).add(theCallOfKtulu.get(Track.UNITPRICE)), invoice.get(Invoice.TOTAL));
editModel.delete();
invoice = connection.selectSingle(key(invoice.primaryKey()));
assertEquals(orion.get(Track.UNITPRICE), invoice.get(Invoice.TOTAL));
}
}
private static Entity createInvoice(EntityConnection connection) {
Entities entities = connection.entities();
return connection.insertSelect(entities.builder(Invoice.TYPE)
.with(Invoice.CUSTOMER_FK, connection.insertSelect(entities.builder(Customer.TYPE)
.with(Customer.FIRSTNAME, "Björn")
.with(Customer.LASTNAME, "Sigurðsson")
.with(Customer.EMAIL, "email@email.com")
.build()))
.with(Invoice.DATE, LocalDate.now())
.build());
}
private static EntityConnectionProvider createConnectionProvider() {
return LocalEntityConnectionProvider.builder()
.domain(new ChinookImpl())
.user(User.parse("scott:tiger"))
.build();
}
}
UI
InvoiceLineEditPanel
package is.codion.demos.chinook.ui;
import is.codion.demos.chinook.domain.api.Chinook.InvoiceLine;
import is.codion.swing.framework.model.SwingEntityEditModel;
import is.codion.swing.framework.ui.EntityEditPanel;
import javax.swing.JLabel;
import javax.swing.JPanel;
import javax.swing.JTextField;
import javax.swing.JToolBar;
import java.awt.BorderLayout;
import static is.codion.swing.common.ui.component.Components.flexibleGridLayoutPanel;
import static is.codion.swing.common.ui.component.Components.toolBar;
import static is.codion.swing.common.ui.component.text.TextComponents.preferredTextFieldHeight;
import static is.codion.swing.common.ui.layout.Layouts.borderLayout;
import static is.codion.swing.framework.ui.EntityEditPanel.ControlKeys.INSERT;
import static is.codion.swing.framework.ui.EntityEditPanel.ControlKeys.UPDATE;
public final class InvoiceLineEditPanel extends EntityEditPanel {
private final JTextField tableSearchField;
public InvoiceLineEditPanel(SwingEntityEditModel editModel, JTextField tableSearchField) {
super(editModel);
this.tableSearchField = tableSearchField;
// We do not want the track to persist when the model is cleared.
editModel.editor().value(InvoiceLine.TRACK_FK).persist().set(false);
}
@Override
protected void initializeUI() {
focus().initial().set(InvoiceLine.TRACK_FK);
createSearchField(InvoiceLine.TRACK_FK)
.selectorFactory(new TrackSelectorFactory())
.columns(15);
createTextField(InvoiceLine.QUANTITY)
.selectAllOnFocusGained(true)
.columns(2)
// Set the INSERT control as the quantity field
// action, triggering insert on Enter
.action(control(INSERT).get());
JToolBar updateToolBar = toolBar()
.floatable(false)
.action(control(UPDATE).get())
.preferredHeight(preferredTextFieldHeight())
.build();
JPanel centerPanel = flexibleGridLayoutPanel(1, 0)
.add(createInputPanel(InvoiceLine.TRACK_FK))
.add(createInputPanel(InvoiceLine.QUANTITY))
.add(createInputPanel(new JLabel(" "), updateToolBar))
.add(createInputPanel(new JLabel(" "), tableSearchField))
.build();
setLayout(borderLayout());
add(centerPanel, BorderLayout.CENTER);
}
}
InvoiceLineTablePanel
package is.codion.demos.chinook.ui;
import is.codion.demos.chinook.domain.api.Chinook.InvoiceLine;
import is.codion.swing.framework.model.SwingEntityTableModel;
import is.codion.swing.framework.ui.EntityTablePanel;
import javax.swing.JTable;
import java.awt.Dimension;
public final class InvoiceLineTablePanel extends EntityTablePanel {
public InvoiceLineTablePanel(SwingEntityTableModel tableModel) {
super(tableModel, config -> config
.includeSouthPanel(false)
.includeConditions(false)
.includeFilters(false)
// The invoice should not be editable via the popup menu
.editable(attributes -> attributes.remove(InvoiceLine.INVOICE_FK))
// We provide a custom component to use when
// the track is edited via the popup menu.
.editComponentFactory(InvoiceLine.TRACK_FK, new TrackEditComponentFactory(InvoiceLine.TRACK_FK)));
table().setAutoResizeMode(JTable.AUTO_RESIZE_ALL_COLUMNS);
setPreferredSize(new Dimension(360, 40));
}
}
Employees

Employee
SQL
CREATE TABLE CHINOOK.EMPLOYEE
(
ID LONG GENERATED BY DEFAULT AS IDENTITY,
LASTNAME VARCHAR(20) NOT NULL,
FIRSTNAME VARCHAR(20) NOT NULL,
TITLE VARCHAR(30),
REPORTSTO_ID LONG,
BIRTHDATE DATE,
HIREDATE DATE,
ADDRESS VARCHAR(70),
CITY VARCHAR(40),
STATE VARCHAR(40),
COUNTRY VARCHAR(40),
POSTALCODE VARCHAR(10),
PHONE VARCHAR(24),
FAX VARCHAR(24),
EMAIL VARCHAR(60) NOT NULL,
CONSTRAINT PK_EMPLOYEE PRIMARY KEY (ID),
CONSTRAINT FK_EMPLOYEE_REPORTS_TO FOREIGN KEY (REPORTSTO_ID) REFERENCES CHINOOK.EMPLOYEE(ID)
);
Domain
API
interface Employee {
EntityType TYPE = DOMAIN.entityType("chinook.employee", Employee.class.getName());
Column<Long> ID = TYPE.longColumn("id");
Column<String> LASTNAME = TYPE.stringColumn("lastname");
Column<String> FIRSTNAME = TYPE.stringColumn("firstname");
Column<String> TITLE = TYPE.stringColumn("title");
Column<Long> REPORTSTO = TYPE.longColumn("reportsto_id");
Column<LocalDate> BIRTHDATE = TYPE.localDateColumn("birthdate");
Column<LocalDate> HIREDATE = TYPE.localDateColumn("hiredate");
Column<String> ADDRESS = TYPE.stringColumn("address");
Column<String> CITY = TYPE.stringColumn("city");
Column<String> STATE = TYPE.stringColumn("state");
Column<String> COUNTRY = TYPE.stringColumn("country");
Column<String> POSTALCODE = TYPE.stringColumn("postalcode");
Column<String> PHONE = TYPE.stringColumn("phone");
Column<String> FAX = TYPE.stringColumn("fax");
Column<String> EMAIL = TYPE.stringColumn("email");
ForeignKey REPORTSTO_FK = TYPE.foreignKey("reportsto_fk", REPORTSTO, Employee.ID);
}
final class EmailValidator extends DefaultEntityValidator {
private static final Pattern EMAIL_PATTERN = Pattern.compile("^(.+)@(.+)$");
private static final ResourceBundle BUNDLE = getBundle(Chinook.class.getName());
private final Column<String> emailColumn;
public EmailValidator(Column<String> emailColumn) {
this.emailColumn = emailColumn;
}
@Override
public <T> void validate(Entity entity, Attribute<T> attribute) {
super.validate(entity, attribute);
if (attribute.equals(emailColumn)) {
validateEmail(entity.get(emailColumn));
}
}
private void validateEmail(String email) {
if (!EMAIL_PATTERN.matcher(email).matches()) {
throw new ValidationException(emailColumn, email, BUNDLE.getString("invalid_email"));
}
}
}
Implementation
EntityDefinition employee() {
return Employee.TYPE.define(
Employee.ID.define()
.primaryKey(),
Employee.LASTNAME.define()
.column()
.searchable(true)
.nullable(false)
.maximumLength(20),
Employee.FIRSTNAME.define()
.column()
.searchable(true)
.nullable(false)
.maximumLength(20),
Employee.TITLE.define()
.column()
.maximumLength(30),
Employee.REPORTSTO.define()
.column(),
Employee.REPORTSTO_FK.define()
.foreignKey()
.attributes(Employee.FIRSTNAME, Employee.LASTNAME),
Employee.BIRTHDATE.define()
.column(),
Employee.HIREDATE.define()
.column()
.localeDateTimePattern(LocaleDateTimePattern.builder()
.delimiterDot()
.yearFourDigits()
.build()),
Employee.ADDRESS.define()
.column()
.maximumLength(70),
Employee.CITY.define()
.column()
.maximumLength(40),
Employee.STATE.define()
.column()
.maximumLength(40),
Employee.COUNTRY.define()
.column()
.maximumLength(40),
Employee.POSTALCODE.define()
.column()
.maximumLength(10),
Employee.PHONE.define()
.column()
.maximumLength(24),
Employee.FAX.define()
.column()
.maximumLength(24),
Employee.EMAIL.define()
.column()
.searchable(true)
.nullable(false)
.maximumLength(60))
.keyGenerator(identity())
.validator(new EmailValidator(Employee.EMAIL))
.orderBy(ascending(Employee.LASTNAME, Employee.FIRSTNAME))
.stringFactory(StringFactory.builder()
.value(Employee.LASTNAME)
.text(", ")
.value(Employee.FIRSTNAME)
.build())
.build();
}
UI
EmployeeEditPanel
package is.codion.demos.chinook.ui;
import is.codion.swing.framework.model.SwingEntityEditModel;
import is.codion.swing.framework.ui.EntityEditPanel;
import javax.swing.JPanel;
import static is.codion.demos.chinook.domain.api.Chinook.Employee;
import static is.codion.swing.common.ui.component.Components.flexibleGridLayoutPanel;
import static is.codion.swing.common.ui.component.Components.gridLayoutPanel;
import static is.codion.swing.common.ui.key.TransferFocusOnEnter.BACKWARD;
import static is.codion.swing.common.ui.layout.Layouts.flexibleGridLayout;
public final class EmployeeEditPanel extends EntityEditPanel {
public EmployeeEditPanel(SwingEntityEditModel editModel) {
super(editModel);
}
@Override
protected void initializeUI() {
focus().initial().set(Employee.FIRSTNAME);
createTextField(Employee.FIRSTNAME)
.columns(6);
createTextField(Employee.LASTNAME)
.columns(6);
createTemporalFieldPanel(Employee.BIRTHDATE)
.columns(6);
createTemporalFieldPanel(Employee.HIREDATE)
.columns(6);
createTextField(Employee.TITLE)
.columns(8);
createTextField(Employee.ADDRESS);
createTextField(Employee.CITY)
.columns(8);
createTextField(Employee.POSTALCODE)
.columns(4);
createTextField(Employee.STATE)
.columns(4)
.upperCase(true);
createTextField(Employee.COUNTRY)
.columns(8);
createTextField(Employee.PHONE)
.columns(12);
createTextField(Employee.FAX)
.columns(12);
createTextField(Employee.EMAIL)
.columns(12);
createComboBox(Employee.REPORTSTO_FK)
.preferredWidth(120)
// Only transfer focus backward on Enter, this way
// the Enter key without any modifiers will trigger
// the default dialog button, for inserting and updating
.transferFocusOnEnter(BACKWARD);
JPanel firstLastNamePanel = gridLayoutPanel(1, 2)
.add(createInputPanel(Employee.FIRSTNAME))
.add(createInputPanel(Employee.LASTNAME))
.build();
JPanel birthHireDatePanel = gridLayoutPanel(1, 2)
.add(createInputPanel(Employee.BIRTHDATE))
.add(createInputPanel(Employee.HIREDATE))
.build();
JPanel cityPostalCodePanel = flexibleGridLayoutPanel(1, 2)
.add(createInputPanel(Employee.CITY))
.add(createInputPanel(Employee.POSTALCODE))
.build();
JPanel stateCountryPanel = flexibleGridLayoutPanel(1, 2)
.add(createInputPanel(Employee.STATE))
.add(createInputPanel(Employee.COUNTRY))
.build();
setLayout(flexibleGridLayout(4, 3));
add(firstLastNamePanel);
add(birthHireDatePanel);
addInputPanel(Employee.TITLE);
addInputPanel(Employee.ADDRESS);
add(cityPostalCodePanel);
add(stateCountryPanel);
addInputPanel(Employee.PHONE);
addInputPanel(Employee.FAX);
addInputPanel(Employee.EMAIL);
addInputPanel(Employee.REPORTSTO_FK);
}
}
EmployeeTablePanel
package is.codion.demos.chinook.ui;
import is.codion.framework.domain.entity.attribute.Attribute;
import is.codion.swing.common.ui.component.table.FilterTableColumnModel;
import is.codion.swing.framework.model.SwingEntityTableModel;
import is.codion.swing.framework.ui.EntityEditPanel;
import is.codion.swing.framework.ui.EntityTablePanel;
import static is.codion.swing.framework.ui.EntityDialogs.editEntityDialog;
public final class EmployeeTablePanel extends EntityTablePanel {
public EmployeeTablePanel(SwingEntityTableModel tableModel) {
// We provide a EmployeeEditPanel instance, which is then accessible
// via double click, popup menu (Add/Edit) or keyboard shortcuts:
// INSERT to add a new employee or CTRL-INSERT to edit the selected one.
super(tableModel, new EmployeeEditPanel(tableModel.editModel()));
}
@Override
protected void setupControls() {
// Replace the default EDIT control command with one that sets
// the initial focus according the selected table column
control(ControlKeys.EDIT).map(control -> control.copy(this::edit).build());
}
private void edit() {
editEntityDialog(editPanel())
.owner(this)
.onShown(this::requestEditFocus)
.show();
}
private void requestEditFocus(EntityEditPanel editPanel) {
FilterTableColumnModel<Attribute<?>> columnModel = table().columnModel();
int columnIndex = columnModel.getSelectionModel().getMinSelectionIndex();
Attribute<?> attribute = columnModel.getColumn(columnIndex).identifier();
editPanel.focus().request(attribute);
}
}
Albums

We start with a few support tables, artist, genre and media type.
Artist
SQL
CREATE TABLE CHINOOK.ARTIST
(
ID LONG GENERATED BY DEFAULT AS IDENTITY,
NAME VARCHAR(120) NOT NULL,
CONSTRAINT PK_ARTIST PRIMARY KEY (ID),
CONSTRAINT UK_ARTIST UNIQUE (NAME)
);
Domain
API
interface Artist {
EntityType TYPE = DOMAIN.entityType("chinook.artist", Artist.class.getName());
Column<Long> ID = TYPE.longColumn("id");
Column<String> NAME = TYPE.stringColumn("name");
Column<Integer> NUMBER_OF_ALBUMS = TYPE.integerColumn("number_of_albums");
Column<Integer> NUMBER_OF_TRACKS = TYPE.integerColumn("number_of_tracks");
static Dto dto(Entity artist) {
return artist == null ? null :
new Dto(artist.get(ID), artist.get(NAME));
}
record Dto(Long id, String name) {
public Entity entity(Entities entities) {
return entities.builder(TYPE)
.with(ID, id)
.with(NAME, name)
.build();
}
}
}
Implementation
EntityDefinition artist() {
return Artist.TYPE.define(
Artist.ID.define()
.primaryKey(),
Artist.NAME.define()
.column()
.searchable(true)
.nullable(false)
.maximumLength(120),
Artist.NUMBER_OF_ALBUMS.define()
.subquery("""
SELECT COUNT(*)
FROM chinook.album
WHERE album.artist_id = artist.id"""),
Artist.NUMBER_OF_TRACKS.define()
.subquery("""
SELECT COUNT(*)
FROM chinook.track
JOIN chinook.album ON track.album_id = album.id
WHERE album.artist_id = artist.id"""))
.keyGenerator(identity())
.orderBy(ascending(Artist.NAME))
.stringFactory(Artist.NAME)
.build();
}
Model
package is.codion.demos.chinook.model;
import is.codion.demos.chinook.domain.api.Chinook.Album;
import is.codion.demos.chinook.domain.api.Chinook.Artist;
import is.codion.framework.db.EntityConnection;
import is.codion.framework.db.EntityConnectionProvider;
import is.codion.framework.domain.entity.Entity;
import is.codion.framework.model.EntityEditModel;
import is.codion.swing.framework.model.SwingEntityTableModel;
import java.util.List;
import static is.codion.framework.db.EntityConnection.Update.where;
import static is.codion.framework.db.EntityConnection.transaction;
import static is.codion.framework.domain.entity.Entity.primaryKeys;
public final class ArtistTableModel extends SwingEntityTableModel {
public ArtistTableModel(EntityConnectionProvider connectionProvider) {
super(Artist.TYPE, connectionProvider);
}
public void combine(List<Entity> artistsToDelete, Entity artistToKeep) {
EntityConnection connection = connection();
transaction(connection, () -> {
connection.update(where(Album.ARTIST_FK.in(artistsToDelete))
.set(Album.ARTIST_ID, artistToKeep.primaryKey().value())
.build());
connection.delete(primaryKeys(artistsToDelete));
});
}
public void onCombined(List<Entity> artistsToDelete, Entity artistToKeep) {
selection().item().set(artistToKeep);
items().remove(artistsToDelete);
EntityEditModel.editEvents().deleted(Artist.TYPE).accept(artistsToDelete);
}
}
UI
ArtistEditPanel
package is.codion.demos.chinook.ui;
import is.codion.demos.chinook.domain.api.Chinook.Artist;
import is.codion.swing.framework.model.SwingEntityEditModel;
import is.codion.swing.framework.ui.EntityEditPanel;
import static is.codion.swing.common.ui.layout.Layouts.gridLayout;
public final class ArtistEditPanel extends EntityEditPanel {
public ArtistEditPanel(SwingEntityEditModel editModel) {
super(editModel);
}
@Override
protected void initializeUI() {
focus().initial().set(Artist.NAME);
createTextField(Artist.NAME)
.columns(18);
setLayout(gridLayout(1, 1));
addInputPanel(Artist.NAME);
}
}
ArtistTablePanel
package is.codion.demos.chinook.ui;
import is.codion.common.Text;
import is.codion.common.model.CancelException;
import is.codion.demos.chinook.domain.api.Chinook.Album;
import is.codion.demos.chinook.model.ArtistTableModel;
import is.codion.framework.domain.entity.Entity;
import is.codion.swing.common.ui.control.Control;
import is.codion.swing.common.ui.dialog.Dialogs;
import is.codion.swing.framework.model.SwingEntityTableModel;
import is.codion.swing.framework.ui.EntityTablePanel;
import javax.swing.JOptionPane;
import java.util.ArrayList;
import java.util.List;
import java.util.Objects;
import static is.codion.framework.db.EntityConnection.Count.where;
import static is.codion.swing.common.ui.dialog.Dialogs.progressWorkerDialog;
import static java.lang.System.lineSeparator;
import static java.util.stream.Collectors.joining;
import static javax.swing.JOptionPane.showConfirmDialog;
import static javax.swing.JOptionPane.showMessageDialog;
public final class ArtistTablePanel extends EntityTablePanel {
public ArtistTablePanel(SwingEntityTableModel tableModel) {
super(tableModel);
configurePopupMenu(layout -> layout.clear()
.control(createCombineControl())
.separator()
.defaults());
}
private Control createCombineControl() {
return Control.builder()
.command(this::combineSelected)
.caption("Combine...")
.enabled(tableModel().selection().multiple())
.build();
}
private void combineSelected() {
List<Entity> selectedArtists = tableModel().selection().items().get();
Entity artistToKeep = Dialogs.listSelectionDialog(selectedArtists)
.owner(this)
.title("Select the artist to keep")
.comparator(Text.collator())
.selectSingle()
.orElseThrow(CancelException::new);
List<Entity> artistsToDelete = new ArrayList<>(selectedArtists);
artistsToDelete.remove(artistToKeep);
int albumCount = tableModel().connection().count(where(Album.ARTIST_FK.in(artistsToDelete)));
if (confirmCombination(artistsToDelete, artistToKeep, albumCount)) {
ArtistTableModel tableModel = (ArtistTableModel) tableModel();
progressWorkerDialog(() -> tableModel.combine(artistsToDelete, artistToKeep))
.owner(this)
.title("Updating albums...")
.onResult(() -> {
tableModel.onCombined(artistsToDelete, artistToKeep);
showMessageDialog(this, "Artists combined!");
})
.execute();
}
}
private boolean confirmCombination(List<Entity> artistsToDelete, Entity artistToKeep, int albumCount) {
StringBuilder message = new StringBuilder();
if (albumCount > 0) {
message.append("Associate ").append(albumCount).append(" albums(s) ").append(lineSeparator())
.append("with ").append(artistToKeep).append("?").append(lineSeparator());
}
message.append("Delete the following:").append(lineSeparator())
.append(artistsToDelete.stream()
.map(Objects::toString)
.collect(joining(lineSeparator()))).append(lineSeparator())
.append("while keeping: ").append(artistToKeep).append("?");
return showConfirmDialog(ArtistTablePanel.this, message,
"Confirm artist combination", JOptionPane.OK_CANCEL_OPTION) == JOptionPane.OK_OPTION;
}
}
Genre
SQL
CREATE TABLE CHINOOK.GENRE
(
ID LONG GENERATED BY DEFAULT AS IDENTITY,
NAME VARCHAR(120) NOT NULL,
CONSTRAINT PK_GENRE PRIMARY KEY (ID),
CONSTRAINT UK_GENRE UNIQUE (NAME)
);
Domain
API
interface Genre {
EntityType TYPE = DOMAIN.entityType("chinook.genre", Genre.class.getName());
Column<Long> ID = TYPE.longColumn("id");
Column<String> NAME = TYPE.stringColumn("name");
static Dto dto(Entity genre) {
return genre == null ? null :
new Dto(genre.get(ID), genre.get(NAME));
}
record Dto(Long id, String name) {
public Entity entity(Entities entities) {
return entities.builder(TYPE)
.with(ID, id)
.with(NAME, name)
.build();
}
}
}
Implementation
EntityDefinition genre() {
return Genre.TYPE.define(
Genre.ID.define()
.primaryKey(),
Genre.NAME.define()
.column()
.searchable(true)
.nullable(false)
.maximumLength(120))
.keyGenerator(identity())
.orderBy(ascending(Genre.NAME))
.stringFactory(Genre.NAME)
.smallDataset(true)
.build();
}
UI
GenreEditPanel
package is.codion.demos.chinook.ui;
import is.codion.demos.chinook.domain.api.Chinook.Genre;
import is.codion.swing.framework.model.SwingEntityEditModel;
import is.codion.swing.framework.ui.EntityEditPanel;
import static is.codion.swing.common.ui.layout.Layouts.gridLayout;
public final class GenreEditPanel extends EntityEditPanel {
public GenreEditPanel(SwingEntityEditModel editModel) {
super(editModel);
}
@Override
protected void initializeUI() {
focus().initial().set(Genre.NAME);
createTextField(Genre.NAME);
setLayout(gridLayout(1, 1));
addInputPanel(Genre.NAME);
}
}
Media Type
SQL
CREATE TABLE CHINOOK.MEDIATYPE
(
ID LONG GENERATED BY DEFAULT AS IDENTITY,
NAME VARCHAR(120) NOT NULL,
CONSTRAINT PK_MEDIATYPE PRIMARY KEY (ID),
CONSTRAINT UK_MEDIATYPE UNIQUE (NAME)
);
Domain
API
interface MediaType {
EntityType TYPE = DOMAIN.entityType("chinook.mediatype", MediaType.class.getName());
Column<Long> ID = TYPE.longColumn("id");
Column<String> NAME = TYPE.stringColumn("name");
static Dto dto(Entity mediaType) {
return mediaType == null ? null :
new Dto(mediaType.get(ID), mediaType.get(NAME));
}
record Dto(Long id, String name) {
public Entity entity(Entities entities) {
return entities.builder(TYPE)
.with(ID, id)
.with(NAME, name)
.build();
}
}
}
Implementation
EntityDefinition mediaType() {
return MediaType.TYPE.define(
MediaType.ID.define()
.primaryKey(),
MediaType.NAME.define()
.column()
.nullable(false)
.maximumLength(120))
.keyGenerator(identity())
.stringFactory(MediaType.NAME)
.smallDataset(true)
.build();
}
UI
MediaTypeEditPanel
package is.codion.demos.chinook.ui;
import is.codion.demos.chinook.domain.api.Chinook.MediaType;
import is.codion.swing.framework.model.SwingEntityEditModel;
import is.codion.swing.framework.ui.EntityEditPanel;
import static is.codion.swing.common.ui.layout.Layouts.gridLayout;
public final class MediaTypeEditPanel extends EntityEditPanel {
public MediaTypeEditPanel(SwingEntityEditModel editModel) {
super(editModel);
}
@Override
protected void initializeUI() {
focus().initial().set(MediaType.NAME);
createTextField(MediaType.NAME);
setLayout(gridLayout(1, 1));
addInputPanel(MediaType.NAME);
}
}
Artist
SQL
CREATE TABLE CHINOOK.ARTIST
(
ID LONG GENERATED BY DEFAULT AS IDENTITY,
NAME VARCHAR(120) NOT NULL,
CONSTRAINT PK_ARTIST PRIMARY KEY (ID),
CONSTRAINT UK_ARTIST UNIQUE (NAME)
);
Domain
API
interface Artist {
EntityType TYPE = DOMAIN.entityType("chinook.artist", Artist.class.getName());
Column<Long> ID = TYPE.longColumn("id");
Column<String> NAME = TYPE.stringColumn("name");
Column<Integer> NUMBER_OF_ALBUMS = TYPE.integerColumn("number_of_albums");
Column<Integer> NUMBER_OF_TRACKS = TYPE.integerColumn("number_of_tracks");
static Dto dto(Entity artist) {
return artist == null ? null :
new Dto(artist.get(ID), artist.get(NAME));
}
record Dto(Long id, String name) {
public Entity entity(Entities entities) {
return entities.builder(TYPE)
.with(ID, id)
.with(NAME, name)
.build();
}
}
}
Implementation
EntityDefinition artist() {
return Artist.TYPE.define(
Artist.ID.define()
.primaryKey(),
Artist.NAME.define()
.column()
.searchable(true)
.nullable(false)
.maximumLength(120),
Artist.NUMBER_OF_ALBUMS.define()
.subquery("""
SELECT COUNT(*)
FROM chinook.album
WHERE album.artist_id = artist.id"""),
Artist.NUMBER_OF_TRACKS.define()
.subquery("""
SELECT COUNT(*)
FROM chinook.track
JOIN chinook.album ON track.album_id = album.id
WHERE album.artist_id = artist.id"""))
.keyGenerator(identity())
.orderBy(ascending(Artist.NAME))
.stringFactory(Artist.NAME)
.build();
}
UI
package is.codion.demos.chinook.ui;
import is.codion.demos.chinook.domain.api.Chinook.Artist;
import is.codion.swing.framework.model.SwingEntityEditModel;
import is.codion.swing.framework.ui.EntityEditPanel;
import static is.codion.swing.common.ui.layout.Layouts.gridLayout;
public final class ArtistEditPanel extends EntityEditPanel {
public ArtistEditPanel(SwingEntityEditModel editModel) {
super(editModel);
}
@Override
protected void initializeUI() {
focus().initial().set(Artist.NAME);
createTextField(Artist.NAME)
.columns(18);
setLayout(gridLayout(1, 1));
addInputPanel(Artist.NAME);
}
}
Album
SQL
CREATE TABLE CHINOOK.ALBUM
(
ID LONG GENERATED BY DEFAULT AS IDENTITY,
TITLE VARCHAR(160) NOT NULL,
ARTIST_ID LONG NOT NULL,
COVER BLOB,
TAGS VARCHAR ARRAY,
CONSTRAINT PK_ALBUM PRIMARY KEY (ID),
CONSTRAINT UK_ALBUM_TITLE UNIQUE (ARTIST_ID, TITLE),
CONSTRAINT FK_ARTIST_ALBUM FOREIGN KEY (ARTIST_ID) REFERENCES CHINOOK.ARTIST(ID)
);
Domain
API
interface Album {
EntityType TYPE = DOMAIN.entityType("chinook.album", Album.class.getName());
Column<Long> ID = TYPE.longColumn("id");
Column<String> TITLE = TYPE.stringColumn("title");
Column<Long> ARTIST_ID = TYPE.longColumn("artist_id");
Column<byte[]> COVER = TYPE.byteArrayColumn("cover");
Column<Integer> NUMBER_OF_TRACKS = TYPE.integerColumn("number_of_tracks");
Column<List<String>> TAGS = TYPE.column("tags", new TypeReference<>() {});
Column<Integer> RATING = TYPE.integerColumn("rating");
ForeignKey ARTIST_FK = TYPE.foreignKey("artist_fk", ARTIST_ID, Artist.ID);
static Dto dto(Entity album) {
return album == null ? null :
new Dto(album.get(ID), album.get(TITLE),
Artist.dto(album.get(ARTIST_FK)));
}
record Dto(Long id, String title, Artist.Dto artist) {
public Entity entity(Entities entities) {
return entities.builder(TYPE)
.with(ID, id)
.with(TITLE, title)
.with(ARTIST_FK, artist.entity(entities))
.build();
}
}
}
Implementation
EntityDefinition album() {
return Album.TYPE.define(
Album.ID.define()
.primaryKey(),
Album.ARTIST_ID.define()
.column()
.nullable(false),
Album.ARTIST_FK.define()
.foreignKey()
.attributes(Artist.NAME),
Album.TITLE.define()
.column()
.searchable(true)
.nullable(false)
.maximumLength(160),
Album.COVER.define()
.column()
.format(new CoverFormatter()),
Album.NUMBER_OF_TRACKS.define()
.subquery("""
SELECT COUNT(*)
FROM chinook.track
WHERE track.album_id = album.id"""),
Album.TAGS.define()
.column()
.columnClass(Array.class, new TagsConverter(), ResultSet::getArray),
Album.RATING.define()
.subquery("""
SELECT AVG(rating)
FROM chinook.track
WHERE track.album_id = album.id"""))
.keyGenerator(identity())
.orderBy(ascending(Album.ARTIST_ID, Album.TITLE))
.stringFactory(Album.TITLE)
.build();
}
private static final class TagsConverter implements Column.Converter<List<String>, Array> {
private static final int ARRAY_VALUE_INDEX = 2;
private final ResultPacker<String> packer = resultSet -> resultSet.getString(ARRAY_VALUE_INDEX);
@Override
public Array toColumnValue(List<String> value, Statement statement) throws SQLException {
return value.isEmpty() ? null :
statement.getConnection().createArrayOf("VARCHAR", value.toArray(new Object[0]));
}
@Override
public List<String> fromColumnValue(Array columnValue) throws SQLException {
try (ResultSet resultSet = columnValue.getResultSet()) {
return packer.pack(resultSet);
}
}
}
Model
AlbumModel
package is.codion.demos.chinook.model;
import is.codion.demos.chinook.domain.api.Chinook.Album;
import is.codion.demos.chinook.domain.api.Chinook.Track;
import is.codion.framework.db.EntityConnectionProvider;
import is.codion.swing.framework.model.SwingEntityModel;
public final class AlbumModel extends SwingEntityModel {
public AlbumModel(EntityConnectionProvider connectionProvider) {
super(Album.TYPE, connectionProvider);
SwingEntityModel trackModel = new SwingEntityModel(new TrackTableModel(connectionProvider));
detailModels().add(trackModel);
TrackEditModel trackEditModel = (TrackEditModel) trackModel.editModel();
trackEditModel.initializeComboBoxModels(Track.MEDIATYPE_FK, Track.GENRE_FK);
// We refresh albums which rating may have changed, due to a track rating being updated
trackEditModel.ratingUpdated().addConsumer(tableModel()::refresh);
}
}
package is.codion.demos.chinook.model;
import is.codion.common.user.User;
import is.codion.demos.chinook.domain.ChinookImpl;
import is.codion.demos.chinook.domain.api.Chinook.Album;
import is.codion.demos.chinook.domain.api.Chinook.Track;
import is.codion.framework.db.EntityConnection;
import is.codion.framework.db.EntityConnectionProvider;
import is.codion.framework.db.local.LocalEntityConnectionProvider;
import is.codion.framework.domain.entity.Entity;
import is.codion.swing.framework.model.SwingEntityTableModel;
import org.junit.jupiter.api.Test;
import java.util.List;
import static is.codion.framework.db.EntityConnection.Update.where;
import static org.junit.jupiter.api.Assertions.assertEquals;
public final class AlbumModelTest {
private static final String MASTER_OF_PUPPETS = "Master Of Puppets";
@Test
void albumRefreshedWhenTrackRatingIsUpdated() {
try (EntityConnectionProvider connectionProvider = createConnectionProvider()) {
EntityConnection connection = connectionProvider.connection();
connection.startTransaction();
// Initialize all the tracks with an inital rating of 8
Entity masterOfPuppets = connection.selectSingle(Album.TITLE.equalTo(MASTER_OF_PUPPETS));
connection.update(where(Track.ALBUM_FK.equalTo(masterOfPuppets))
.set(Track.RATING, 8)
.build());
// Re-select the album to get the updated rating, which is the average of the track ratings
masterOfPuppets = connection.selectSingle(Album.TITLE.equalTo(MASTER_OF_PUPPETS));
assertEquals(8, masterOfPuppets.get(Album.RATING));
// Create our AlbumModel and configure the query condition
// to populate it with only Master Of Puppets
AlbumModel albumModel = new AlbumModel(connectionProvider);
SwingEntityTableModel albumTableModel = albumModel.tableModel();
albumTableModel.queryModel().condition().get(Album.TITLE).set().equalTo(MASTER_OF_PUPPETS);
albumTableModel.items().refresh();
assertEquals(1, albumTableModel.items().count());
List<Entity> modifiedTracks = connection.select(Track.ALBUM_FK.equalTo(masterOfPuppets)).stream()
.peek(track -> track.set(Track.RATING, 10))
.toList();
// Update the tracks using the edit model
albumModel.detailModels().get(Track.TYPE).editModel().update(modifiedTracks);
// Which should trigger the refresh of the album in the Album model
// now with the new rating as the average of the track ratings
assertEquals(10, albumTableModel.items().visible().get(0).get(Album.RATING));
connection.rollbackTransaction();
}
}
private static EntityConnectionProvider createConnectionProvider() {
return LocalEntityConnectionProvider.builder()
.domain(new ChinookImpl())
.user(User.parse("scott:tiger"))
.build();
}
}
UI
AlbumPanel
package is.codion.demos.chinook.ui;
import is.codion.demos.chinook.domain.api.Chinook.Track;
import is.codion.demos.chinook.model.TrackTableModel;
import is.codion.swing.framework.model.SwingEntityModel;
import is.codion.swing.framework.ui.EntityPanel;
public final class AlbumPanel extends EntityPanel {
public AlbumPanel(SwingEntityModel albumModel) {
super(albumModel,
new AlbumEditPanel(albumModel.editModel()),
new AlbumTablePanel(albumModel.tableModel()));
SwingEntityModel trackModel = albumModel.detailModels().get(Track.TYPE);
EntityPanel trackPanel = new EntityPanel(trackModel,
new TrackEditPanel(trackModel.editModel(), trackModel.tableModel().selection()),
new TrackTablePanel((TrackTableModel) trackModel.tableModel()));
detailPanels().add(trackPanel);
}
}
AlbumEditPanel
package is.codion.demos.chinook.ui;
import is.codion.demos.chinook.domain.api.Chinook.Album;
import is.codion.demos.chinook.domain.api.Chinook.Artist;
import is.codion.swing.common.model.component.list.FilterListModel;
import is.codion.swing.common.ui.component.list.FilterList;
import is.codion.swing.common.ui.component.value.ComponentValue;
import is.codion.swing.framework.model.SwingEntityEditModel;
import is.codion.swing.framework.ui.EntityEditPanel;
import javax.swing.JPanel;
import java.awt.BorderLayout;
import java.util.List;
import static is.codion.swing.common.ui.component.Components.flexibleGridLayoutPanel;
import static is.codion.swing.common.ui.layout.Layouts.borderLayout;
public final class AlbumEditPanel extends EntityEditPanel {
public AlbumEditPanel(SwingEntityEditModel editModel) {
super(editModel);
}
@Override
protected void initializeUI() {
focus().initial().set(Album.ARTIST_FK);
createSearchField(Album.ARTIST_FK)
.columns(15)
// We provide a edit panel supplier, which enables
// keyboard shortcuts for adding a new artist (INSERT)
// or editing the currently selected one (CTRL-INSERT).
.editPanel(this::createArtistEditPanel);
createTextField(Album.TITLE)
.columns(15);
// We create a custom component for the album tags,
// the JList it is based on is automatically associated
// with Album.TAGS, since we use the createList() method.
AlbumTagPanel albumTagPanel = createAlbumTagPanel();
// We create a custom component for the album cover art
CoverArtPanel coverArtPanel = new CoverArtPanel(editModel().editor().value(Album.COVER));
// We set the CoverArtPanel as the component for Album.COVER,
// so that it will appear in the input component selection dialog
component(Album.COVER).set(coverArtPanel);
JPanel centerPanel = flexibleGridLayoutPanel(2, 2)
.add(createInputPanel(Album.ARTIST_FK))
.add(createInputPanel(Album.TITLE))
.add(createInputPanel(Album.TAGS, albumTagPanel))
.add(createInputPanel(Album.COVER))
.build();
setLayout(borderLayout());
add(centerPanel, BorderLayout.CENTER);
}
private AlbumTagPanel createAlbumTagPanel() {
// We create JList based value for the album tags.
ComponentValue<List<String>, FilterList<String>> tagsValue =
createList(FilterListModel.<String>filterListModel())
// The value should be based on the items in
// the list as opposed to the selected items
.items(Album.TAGS)
.buildValue();
// We then base the custom AlbumTagPanel component
// on the above component value
return new AlbumTagPanel(tagsValue);
}
private EntityEditPanel createArtistEditPanel() {
return new ArtistEditPanel(new SwingEntityEditModel(Artist.TYPE, editModel().connectionProvider()));
}
}
CoverArtPanel
package is.codion.demos.chinook.ui;
import is.codion.common.state.State;
import is.codion.common.value.Value;
import is.codion.plugin.imagepanel.NavigableImagePanel;
import is.codion.swing.common.ui.FileTransferHandler;
import is.codion.swing.common.ui.Utilities;
import is.codion.swing.common.ui.control.Control;
import is.codion.swing.common.ui.dialog.Dialogs;
import is.codion.swing.framework.ui.icon.FrameworkIcons;
import org.kordamp.ikonli.foundation.Foundation;
import javax.imageio.ImageIO;
import javax.swing.JButton;
import javax.swing.JPanel;
import javax.swing.filechooser.FileNameExtensionFilter;
import java.awt.BorderLayout;
import java.awt.Component;
import java.awt.Dimension;
import java.awt.GridLayout;
import java.awt.event.MouseAdapter;
import java.awt.event.MouseEvent;
import java.awt.image.BufferedImage;
import java.io.ByteArrayInputStream;
import java.io.File;
import java.io.IOException;
import java.nio.file.Files;
import java.util.List;
import java.util.ResourceBundle;
import static is.codion.swing.common.ui.component.Components.*;
import static is.codion.swing.common.ui.layout.Layouts.borderLayout;
import static java.util.ResourceBundle.getBundle;
import static javax.swing.BorderFactory.createEtchedBorder;
/**
* A panel for displaying a cover image, based on a byte array.
*/
final class CoverArtPanel extends JPanel {
private static final ResourceBundle BUNDLE = getBundle(CoverArtPanel.class.getName());
private static final FrameworkIcons ICONS = FrameworkIcons.instance();
private static final Dimension EMBEDDED_SIZE = new Dimension(200, 200);
private static final Dimension DIALOG_SIZE = new Dimension(400, 400);
private static final FileNameExtensionFilter IMAGE_FILE_FILTER =
new FileNameExtensionFilter(BUNDLE.getString("images"),
new String[] {"jpg", "jpeg", "png", "bmp", "gif"});
private final JButton addButton;
private final JButton removeButton;
private final JPanel centerPanel;
private final NavigableImagePanel imagePanel;
private final Value<byte[]> imageBytes;
private final State imageSelected;
private final State embedded = State.state(true);
/**
* @param imageBytes the image bytes value to base this panel on.
*/
CoverArtPanel(Value<byte[]> imageBytes) {
super(borderLayout());
this.imageBytes = imageBytes;
this.imageSelected = State.state(!imageBytes.isNull());
this.imagePanel = createImagePanel();
this.addButton = button(Control.builder()
.command(this::addCover)
.smallIcon(ICONS.get(Foundation.PLUS)))
.transferFocusOnEnter(true)
.build();
this.removeButton = button(Control.builder()
.command(this::removeCover)
.smallIcon(ICONS.get(Foundation.MINUS)))
.transferFocusOnEnter(true)
.enabled(imageSelected)
.build();
this.centerPanel = createCenterPanel();
add(centerPanel, BorderLayout.CENTER);
bindEvents();
}
@Override
public boolean requestFocusInWindow() {
// The panel itself is not focusable,
// request focus for the add button instead
return addButton.requestFocusInWindow();
}
private JPanel createCenterPanel() {
return borderLayoutPanel()
.preferredSize(EMBEDDED_SIZE)
.centerComponent(imagePanel)
.southComponent(borderLayoutPanel()
.eastComponent(panel(new GridLayout(1, 2, 0, 0))
.addAll(addButton, removeButton)
.build())
.build())
.build();
}
private void bindEvents() {
imageBytes.addConsumer(bytes -> imagePanel.setImage(readImage(bytes)));
imageBytes.addConsumer(bytes -> imageSelected.set(bytes != null));
embedded.addConsumer(this::setEmbedded);
imagePanel.addMouseListener(new EmbeddingMouseListener());
}
private void addCover() throws IOException {
File coverFile = Dialogs.fileSelectionDialog()
.owner(this)
.title(BUNDLE.getString("select_image"))
.fileFilter(IMAGE_FILE_FILTER)
.selectFile();
imageBytes.set(Files.readAllBytes(coverFile.toPath()));
}
private void removeCover() {
imageBytes.clear();
}
private void setEmbedded(boolean embedded) {
configureImagePanel(embedded);
if (embedded) {
embed();
}
else {
displayInDialog();
}
}
private void embed() {
Utilities.disposeParentWindow(centerPanel);
centerPanel.setSize(EMBEDDED_SIZE);
imagePanel.resetView();
add(centerPanel, BorderLayout.CENTER);
revalidate();
repaint();
}
private void displayInDialog() {
remove(centerPanel);
revalidate();
repaint();
Dialogs.componentDialog(centerPanel)
.owner(this)
.modal(false)
.title(BUNDLE.getString("cover"))
.onClosed(windowEvent -> embedded.set(true))
.onOpened(windowEvent -> imagePanel.resetView())
.size(DIALOG_SIZE)
.show();
}
private void configureImagePanel(boolean embedded) {
imagePanel.setZoomDevice(embedded ? NavigableImagePanel.ZoomDevice.NONE : NavigableImagePanel.ZoomDevice.MOUSE_WHEEL);
imagePanel.setMoveImageEnabled(!embedded);
}
private NavigableImagePanel createImagePanel() {
NavigableImagePanel panel = new NavigableImagePanel();
panel.setZoomDevice(NavigableImagePanel.ZoomDevice.NONE);
panel.setNavigationImageEnabled(false);
panel.setMoveImageEnabled(false);
panel.setTransferHandler(new CoverTransferHandler());
panel.setBorder(createEtchedBorder());
return panel;
}
private static BufferedImage readImage(byte[] bytes) {
if (bytes == null) {
return null;
}
try {
return ImageIO.read(new ByteArrayInputStream(bytes));
}
catch (IOException e) {
throw new RuntimeException(e);
}
}
private final class EmbeddingMouseListener extends MouseAdapter {
@Override
public void mouseClicked(MouseEvent e) {
if (e.getClickCount() == 2) {
embedded.set(!embedded.get());
}
}
}
private final class CoverTransferHandler extends FileTransferHandler {
@Override
protected boolean importFiles(Component component, List<File> files) {
try {
if (singleImage(files)) {
imageBytes.set(Files.readAllBytes(files.getFirst().toPath()));
return true;
}
return false;
}
catch (IOException e) {
throw new RuntimeException(e);
}
}
private boolean singleImage(List<File> files) {
return files.size() == 1 && IMAGE_FILE_FILTER.accept(files.getFirst());
}
}
}
AlbumTagPanel
package is.codion.demos.chinook.ui;
import is.codion.common.model.filter.FilterModel;
import is.codion.common.state.State;
import is.codion.framework.i18n.FrameworkMessages;
import is.codion.swing.common.ui.component.list.FilterList;
import is.codion.swing.common.ui.component.value.ComponentValue;
import is.codion.swing.common.ui.control.Control;
import is.codion.swing.common.ui.control.Controls;
import is.codion.swing.common.ui.key.KeyEvents;
import is.codion.swing.framework.ui.icon.FrameworkIcons;
import org.kordamp.ikonli.foundation.Foundation;
import javax.swing.JPanel;
import javax.swing.event.ListSelectionEvent;
import javax.swing.event.ListSelectionListener;
import java.awt.BorderLayout;
import java.util.Arrays;
import java.util.List;
import static is.codion.swing.common.ui.component.Components.*;
import static is.codion.swing.common.ui.dialog.Dialogs.inputDialog;
import static is.codion.swing.common.ui.layout.Layouts.borderLayout;
import static java.awt.event.InputEvent.CTRL_DOWN_MASK;
import static java.awt.event.KeyEvent.*;
final class AlbumTagPanel extends JPanel {
private static final FrameworkIcons ICONS = FrameworkIcons.instance();
private final ComponentValue<List<String>, FilterList<String>> tagsValue;
private final FilterModel.Items<String> tagItems;
private final State selectionEmpty = State.state(true);
private final State movingTags = State.state(false);
private final Control addTagControl = Control.builder()
.command(this::addTag)
.smallIcon(ICONS.get(Foundation.PLUS))
.build();
private final Control removeTagControl = Control.builder()
.command(this::removeTag)
.smallIcon(ICONS.get(Foundation.MINUS))
.enabled(selectionEmpty.not())
.build();
private final Control moveSelectionUpControl = Control.builder()
.command(this::moveSelectedTagsUp)
.smallIcon(ICONS.up())
.enabled(selectionEmpty.not())
.build();
private final Control moveSelectionDownControl = Control.builder()
.command(this::moveSelectedTagsDown)
.smallIcon(ICONS.down())
.enabled(selectionEmpty.not())
.build();
/**
* @param tagsValue the list value providing the list component
*/
AlbumTagPanel(ComponentValue<List<String>, FilterList<String>> tagsValue) {
super(borderLayout());
this.tagsValue = tagsValue;
this.tagsValue.component().addListSelectionListener(new UpdateSelectionEmptyState());
this.tagItems = tagsValue.component().model().items();
add(createCenterPanel(), BorderLayout.CENTER);
setupKeyEvents();
}
ComponentValue<List<String>, FilterList<String>> tagsValue() {
return tagsValue;
}
private JPanel createCenterPanel() {
return borderLayoutPanel()
.centerComponent(scrollPane(tagsValue.component())
.preferredWidth(120)
.build())
.southComponent(borderLayoutPanel()
.westComponent(createButtonPanel(moveSelectionDownControl, moveSelectionUpControl))
.eastComponent(createButtonPanel(addTagControl, removeTagControl))
.build())
.build();
}
private JPanel createButtonPanel(Control leftControl, Control rightControl) {
return buttonPanel(Controls.builder()
.control(leftControl)
.control(rightControl))
.transferFocusOnEnter(true)
.buttonGap(0)
.build();
}
private void setupKeyEvents() {
KeyEvents.builder(VK_INSERT)
.action(addTagControl)
.enable(tagsValue.component());
KeyEvents.builder(VK_DELETE)
.action(removeTagControl)
.enable(tagsValue.component());
KeyEvents.builder(VK_UP)
.modifiers(CTRL_DOWN_MASK)
.action(moveSelectionUpControl)
.enable(tagsValue.component());
KeyEvents.builder(VK_DOWN)
.modifiers(CTRL_DOWN_MASK)
.action(moveSelectionDownControl)
.enable(tagsValue.component());
}
private void addTag() {
State tagNull = State.state(true);
tagItems.add(inputDialog(stringField()
.consumer(tag -> tagNull.set(tag == null)))
.owner(this)
.title(FrameworkMessages.add())
.valid(tagNull.not())
.show());
}
private void removeTag() {
tagsValue.component().getSelectedValuesList().forEach(tagItems::remove);
}
private void moveSelectedTagsUp() {
movingTags.set(true);
try {
int[] selected = tagsValue.component().getSelectedIndices();
if (selected.length > 0 && selected[0] != 0) {
moveTagsUp(selected);
moveSelectionUp(selected);
}
}
finally {
movingTags.set(false);
}
}
private void moveSelectedTagsDown() {
movingTags.set(true);
try {
int[] selected = tagsValue.component().getSelectedIndices();
if (selected.length > 0 && selected[selected.length - 1] != tagItems.visible().count() - 1) {
moveTagsDown(selected);
moveSelectionDown(selected);
}
}
finally {
movingTags.set(false);
}
}
private void moveTagsUp(int[] selected) {
for (int i = 0; i < selected.length; i++) {
tagItems.visible().add(selected[i] - 1, tagItems.visible().remove(selected[i]));
}
}
private void moveTagsDown(int[] selected) {
for (int i = selected.length - 1; i >= 0; i--) {
tagItems.visible().add(selected[i] + 1, tagItems.visible().remove(selected[i]));
}
}
private void moveSelectionUp(int[] selected) {
tagsValue.component().setSelectedIndices(Arrays.stream(selected)
.map(index -> index - 1)
.toArray());
tagsValue.component().ensureIndexIsVisible(selected[0] - 1);
}
private void moveSelectionDown(int[] selected) {
tagsValue.component().setSelectedIndices(Arrays.stream(selected)
.map(index -> index + 1)
.toArray());
tagsValue.component().ensureIndexIsVisible(selected[selected.length - 1] + 1);
}
private final class UpdateSelectionEmptyState implements ListSelectionListener {
@Override
public void valueChanged(ListSelectionEvent e) {
if (!movingTags.get()) {
selectionEmpty.set(tagsValue.component().isSelectionEmpty());
}
}
}
}
AlbumTablePanel
package is.codion.demos.chinook.ui;
import is.codion.demos.chinook.domain.api.Chinook.Album;
import is.codion.plugin.imagepanel.NavigableImagePanel;
import is.codion.swing.common.model.component.list.FilterListModel;
import is.codion.swing.common.ui.Utilities;
import is.codion.swing.common.ui.Windows;
import is.codion.swing.common.ui.component.Components;
import is.codion.swing.common.ui.component.value.AbstractComponentValue;
import is.codion.swing.common.ui.component.value.ComponentValue;
import is.codion.swing.common.ui.control.Control;
import is.codion.swing.common.ui.dialog.Dialogs;
import is.codion.swing.framework.model.SwingEntityEditModel;
import is.codion.swing.framework.model.SwingEntityTableModel;
import is.codion.swing.framework.ui.EntityTableCellRenderer;
import is.codion.swing.framework.ui.EntityTablePanel;
import is.codion.swing.framework.ui.component.EditComponentFactory;
import javax.imageio.ImageIO;
import javax.swing.JDialog;
import java.awt.image.BufferedImage;
import java.io.ByteArrayInputStream;
import java.io.IOException;
import java.util.List;
import static is.codion.demos.chinook.ui.TrackTablePanel.RATINGS;
public final class AlbumTablePanel extends EntityTablePanel {
private final NavigableImagePanel coverPanel;
public AlbumTablePanel(SwingEntityTableModel tableModel) {
super(tableModel, config -> config
// A custom input component for editing Album.TAGS
.editComponentFactory(Album.TAGS, new TagEditComponentFactory())
// Custom cell renderer for Album.RATING
// rendering the rating as stars, i.e. *****
.cellRenderer(Album.RATING, EntityTableCellRenderer.builder(Album.RATING, tableModel)
.string(RATINGS::get)
.toolTipData(true)
.build()));
coverPanel = new NavigableImagePanel();
coverPanel.setPreferredSize(Windows.screenSizeRatio(0.5));
table().doubleClick().set(viewCoverControl());
}
private Control viewCoverControl() {
return Control.builder()
.command(this::viewSelectedCover)
.enabled(tableModel().selection().single())
.build();
}
private void viewSelectedCover() {
tableModel().selection().item().optional()
.filter(album -> !album.isNull(Album.COVER))
.ifPresent(album -> displayCover(album.get(Album.TITLE), album.get(Album.COVER)));
}
private void displayCover(String title, byte[] coverBytes) {
coverPanel.setImage(readImage(coverBytes));
if (coverPanel.isShowing()) {
JDialog dialog = Utilities.parentDialog(coverPanel);
dialog.setTitle(title);
dialog.toFront();
}
else {
Dialogs.componentDialog(coverPanel)
.owner(Utilities.parentWindow(this))
.title(title)
.modal(false)
.onClosed(_ -> coverPanel.setImage(null))
.show();
}
}
private static BufferedImage readImage(byte[] bytes) {
try {
return ImageIO.read(new ByteArrayInputStream(bytes));
}
catch (IOException e) {
throw new RuntimeException(e);
}
}
private static final class TagEditComponentFactory
implements EditComponentFactory<List<String>, AlbumTagPanel> {
@Override
public ComponentValue<List<String>, AlbumTagPanel> component(SwingEntityEditModel editModel,
List<String> value) {
return new TagComponentValue(value);
}
}
private static final class TagComponentValue extends AbstractComponentValue<List<String>, AlbumTagPanel> {
private TagComponentValue(List<String> tags) {
super(new AlbumTagPanel(Components.list(FilterListModel.<String>filterListModel())
// A list component value based on the items in
// the model, as opposed to the selected items
.items()
// The initial tags to display
.value(tags)
.buildValue()));
}
@Override
protected List<String> getComponentValue() {
return component().tagsValue().get();
}
@Override
protected void setComponentValue(List<String> value) {
component().tagsValue().set(value);
}
}
}
Track
SQL
CREATE TABLE CHINOOK.TRACK
(
ID LONG GENERATED BY DEFAULT AS IDENTITY,
NAME VARCHAR(200) NOT NULL,
ALBUM_ID LONG NOT NULL,
MEDIATYPE_ID LONG NOT NULL,
GENRE_ID LONG,
COMPOSER VARCHAR(220),
MILLISECONDS INTEGER NOT NULL,
BYTES DOUBLE,
RATING INTEGER NOT NULL,
UNITPRICE DOUBLE NOT NULL,
CONSTRAINT PK_TRACK PRIMARY KEY (ID),
CONSTRAINT UK_TRACK_NAME UNIQUE (ALBUM_ID, NAME),
CONSTRAINT FK_ALBUM_TRACK FOREIGN KEY (ALBUM_ID) REFERENCES CHINOOK.ALBUM(ID),
CONSTRAINT FK_MEDIATYPE_TRACK FOREIGN KEY (MEDIATYPE_ID) REFERENCES CHINOOK.MEDIATYPE(ID),
CONSTRAINT FK_GENRE_TRACK FOREIGN KEY (GENRE_ID) REFERENCES CHINOOK.GENRE(ID),
CONSTRAINT CHK_RATING CHECK (RATING BETWEEN 1 AND 10)
);
Domain
Track Domain API
interface Track {
EntityType TYPE = DOMAIN.entityType("chinook.track", Track.class.getName());
Column<Long> ID = TYPE.longColumn("id");
Column<String> NAME = TYPE.stringColumn("name");
Column<Long> ALBUM_ID = TYPE.longColumn("album_id");
Column<String> ARTIST_NAME = TYPE.stringColumn("artist_name");
Column<Long> MEDIATYPE_ID = TYPE.longColumn("mediatype_id");
Column<Long> GENRE_ID = TYPE.longColumn("genre_id");
Column<String> COMPOSER = TYPE.stringColumn("composer");
Column<Integer> MILLISECONDS = TYPE.integerColumn("milliseconds");
Column<Integer> BYTES = TYPE.integerColumn("bytes");
Column<Integer> RATING = TYPE.integerColumn("rating");
Column<BigDecimal> UNITPRICE = TYPE.bigDecimalColumn("unitprice");
Column<Integer> PLAY_COUNT = TYPE.integerColumn("play_count");
Column<Void> RANDOM = TYPE.column("random()", Void.class);
ForeignKey ALBUM_FK = TYPE.foreignKey("album_fk", ALBUM_ID, Album.ID);
ForeignKey MEDIATYPE_FK = TYPE.foreignKey("mediatype_fk", MEDIATYPE_ID, MediaType.ID);
ForeignKey GENRE_FK = TYPE.foreignKey("genre_fk", GENRE_ID, Genre.ID);
FunctionType<EntityConnection, RaisePriceParameters, Collection<Entity>> RAISE_PRICE = functionType("chinook.raise_price");
ConditionType NOT_IN_PLAYLIST = TYPE.conditionType("not_in_playlist");
static Dto dto(Entity track) {
return track == null ? null :
new Dto(track.get(ID), track.get(NAME),
track.get(ARTIST_NAME),
Album.dto(track.get(ALBUM_FK)),
Genre.dto(track.get(GENRE_FK)),
MediaType.dto(track.get(MEDIATYPE_FK)),
track.get(MILLISECONDS),
track.get(RATING),
track.get(UNITPRICE),
track.get(PLAY_COUNT));
}
record RaisePriceParameters(Collection<Long> trackIds, BigDecimal priceIncrease) implements Serializable {
public RaisePriceParameters {
requireNonNull(trackIds);
requireNonNull(priceIncrease);
}
}
record Dto(Long id, String name, String artistName, Album.Dto album,
Genre.Dto genre, MediaType.Dto mediaType,
Integer milliseconds, Integer rating,
BigDecimal unitPrice, Integer playCount) {
public Entity entity(Entities entities) {
return entities.builder(TYPE)
.with(ID, id)
.with(NAME, name)
.with(ARTIST_NAME, artistName)
.with(ALBUM_FK, album.entity(entities))
.with(GENRE_FK, genre.entity(entities))
.with(MEDIATYPE_FK, mediaType.entity(entities))
.with(MILLISECONDS, milliseconds)
.with(RATING, rating)
.with(UNITPRICE, unitPrice)
.with(PLAY_COUNT, playCount)
.build();
}
}
}
final class CoverFormatter extends Format {
private final NumberFormat kbFormat = NumberFormat.getIntegerInstance();
@Override
public StringBuffer format(Object value, StringBuffer toAppendTo, FieldPosition pos) {
if (value != null) {
toAppendTo.append(kbFormat.format(((byte[]) value).length / 1024) + " Kb");
}
return toAppendTo;
}
@Override
public Object parseObject(String source, ParsePosition pos) {
throw new UnsupportedOperationException();
}
}
Track Domain Implementation
EntityDefinition track() {
return Track.TYPE.define(
Track.ID.define()
.primaryKey()
// Ambiguous column due to join
.expression("track.id"),
Track.ALBUM_ID.define()
.column()
.nullable(false),
Track.ALBUM_FK.define()
.foreignKey()
.referenceDepth(2)
.attributes(Album.ARTIST_FK, Album.TITLE),
Track.ARTIST_NAME.define()
.column()
// Ambiguous column due to join
.expression("artist.name")
// Read-only column from a joined table
.readOnly(true),
Track.NAME.define()
.column()
// Ambiguous column due to join
.expression("track.name")
.searchable(true)
.nullable(false)
.maximumLength(200),
Track.GENRE_ID.define()
.column(),
Track.GENRE_FK.define()
.foreignKey(),
Track.COMPOSER.define()
.column()
.maximumLength(220),
Track.MEDIATYPE_ID.define()
.column()
.nullable(false),
Track.MEDIATYPE_FK.define()
.foreignKey(),
Track.MILLISECONDS.define()
.column()
.nullable(false)
.format(NumberFormat.getIntegerInstance()),
Track.BYTES.define()
.column()
.format(NumberFormat.getIntegerInstance()),
Track.RATING.define()
.column()
.nullable(false)
.defaultValue(5)
.valueRange(1, 10),
Track.UNITPRICE.define()
.column()
.nullable(false)
.minimumValue(0)
.maximumFractionDigits(2),
Track.PLAY_COUNT.define()
.column()
.nullable(false)
.defaultValue(0),
Track.RANDOM.define()
.column()
.readOnly(true)
.selected(false))
.keyGenerator(identity())
.selectQuery(EntitySelectQuery.builder()
// Override the default FROM clause, joining
// the ALBUM and ARTIST tables in order to
// have the ARTIST.NAME column available
.from("""
chinook.track
JOIN chinook.album ON track.album_id = album.id
JOIN chinook.artist ON album.artist_id = artist.id""")
.build())
.orderBy(ascending(Track.NAME))
// Implement a custom condition for specifying
// tracks that are not in a given playlist
.condition(Track.NOT_IN_PLAYLIST, (_, _) -> """
track.id NOT IN (
SELECT track_id
FROM chinook.playlisttrack
WHERE playlist_id = ?
)""")
.stringFactory(Track.NAME)
.build();
}
private static final class RaisePriceFunction implements DatabaseFunction<EntityConnection, RaisePriceParameters, Collection<Entity>> {
@Override
public Collection<Entity> execute(EntityConnection entityConnection,
RaisePriceParameters parameters) {
Select select = where(Track.ID.in(parameters.trackIds()))
.forUpdate()
.build();
return entityConnection.updateSelect(entityConnection.select(select).stream()
.map(track -> raisePrice(track, parameters.priceIncrease()))
.toList());
}
private static Entity raisePrice(Entity track, BigDecimal priceIncrease) {
track.set(Track.UNITPRICE, track.get(Track.UNITPRICE).add(priceIncrease));
return track;
}
}
Model
TrackEditModel
package is.codion.demos.chinook.model;
import is.codion.common.event.Event;
import is.codion.common.observable.Observer;
import is.codion.demos.chinook.domain.api.Chinook.Track;
import is.codion.framework.db.EntityConnection;
import is.codion.framework.db.EntityConnectionProvider;
import is.codion.framework.domain.entity.Entity;
import is.codion.swing.framework.model.SwingEntityEditModel;
import java.util.Collection;
import java.util.Set;
import static java.util.stream.Collectors.toSet;
public final class TrackEditModel extends SwingEntityEditModel {
private final Event<Collection<Entity.Key>> ratingUpdated = Event.event();
public TrackEditModel(EntityConnectionProvider connectionProvider) {
super(Track.TYPE, connectionProvider);
// Creates and populates the combo box models for the given foreign keys, otherwise this
// would happen when the associated combo boxes are created, as the UI is initialized.
initializeComboBoxModels(Track.MEDIATYPE_FK, Track.GENRE_FK);
}
Observer<Collection<Entity.Key>> ratingUpdated() {
return ratingUpdated.observer();
}
@Override
protected Collection<Entity> update(Collection<Entity> entities, EntityConnection connection) {
// Find tracks which rating has been modified and collect the
// album keys, in order to propagate to the ratingUpdated event
Set<Entity.Key> albumKeys = entities.stream()
.filter(entity -> entity.type().equals(Track.TYPE))
.filter(track -> track.modified(Track.RATING))
.map(track -> track.key(Track.ALBUM_FK))
.collect(toSet());
Collection<Entity> updated = super.update(entities, connection);
if (!albumKeys.isEmpty()) {
ratingUpdated.accept(albumKeys);
}
return updated;
}
}
TrackTableModel
package is.codion.demos.chinook.model;
import is.codion.common.value.Value.Validator;
import is.codion.demos.chinook.domain.api.Chinook.Track.RaisePriceParameters;
import is.codion.framework.db.EntityConnectionProvider;
import is.codion.framework.domain.entity.Entity;
import is.codion.framework.domain.entity.attribute.ForeignKey;
import is.codion.framework.model.ForeignKeyConditionModel;
import is.codion.swing.framework.model.SwingEntityConditionModelFactory;
import is.codion.swing.framework.model.SwingEntityTableModel;
import is.codion.swing.framework.model.SwingForeignKeyConditionModel;
import java.math.BigDecimal;
import java.util.Collection;
import static is.codion.demos.chinook.domain.api.Chinook.Track;
import static is.codion.framework.model.EntityQueryModel.entityQueryModel;
import static is.codion.framework.model.EntityTableConditionModel.entityTableConditionModel;
public final class TrackTableModel extends SwingEntityTableModel {
private static final int DEFAULT_LIMIT = 1_000;
private static final int MAXIMUM_LIMIT = 10_000;
public TrackTableModel(EntityConnectionProvider connectionProvider) {
super(new TrackEditModel(connectionProvider),
entityQueryModel(entityTableConditionModel(Track.TYPE, connectionProvider,
new TrackColumnConditionFactory(connectionProvider))));
editable().set(true);
configureLimit();
}
public void raisePriceOfSelected(BigDecimal increase) {
if (selection().empty().not().get()) {
Collection<Long> trackIds = Entity.values(Track.ID, selection().items().get());
Collection<Entity> result = connection()
.execute(Track.RAISE_PRICE, new RaisePriceParameters(trackIds, increase));
replace(result);
}
}
private void configureLimit() {
queryModel().limit().set(DEFAULT_LIMIT);
queryModel().limit().addListener(items()::refresh);
queryModel().limit().addValidator(new LimitValidator());
}
private static final class LimitValidator implements Validator<Integer> {
@Override
public void validate(Integer limit) {
if (limit != null && limit > MAXIMUM_LIMIT) {
// The error message is never displayed, so not required
throw new IllegalArgumentException();
}
}
}
private static class TrackColumnConditionFactory extends SwingEntityConditionModelFactory {
private TrackColumnConditionFactory(EntityConnectionProvider connectionProvider) {
super(Track.TYPE, connectionProvider);
}
@Override
protected ForeignKeyConditionModel conditionModel(ForeignKey foreignKey) {
if (foreignKey.equals(Track.MEDIATYPE_FK)) {
return SwingForeignKeyConditionModel.builder()
.equalComboBoxModel(createEqualComboBoxModel(Track.MEDIATYPE_FK))
.build();
}
return super.conditionModel(foreignKey);
}
}
}
package is.codion.demos.chinook.model;
import is.codion.common.user.User;
import is.codion.demos.chinook.domain.ChinookImpl;
import is.codion.demos.chinook.domain.api.Chinook.Album;
import is.codion.demos.chinook.domain.api.Chinook.Track;
import is.codion.framework.db.EntityConnectionProvider;
import is.codion.framework.db.local.LocalEntityConnectionProvider;
import is.codion.framework.domain.entity.Entity;
import org.junit.jupiter.api.Test;
import java.math.BigDecimal;
import static org.junit.jupiter.api.Assertions.assertEquals;
public final class TrackTableModelTest {
@Test
void raisePriceOfSelected() {
try (EntityConnectionProvider connectionProvider = createConnectionProvider()) {
Entity masterOfPuppets = connectionProvider.connection()
.selectSingle(Album.TITLE.equalTo("Master Of Puppets"));
TrackTableModel trackTableModel = new TrackTableModel(connectionProvider);
trackTableModel.queryModel().condition()
.get(Track.ALBUM_FK).set().equalTo(masterOfPuppets);
trackTableModel.items().refresh();
assertEquals(8, trackTableModel.items().visible().count());
trackTableModel.selection().selectAll();
trackTableModel.raisePriceOfSelected(BigDecimal.ONE);
trackTableModel.items().get().forEach(track ->
assertEquals(BigDecimal.valueOf(1.99), track.get(Track.UNITPRICE)));
}
}
private static EntityConnectionProvider createConnectionProvider() {
return LocalEntityConnectionProvider.builder()
.domain(new ChinookImpl())
.user(User.parse("scott:tiger"))
.build();
}
}
UI
TrackEditPanel
package is.codion.demos.chinook.ui;
import is.codion.demos.chinook.ui.DurationComponentValue.DurationPanel;
import is.codion.framework.domain.entity.Entity;
import is.codion.swing.common.model.component.list.FilterListSelection;
import is.codion.swing.common.ui.key.KeyEvents;
import is.codion.swing.framework.model.SwingEntityEditModel;
import is.codion.swing.framework.ui.EntityEditPanel;
import javax.swing.JPanel;
import static is.codion.demos.chinook.domain.api.Chinook.*;
import static is.codion.swing.common.ui.component.Components.flexibleGridLayoutPanel;
import static is.codion.swing.common.ui.component.Components.gridLayoutPanel;
import static is.codion.swing.common.ui.control.Control.command;
import static is.codion.swing.common.ui.layout.Layouts.flexibleGridLayout;
import static java.awt.event.InputEvent.CTRL_DOWN_MASK;
import static java.awt.event.KeyEvent.VK_DOWN;
import static java.awt.event.KeyEvent.VK_UP;
public final class TrackEditPanel extends EntityEditPanel {
private final FilterListSelection<Entity> tableSelection;
private final UpdateCommand updateAndDecrementSelectedIndexes;
private final UpdateCommand updateAndIncrementSelectedIndexes;
public TrackEditPanel(SwingEntityEditModel editModel, FilterListSelection<Entity> tableSelection) {
super(editModel);
this.tableSelection = tableSelection;
this.updateAndDecrementSelectedIndexes = updateCommand()
.onUpdate(tableSelection.indexes()::decrement)
.build();
this.updateAndIncrementSelectedIndexes = updateCommand()
.onUpdate(tableSelection.indexes()::increment)
.build();
addKeyEvents();
}
@Override
protected void initializeUI() {
focus().initial().set(Track.ALBUM_FK);
createSearchField(Track.ALBUM_FK);
createTextField(Track.NAME)
.columns(12);
createComboBoxPanel(Track.MEDIATYPE_FK, this::createMediaTypeEditPanel)
.preferredWidth(160)
.includeAddButton(true)
.includeEditButton(true);
createComboBoxPanel(Track.GENRE_FK, this::createGenreEditPanel)
.preferredWidth(160)
.includeAddButton(true)
.includeEditButton(true);
createTextFieldPanel(Track.COMPOSER)
.columns(12);
DurationPanel durationPanel = createDurationPanel();
component(Track.MILLISECONDS).set(durationPanel);
createIntegerField(Track.BYTES)
.columns(6);
createIntegerSpinner(Track.RATING)
.columns(2);
createTextField(Track.UNITPRICE)
.columns(4);
createIntegerField(Track.PLAY_COUNT)
.columns(4);
JPanel genreMediaTypePanel = gridLayoutPanel(1, 2)
.add(createInputPanel(Track.GENRE_FK))
.add(createInputPanel(Track.MEDIATYPE_FK))
.build();
JPanel durationInputPanel = gridLayoutPanel(1, 2)
.add(createInputPanel(Track.BYTES))
.add(durationPanel)
.build();
JPanel unitPricePanel = flexibleGridLayoutPanel(1, 3)
.add(createInputPanel(Track.RATING))
.add(createInputPanel(Track.UNITPRICE))
.add(createInputPanel(Track.PLAY_COUNT))
.build();
setLayout(flexibleGridLayout(4, 2));
addInputPanel(Track.ALBUM_FK);
addInputPanel(Track.NAME);
add(genreMediaTypePanel);
addInputPanel(Track.COMPOSER);
add(durationInputPanel);
add(unitPricePanel);
}
private EntityEditPanel createMediaTypeEditPanel() {
return new MediaTypeEditPanel(new SwingEntityEditModel(MediaType.TYPE, editModel().connectionProvider()));
}
private GenreEditPanel createGenreEditPanel() {
return new GenreEditPanel(new SwingEntityEditModel(Genre.TYPE, editModel().connectionProvider()));
}
private DurationPanel createDurationPanel() {
return new DurationComponentValue(editModel().editor().value(Track.MILLISECONDS)).component();
}
private void addKeyEvents() {
// We add key events for CTRL-DOWN and CTRL-UP
// for incrementing and decrementing the selected
// index, respectively, after updating the selected
// item in case it is modified.
KeyEvents.builder()
// Set the condition
.condition(WHEN_ANCESTOR_OF_FOCUSED_COMPONENT)
// and modifiers
.modifiers(CTRL_DOWN_MASK)
// set a keycode
.keyCode(VK_UP)
// and an action
.action(command(this::decrementSelection))
// and enable
.enable(this)
// set a new keycode
.keyCode(VK_DOWN)
// and a new action
.action(command(this::incrementSelection))
// and enable
.enable(this);
}
private void decrementSelection() {
if (editModel().editor().modified().get()) {
updateAndDecrementSelectedIndexes.execute();
}
else {
tableSelection.indexes().decrement();
}
}
private void incrementSelection() {
if (editModel().editor().modified().get()) {
updateAndIncrementSelectedIndexes.execute();
}
else {
tableSelection.indexes().increment();
}
}
}
TrackTablePanel
package is.codion.demos.chinook.ui;
import is.codion.demos.chinook.domain.api.Chinook.Track;
import is.codion.demos.chinook.model.TrackTableModel;
import is.codion.demos.chinook.ui.DurationComponentValue.DurationPanel;
import is.codion.framework.domain.entity.EntityDefinition;
import is.codion.framework.domain.entity.attribute.AttributeDefinition;
import is.codion.swing.common.ui.component.spinner.NumberSpinnerBuilder;
import is.codion.swing.common.ui.component.table.FilterTableCellEditor;
import is.codion.swing.common.ui.component.table.FilterTableCellRenderer;
import is.codion.swing.common.ui.component.value.ComponentValue;
import is.codion.swing.common.ui.control.Control;
import is.codion.swing.framework.model.SwingEntityEditModel;
import is.codion.swing.framework.model.SwingEntityTableModel;
import is.codion.swing.framework.ui.EntityTableCellRenderer;
import is.codion.swing.framework.ui.EntityTablePanel;
import is.codion.swing.framework.ui.component.EditComponentFactory;
import javax.swing.JSpinner;
import java.math.BigDecimal;
import java.util.Map;
import java.util.Optional;
import java.util.ResourceBundle;
import static is.codion.common.Text.rightPad;
import static is.codion.demos.chinook.ui.DurationComponentValue.minutes;
import static is.codion.demos.chinook.ui.DurationComponentValue.seconds;
import static is.codion.swing.common.ui.component.Components.bigDecimalField;
import static is.codion.swing.common.ui.component.table.FilterTableCellEditor.filterTableCellEditor;
import static is.codion.swing.common.ui.dialog.Dialogs.inputDialog;
import static is.codion.swing.common.ui.key.KeyEvents.keyStroke;
import static is.codion.swing.framework.ui.component.EntityComponents.entityComponents;
import static java.awt.event.KeyEvent.VK_INSERT;
import static java.util.ResourceBundle.getBundle;
import static java.util.function.Function.identity;
import static java.util.stream.Collectors.toMap;
import static java.util.stream.IntStream.rangeClosed;
public final class TrackTablePanel extends EntityTablePanel {
private static final ResourceBundle BUNDLE = getBundle(TrackTablePanel.class.getName());
static final Map<Integer, String> RATINGS = rangeClosed(1, 10)
.mapToObj(ranking -> rightPad("", ranking, '*'))
.collect(toMap(String::length, identity()));
public TrackTablePanel(TrackTableModel tableModel) {
super(tableModel, config -> config
// Custom component for editing track ratings
.editComponentFactory(Track.RATING, new RatingEditComponentFactory())
// Custom component for editing track durations
.editComponentFactory(Track.MILLISECONDS, new DurationEditComponentFactory())
// Custom cell renderer for ratings
.cellRenderer(Track.RATING, ratingRenderer(tableModel))
// Custom cell renderer for track duration (min:sec)
.cellRenderer(Track.MILLISECONDS, durationRenderer(tableModel))
// Custom cell editor for track ratings
.cellEditor(Track.RATING, ratingEditor(tableModel.entityDefinition()))
// Custom cell editor for track durations (min:sec:ms)
.cellEditor(Track.MILLISECONDS, durationEditor())
// Start editing when the INSERT key is pressed
.table(table ->
table.startEditing(keyStroke(VK_INSERT)))
.includeLimitMenu(true));
// Add a custom control to the top of the table popup menu.
// Start by clearing the popup menu layout
configurePopupMenu(layout -> layout.clear()
// add our custom control
.control(Control.builder()
.command(this::raisePriceOfSelected)
.caption(BUNDLE.getString("raise_price") + "...")
.enabled(tableModel().selection().empty().not()))
// and a separator
.separator()
// and add all the default controls
.defaults());
}
private void raisePriceOfSelected() {
TrackTableModel tableModel = (TrackTableModel) tableModel();
tableModel.raisePriceOfSelected(getAmountFromUser());
}
private BigDecimal getAmountFromUser() {
return inputDialog(bigDecimalField()
.nullable(false)
.minimumValue(0))
.owner(this)
.title(BUNDLE.getString("amount"))
.validator(amount -> amount.compareTo(BigDecimal.ZERO) > 0)
.show();
}
private static FilterTableCellRenderer<Integer> durationRenderer(SwingEntityTableModel tableModel) {
return EntityTableCellRenderer.builder(Track.MILLISECONDS, tableModel)
.string(milliseconds -> minutes(milliseconds) + " min " + seconds(milliseconds) + " sec")
.toolTipData(true)
.build();
}
private static FilterTableCellEditor<Integer> durationEditor() {
return filterTableCellEditor(() -> new DurationComponentValue(true));
}
private static FilterTableCellRenderer<Integer> ratingRenderer(SwingEntityTableModel tableModel) {
return EntityTableCellRenderer.builder(Track.RATING, tableModel)
.string(RATINGS::get)
.toolTipData(true)
.build();
}
private static FilterTableCellEditor<Integer> ratingEditor(EntityDefinition entityDefinition) {
return filterTableCellEditor(() -> ratingSpinner(entityDefinition).buildValue());
}
private static NumberSpinnerBuilder<Integer> ratingSpinner(EntityDefinition entityDefinition) {
return entityComponents(entityDefinition).integerSpinner(Track.RATING);
}
private static final class RatingEditComponentFactory
implements EditComponentFactory<Integer, JSpinner> {
@Override
public ComponentValue<Integer, JSpinner> component(SwingEntityEditModel editModel,
Integer value) {
return ratingSpinner(editModel.entityDefinition())
.value(value)
.buildValue();
}
}
private static final class DurationEditComponentFactory
implements EditComponentFactory<Integer, DurationPanel> {
@Override
public Optional<String> caption(AttributeDefinition<Integer> attribute) {
return Optional.empty();
}
@Override
public ComponentValue<Integer, DurationPanel> component(SwingEntityEditModel editModel, Integer value) {
DurationComponentValue durationValue = new DurationComponentValue(false);
durationValue.set(value);
return durationValue;
}
}
}
DurationComponentValue
package is.codion.demos.chinook.ui;
import is.codion.common.state.ObservableState;
import is.codion.framework.model.EntityEditModel.EditorValue;
import is.codion.swing.common.ui.component.text.NumberField;
import is.codion.swing.common.ui.component.value.AbstractComponentValue;
import javax.swing.JLabel;
import javax.swing.JPanel;
import java.awt.BorderLayout;
import java.awt.event.FocusAdapter;
import java.awt.event.FocusEvent;
import java.util.ResourceBundle;
import static is.codion.swing.common.ui.component.Components.*;
import static is.codion.swing.common.ui.layout.Layouts.borderLayout;
import static java.time.Duration.ofMillis;
import static java.time.Duration.ofMinutes;
import static java.util.ResourceBundle.getBundle;
final class DurationComponentValue extends AbstractComponentValue<Integer, DurationComponentValue.DurationPanel> {
DurationComponentValue(EditorValue<Integer> millisecondsValue) {
this(new DurationPanel(false, millisecondsValue.valid(), millisecondsValue.modified()));
link(millisecondsValue);
}
DurationComponentValue(boolean cellEditor) {
this(new DurationPanel(cellEditor));
}
DurationComponentValue(DurationPanel panel) {
super(panel);
component().minutesField.observable().addListener(this::notifyListeners);
component().secondsField.observable().addListener(this::notifyListeners);
component().millisecondsField.observable().addListener(this::notifyListeners);
}
@Override
protected Integer getComponentValue() {
Integer minutes = component().minutesField.get();
Integer seconds = component().secondsField.get();
Integer milliseconds = component().millisecondsField.get();
if (minutes == null && seconds == null && milliseconds == null) {
return null;
}
return (int) ofMinutes(component().minutesField.optional().orElse(0))
.plusSeconds(component().secondsField.optional().orElse(0))
.plusMillis(component().millisecondsField.optional().orElse(0))
.toMillis();
}
@Override
protected void setComponentValue(Integer milliseconds) {
component().minutesField.set(minutes(milliseconds));
component().secondsField.set(seconds(milliseconds));
component().millisecondsField.set(milliseconds(milliseconds));
}
static Integer minutes(Integer milliseconds) {
if (milliseconds == null) {
return null;
}
return (int) ofMillis(milliseconds).toMinutes();
}
static Integer seconds(Integer milliseconds) {
if (milliseconds == null) {
return null;
}
return (int) ofMillis(milliseconds)
.minusMinutes(ofMillis(milliseconds).toMinutes())
.getSeconds();
}
static Integer milliseconds(Integer milliseconds) {
if (milliseconds == null) {
return null;
}
return (int) ofMillis(milliseconds)
.minusSeconds(ofMillis(milliseconds).toSeconds())
.toMillis();
}
static final class DurationPanel extends JPanel {
private static final ResourceBundle BUNDLE = getBundle(DurationPanel.class.getName());
private final NumberField<Integer> minutesField;
private final NumberField<Integer> secondsField;
private final NumberField<Integer> millisecondsField;
private final JLabel minLabel = new JLabel(BUNDLE.getString("min"));
private final JLabel secLabel = new JLabel(BUNDLE.getString("sec"));
private final JLabel msLabel = new JLabel(BUNDLE.getString("ms"));
private DurationPanel(boolean cellEditor) {
this(cellEditor, null, null);
}
private DurationPanel(boolean cellEditor, ObservableState valid, ObservableState modified) {
super(borderLayout());
minutesField = integerField()
.transferFocusOnEnter(true)
.selectAllOnFocusGained(true)
.modifiedIndicator(modified)
.validIndicator(valid)
.label(minLabel)
.columns(2)
.build();
secondsField = integerField()
.valueRange(0, 59)
.transferFocusOnEnter(true)
.selectAllOnFocusGained(true)
.silentValidation(true)
.modifiedIndicator(modified)
.validIndicator(valid)
.label(secLabel)
.columns(2)
.build();
millisecondsField = integerField()
.valueRange(0, 999)
.transferFocusOnEnter(!cellEditor)
.selectAllOnFocusGained(true)
.silentValidation(true)
.modifiedIndicator(modified)
.validIndicator(valid)
.label(msLabel)
.columns(3)
.build();
if (cellEditor) {
initializeCellEditor();
}
else {
initializeInputPanel();
}
addFocusListener(new FocusAdapter() {
@Override
public void focusGained(FocusEvent e) {
minutesField.requestFocusInWindow();
}
});
}
private void initializeCellEditor() {
add(flexibleGridLayoutPanel(1, 0)
.add(minutesField)
.add(secondsField)
.add(millisecondsField)
.build(), BorderLayout.CENTER);
}
private void initializeInputPanel() {
add(borderLayoutPanel()
.northComponent(gridLayoutPanel(1, 0)
.add(minLabel)
.add(secLabel)
.add(msLabel)
.build())
.centerComponent(gridLayoutPanel(1, 0)
.add(minutesField)
.add(secondsField)
.add(millisecondsField)
.build())
.build());
}
}
}
Playlists

Playlist
SQL
CREATE TABLE CHINOOK.PLAYLIST
(
ID LONG GENERATED BY DEFAULT AS IDENTITY,
NAME VARCHAR(120) NOT NULL,
CONSTRAINT PK_PLAYLIST PRIMARY KEY (ID),
CONSTRAINT UK_PLAYLIST UNIQUE (NAME)
);
Domain
API
interface Playlist {
EntityType TYPE = DOMAIN.entityType("chinook.playlist", Playlist.class.getName());
Column<Long> ID = TYPE.longColumn("id");
Column<String> NAME = TYPE.stringColumn("name");
FunctionType<EntityConnection, RandomPlaylistParameters, Entity> RANDOM_PLAYLIST = functionType("chinook.random_playlist");
record RandomPlaylistParameters(String playlistName, Integer noOfTracks, Collection<Entity> genres) implements Serializable {}
}
Implementation
EntityDefinition playlist() {
return Playlist.TYPE.define(
Playlist.ID.define()
.primaryKey(),
Playlist.NAME.define()
.column()
.searchable(true)
.nullable(false)
.maximumLength(120))
.keyGenerator(identity())
.orderBy(ascending(Playlist.NAME))
.stringFactory(Playlist.NAME)
.build();
}
CreateRandomPlaylistFunction
private static final class CreateRandomPlaylistFunction implements DatabaseFunction<EntityConnection, RandomPlaylistParameters, Entity> {
private final Entities entities;
private CreateRandomPlaylistFunction(Entities entities) {
this.entities = entities;
}
@Override
public Entity execute(EntityConnection connection,
RandomPlaylistParameters parameters) {
List<Long> trackIds = randomTrackIds(connection, parameters.noOfTracks(), parameters.genres());
return insertPlaylist(connection, parameters.playlistName(), trackIds);
}
private Entity insertPlaylist(EntityConnection connection, String playlistName,
List<Long> trackIds) {
Entity playlist = connection.insertSelect(createPlaylist(playlistName));
connection.insert(createPlaylistTracks(playlist.primaryKey().value(), trackIds));
return playlist;
}
private Entity createPlaylist(String playlistName) {
return entities.builder(Playlist.TYPE)
.with(Playlist.NAME, playlistName)
.build();
}
private List<Entity> createPlaylistTracks(Long playlistId, List<Long> trackIds) {
return trackIds.stream()
.map(trackId -> createPlaylistTrack(playlistId, trackId))
.toList();
}
private Entity createPlaylistTrack(Long playlistId, Long trackId) {
return entities.builder(PlaylistTrack.TYPE)
.with(PlaylistTrack.PLAYLIST_ID, playlistId)
.with(PlaylistTrack.TRACK_ID, trackId)
.build();
}
private static List<Long> randomTrackIds(EntityConnection connection, int noOfTracks,
Collection<Entity> genres) {
return connection.select(Track.ID,
where(Track.GENRE_FK.in(genres))
.orderBy(ascending(Track.RANDOM))
.limit(noOfTracks)
.build());
}
}
Model
PlaylistEditModel
package is.codion.demos.chinook.model;
import is.codion.demos.chinook.domain.api.Chinook.Playlist;
import is.codion.demos.chinook.domain.api.Chinook.PlaylistTrack;
import is.codion.framework.db.EntityConnection;
import is.codion.framework.db.EntityConnectionProvider;
import is.codion.framework.domain.entity.Entity;
import is.codion.swing.framework.model.SwingEntityEditModel;
import java.util.Collection;
import static is.codion.framework.db.EntityConnection.transaction;
import static is.codion.framework.domain.entity.Entity.primaryKeys;
public final class PlaylistEditModel extends SwingEntityEditModel {
public PlaylistEditModel(EntityConnectionProvider connectionProvider) {
super(Playlist.TYPE, connectionProvider);
}
@Override
protected void delete(Collection<Entity> playlists, EntityConnection connection) {
// We delete all playlist tracks along
// with the playlist, within a transaction
transaction(connection, () -> {
connection.delete(PlaylistTrack.PLAYLIST_FK.in(playlists));
connection.delete(primaryKeys(playlists));
});
}
}
PlaylistTableModel
package is.codion.demos.chinook.model;
import is.codion.demos.chinook.domain.api.Chinook.Playlist;
import is.codion.demos.chinook.domain.api.Chinook.Playlist.RandomPlaylistParameters;
import is.codion.framework.db.EntityConnection;
import is.codion.framework.db.EntityConnectionProvider;
import is.codion.framework.domain.entity.Entity;
import is.codion.swing.framework.model.SwingEntityTableModel;
import static is.codion.framework.db.EntityConnection.transaction;
public final class PlaylistTableModel extends SwingEntityTableModel {
public PlaylistTableModel(EntityConnectionProvider connectionProvider) {
super(new PlaylistEditModel(connectionProvider));
}
public void createRandomPlaylist(RandomPlaylistParameters parameters) {
EntityConnection connection = connection();
Entity randomPlaylist = transaction(connection, () -> connection.execute(Playlist.RANDOM_PLAYLIST, parameters));
items().visible().add(0, randomPlaylist);
selection().item().set(randomPlaylist);
}
}
UI
PlaylistPanel
package is.codion.demos.chinook.ui;
import is.codion.demos.chinook.domain.api.Chinook.PlaylistTrack;
import is.codion.swing.framework.model.SwingEntityModel;
import is.codion.swing.framework.ui.EntityPanel;
import java.awt.BorderLayout;
import static is.codion.swing.common.ui.component.Components.splitPane;
import static is.codion.swing.common.ui.layout.Layouts.borderLayout;
public final class PlaylistPanel extends EntityPanel {
public PlaylistPanel(SwingEntityModel playlistModel) {
super(playlistModel,
new PlaylistTablePanel(playlistModel.tableModel()),
// We override initializeUI(), so we don't need a detail layout
config -> config.detailLayout(DetailLayout.NONE));
SwingEntityModel playlistTrackModel =
playlistModel.detailModels().get(PlaylistTrack.TYPE);
EntityPanel playlistTrackPanel =
new EntityPanel(playlistTrackModel,
new PlaylistTrackTablePanel(playlistTrackModel.tableModel()));
// We still add the detail panel, for keyboard navigation
detailPanels().add(playlistTrackPanel);
}
@Override
protected void initializeUI() {
setLayout(borderLayout());
add(splitPane()
.leftComponent(mainPanel())
.rightComponent(detailPanels().get(PlaylistTrack.TYPE).initialize())
.continuousLayout(true)
.build(), BorderLayout.CENTER);
}
}
PlaylistEditPanel
package is.codion.demos.chinook.ui;
import is.codion.demos.chinook.domain.api.Chinook.Playlist;
import is.codion.swing.common.ui.layout.Layouts;
import is.codion.swing.framework.model.SwingEntityEditModel;
import is.codion.swing.framework.ui.EntityEditPanel;
import javax.swing.border.EmptyBorder;
import java.awt.BorderLayout;
import static is.codion.swing.common.ui.component.Components.borderLayoutPanel;
import static is.codion.swing.common.ui.layout.Layouts.borderLayout;
final class PlaylistEditPanel extends EntityEditPanel {
PlaylistEditPanel(SwingEntityEditModel editModel) {
super(editModel, config -> config
// Skip confirmation when updating
.updateConfirmer(Confirmer.NONE));
}
@Override
protected void initializeUI() {
focus().initial().set(Playlist.NAME);
setLayout(borderLayout());
add(borderLayoutPanel()
.westComponent(createLabel(Playlist.NAME).build())
.centerComponent(createTextField(Playlist.NAME)
.transferFocusOnEnter(false)
.columns(20)
.build())
.border(new EmptyBorder(Layouts.GAP.get(), Layouts.GAP.get(), 0, Layouts.GAP.get()))
.build(), BorderLayout.CENTER);
}
}
PlaylistTablePanel
package is.codion.demos.chinook.ui;
import is.codion.demos.chinook.domain.api.Chinook.Playlist.RandomPlaylistParameters;
import is.codion.demos.chinook.model.PlaylistTableModel;
import is.codion.framework.db.EntityConnectionProvider;
import is.codion.swing.common.ui.component.value.AbstractComponentValue;
import is.codion.swing.common.ui.control.Control;
import is.codion.swing.framework.model.SwingEntityTableModel;
import is.codion.swing.framework.ui.EntityTablePanel;
import is.codion.swing.framework.ui.icon.FrameworkIcons;
import java.util.ResourceBundle;
import static is.codion.swing.common.ui.dialog.Dialogs.inputDialog;
import static is.codion.swing.framework.ui.EntityTablePanel.ControlKeys.DELETE;
import static is.codion.swing.framework.ui.EntityTablePanel.ControlKeys.EDIT_ATTRIBUTE_CONTROLS;
import static java.util.ResourceBundle.getBundle;
public final class PlaylistTablePanel extends EntityTablePanel {
private static final ResourceBundle BUNDLE = getBundle(PlaylistTablePanel.class.getName());
public PlaylistTablePanel(SwingEntityTableModel tableModel) {
// We provide an edit panel, which becomes available via
// double click and keyboard shortcuts, instead of embedding it
super(tableModel, new PlaylistEditPanel(tableModel.editModel()));
// Add a custom control, for creating a random playlist,
// positioned below the standard DELETE control.
// Start by clearing the popup menu layout
configurePopupMenu(layout -> layout.clear()
// add all default controls up to and including DELETE
.defaults(DELETE)
// and a separator
.separator()
// and our custom control
.control(Control.builder()
.command(this::randomPlaylist)
.caption(BUNDLE.getString("random_playlist"))
.smallIcon(FrameworkIcons.instance().add()))
// and a separator
.separator()
// and the remaining default controls
.defaults());
}
@Override
protected void setupControls() {
// No need for the edit attribute controls in the popup menu
control(EDIT_ATTRIBUTE_CONTROLS).clear();
}
private void randomPlaylist() {
RandomPlaylistParametersValue playlistParametersValue = new RandomPlaylistParametersValue(tableModel().connectionProvider());
RandomPlaylistParameters randomPlaylistParameters = inputDialog(playlistParametersValue)
.owner(this)
.title(BUNDLE.getString("random_playlist"))
.valid(playlistParametersValue.component().parametersValid())
.show();
PlaylistTableModel playlistTableModel = (PlaylistTableModel) tableModel();
playlistTableModel.createRandomPlaylist(randomPlaylistParameters);
}
private static final class RandomPlaylistParametersValue
extends AbstractComponentValue<RandomPlaylistParameters, RandomPlaylistParametersPanel> {
private RandomPlaylistParametersValue(EntityConnectionProvider connectionProvider) {
super(new RandomPlaylistParametersPanel(connectionProvider));
}
@Override
protected RandomPlaylistParameters getComponentValue() {
return component().get();
}
@Override
protected void setComponentValue(RandomPlaylistParameters parameters) {/* Read only value, not required */}
}
}
RandomPlaylistParametersPanel
package is.codion.demos.chinook.ui;
import is.codion.common.state.ObservableState;
import is.codion.common.state.State;
import is.codion.common.value.Value;
import is.codion.common.value.ValueList;
import is.codion.demos.chinook.domain.api.Chinook.Genre;
import is.codion.demos.chinook.domain.api.Chinook.Playlist.RandomPlaylistParameters;
import is.codion.framework.db.EntityConnectionProvider;
import is.codion.framework.domain.entity.Entity;
import is.codion.swing.common.ui.component.Components;
import is.codion.swing.common.ui.component.list.FilterList;
import is.codion.swing.common.ui.component.text.NumberField;
import javax.swing.JLabel;
import javax.swing.JPanel;
import javax.swing.JScrollPane;
import javax.swing.JTextField;
import java.awt.BorderLayout;
import java.util.Collection;
import java.util.ResourceBundle;
import static is.codion.common.Text.nullOrEmpty;
import static is.codion.framework.db.EntityConnection.Select.all;
import static is.codion.framework.domain.entity.OrderBy.ascending;
import static is.codion.swing.common.model.component.list.FilterListModel.filterListModel;
import static is.codion.swing.common.ui.component.Components.*;
import static is.codion.swing.common.ui.layout.Layouts.borderLayout;
import static java.util.ResourceBundle.getBundle;
final class RandomPlaylistParametersPanel extends JPanel {
private static final ResourceBundle BUNDLE = getBundle(RandomPlaylistParametersPanel.class.getName());
private final RandomPlaylistParametersModel model = new RandomPlaylistParametersModel();
private final JTextField playlistNameField;
private final NumberField<Integer> noOfTracksField;
private final FilterList<Entity> genreList;
RandomPlaylistParametersPanel(EntityConnectionProvider connectionProvider) {
super(borderLayout());
playlistNameField = createPlaylistNameField();
noOfTracksField = createNoOfTracksField();
genreList = createGenreList(connectionProvider);
add(borderLayoutPanel()
.northComponent(gridLayoutPanel(1, 2)
.add(new JLabel(BUNDLE.getString("playlist_name")))
.add(new JLabel(BUNDLE.getString("no_of_tracks")))
.build())
.centerComponent(gridLayoutPanel(1, 2)
.add(playlistNameField)
.add(noOfTracksField)
.build())
.southComponent(borderLayoutPanel()
.northComponent(new JLabel(BUNDLE.getString("genres")))
.centerComponent(new JScrollPane(genreList))
.build())
.build(), BorderLayout.CENTER);
}
ObservableState parametersValid() {
return model.parametersValid.observable();
}
RandomPlaylistParameters get() {
return new RandomPlaylistParameters(model.playlistName.get(), model.noOfTracks.get(), model.genres.get());
}
private JTextField createPlaylistNameField() {
return stringField(model.playlistName)
.transferFocusOnEnter(true)
.selectAllOnFocusGained(true)
.maximumLength(120)
.columns(10)
.build();
}
private NumberField<Integer> createNoOfTracksField() {
return integerField(model.noOfTracks)
.valueRange(1, 5000)
.transferFocusOnEnter(true)
.selectAllOnFocusGained(true)
.columns(3)
.build();
}
private FilterList<Entity> createGenreList(EntityConnectionProvider connectionProvider) {
return Components.list(filterListModel(allGenres(connectionProvider)))
.selectedItems(model.genres)
.visibleRowCount(5)
.build();
}
private static Collection<Entity> allGenres(EntityConnectionProvider connectionProvider) {
return connectionProvider.connection().select(all(Genre.TYPE)
.orderBy(ascending(Genre.NAME))
.build());
}
private static final class RandomPlaylistParametersModel {
private final Value<String> playlistName = Value.nullable();
private final Value<Integer> noOfTracks = Value.nullable();
private final ValueList<Entity> genres = ValueList.valueList();
private final State parametersValid = State.state();
private RandomPlaylistParametersModel() {
playlistName.addListener(this::validate);
noOfTracks.addListener(this::validate);
genres.addListener(this::validate);
validate();
}
private void validate() {
parametersValid.set(valid());
}
private boolean valid() {
if (nullOrEmpty(playlistName.get())) {
return false;
}
if (noOfTracks.isNull()) {
return false;
}
if (genres.isEmpty()) {
return false;
}
return true;
}
}
}
Playlist Track
SQL
CREATE TABLE CHINOOK.PLAYLISTTRACK
(
ID LONG GENERATED BY DEFAULT AS IDENTITY,
PLAYLIST_ID LONG NOT NULL,
TRACK_ID LONG NOT NULL,
CONSTRAINT PK_PLAYLISTTRACK PRIMARY KEY (ID),
CONSTRAINT UK_PLAYLISTTRACK UNIQUE (PLAYLIST_ID, TRACK_ID),
CONSTRAINT FK_TRACK_PLAYLISTTRACK FOREIGN KEY (TRACK_ID) REFERENCES CHINOOK.TRACK(ID),
CONSTRAINT FK_PLAYLIST_PLAYLISTTRACK FOREIGN KEY (PLAYLIST_ID) REFERENCES CHINOOK.PLAYLIST(ID)
);
Domain
API
interface PlaylistTrack {
EntityType TYPE = DOMAIN.entityType("chinook.playlisttrack", PlaylistTrack.class.getName());
Column<Long> ID = TYPE.longColumn("id");
Column<Long> PLAYLIST_ID = TYPE.longColumn("playlist_id");
Column<Long> TRACK_ID = TYPE.longColumn("track_id");
Attribute<Entity> ALBUM = TYPE.entityAttribute("album");
Attribute<Entity> ARTIST = TYPE.entityAttribute("artist");
ForeignKey PLAYLIST_FK = TYPE.foreignKey("playlist_fk", PLAYLIST_ID, Playlist.ID);
ForeignKey TRACK_FK = TYPE.foreignKey("track_fk", TRACK_ID, Track.ID);
}
Implementation
EntityDefinition playlistTrack() {
return PlaylistTrack.TYPE.define(
PlaylistTrack.ID.define()
.primaryKey(),
PlaylistTrack.PLAYLIST_ID.define()
.column()
.nullable(false),
PlaylistTrack.PLAYLIST_FK.define()
.foreignKey(),
PlaylistTrack.ARTIST.define()
.denormalized(PlaylistTrack.ALBUM, Album.ARTIST_FK),
PlaylistTrack.TRACK_ID.define()
.column()
.nullable(false),
PlaylistTrack.TRACK_FK.define()
.foreignKey()
.referenceDepth(3),
PlaylistTrack.ALBUM.define()
.denormalized(PlaylistTrack.TRACK_FK, Track.ALBUM_FK))
.keyGenerator(identity())
.stringFactory(StringFactory.builder()
.value(PlaylistTrack.PLAYLIST_FK)
.text(" - ")
.value(PlaylistTrack.TRACK_FK)
.build())
.build();
}
Model
PlaylistTrackEditModel
package is.codion.demos.chinook.model;
import is.codion.demos.chinook.domain.api.Chinook.Playlist;
import is.codion.demos.chinook.domain.api.Chinook.PlaylistTrack;
import is.codion.demos.chinook.domain.api.Chinook.Track;
import is.codion.framework.db.EntityConnectionProvider;
import is.codion.framework.domain.entity.Entity;
import is.codion.framework.domain.entity.condition.Condition;
import is.codion.swing.framework.model.SwingEntityEditModel;
public final class PlaylistTrackEditModel extends SwingEntityEditModel {
public PlaylistTrackEditModel(EntityConnectionProvider connectionProvider) {
super(PlaylistTrack.TYPE, connectionProvider);
// So that the track editor value is cleared after a track is added
editor().value(PlaylistTrack.TRACK_FK).persist().set(false);
// Set the search model condition, so the search results
// won't contain tracks already in the selected playlist
searchModel(PlaylistTrack.TRACK_FK).condition().set(this::excludePlaylistTracks);
}
private Condition excludePlaylistTracks() {
Entity playlist = editor().value(PlaylistTrack.PLAYLIST_FK).getOrThrow();
// Use a custom subquery based condition, see domain model implementation
return Track.NOT_IN_PLAYLIST.get(Playlist.ID, playlist.get(Playlist.ID));
}
}
UI
PlaylistTrackEditPanel
package is.codion.demos.chinook.ui;
import is.codion.demos.chinook.domain.api.Chinook.PlaylistTrack;
import is.codion.swing.common.ui.layout.Layouts;
import is.codion.swing.framework.model.SwingEntityEditModel;
import is.codion.swing.framework.ui.EntityEditPanel;
import javax.swing.border.EmptyBorder;
import java.awt.BorderLayout;
import static is.codion.swing.common.ui.component.Components.borderLayoutPanel;
import static is.codion.swing.common.ui.layout.Layouts.borderLayout;
final class PlaylistTrackEditPanel extends EntityEditPanel {
PlaylistTrackEditPanel(SwingEntityEditModel editModel) {
super(editModel, config -> config
// Skip confirmation when deleting
.deleteConfirmer(Confirmer.NONE));
}
@Override
protected void initializeUI() {
focus().initial().set(PlaylistTrack.TRACK_FK);
createSearchField(PlaylistTrack.TRACK_FK)
.selectorFactory(new TrackSelectorFactory())
.transferFocusOnEnter(false)
.columns(25);
setLayout(borderLayout());
add(borderLayoutPanel()
.westComponent(createLabel(PlaylistTrack.TRACK_FK).build())
.centerComponent(component(PlaylistTrack.TRACK_FK).get())
.border(new EmptyBorder(Layouts.GAP.get(), Layouts.GAP.get(), 0, Layouts.GAP.get()))
.build(), BorderLayout.CENTER);
}
}
PlaylistTrackTablePanel
package is.codion.demos.chinook.ui;
import is.codion.common.model.condition.ConditionModel;
import is.codion.demos.chinook.domain.api.Chinook.PlaylistTrack;
import is.codion.framework.domain.entity.EntityDefinition;
import is.codion.framework.model.ForeignKeyConditionModel;
import is.codion.swing.framework.model.SwingEntityTableModel;
import is.codion.swing.framework.ui.EntityConditionComponentFactory;
import is.codion.swing.framework.ui.EntityEditPanel.Confirmer;
import is.codion.swing.framework.ui.EntityTablePanel;
import is.codion.swing.framework.ui.component.EntitySearchField;
import javax.swing.JComponent;
public final class PlaylistTrackTablePanel extends EntityTablePanel {
public PlaylistTrackTablePanel(SwingEntityTableModel tableModel) {
// We provide an edit panel to use when adding a new row. The Add action
// is available via the popup menu, toolbar and keyboard shortcut (INSERT)
super(tableModel, new PlaylistTrackEditPanel(tableModel.editModel()), config -> config
// Custom condition component factory for the track condition panel
.conditionComponentFactory(PlaylistTrack.TRACK_FK,
new TrackConditionComponentFactory(tableModel.entityDefinition()))
// Skip confirmation when deleting
.deleteConfirmer(Confirmer.NONE)
// No need to edit individual rows, we just add or delete
.includeEditAttributeControl(false)
.includeEditControl(false));
// Hide the playlist column
table().columnModel().visible(PlaylistTrack.PLAYLIST_FK).set(false);
}
@Override
protected void setupControls() {
// Modify the ADD control so that it is only enabled when a playlist is selected
control(ControlKeys.ADD).map(control -> control.copy()
.enabled(tableModel().editModel().editor().value(PlaylistTrack.PLAYLIST_FK).present())
.build());
}
// A ComponentFactory, which uses the TrackSelectorFactory, displaying
// a table instead of the default list when selecting tracks
private static final class TrackConditionComponentFactory extends EntityConditionComponentFactory {
private TrackConditionComponentFactory(EntityDefinition entityDefinition) {
super(entityDefinition, PlaylistTrack.TRACK_FK);
}
@Override
public <T> JComponent equal(ConditionModel<T> conditionModel) {
return EntitySearchField.builder(((ForeignKeyConditionModel) conditionModel).equalSearchModel())
.singleSelection()
.selectorFactory(new TrackSelectorFactory())
.build();
}
@Override
public <T> JComponent in(ConditionModel<T> conditionModel) {
return EntitySearchField.builder(((ForeignKeyConditionModel) conditionModel).inSearchModel())
.multiSelection()
.selectorFactory(new TrackSelectorFactory())
.build();
}
}
}
TrackEditComponentFactory
package is.codion.demos.chinook.ui;
import is.codion.framework.domain.entity.Entity;
import is.codion.framework.domain.entity.EntityDefinition;
import is.codion.framework.domain.entity.attribute.ForeignKey;
import is.codion.framework.model.EntitySearchModel;
import is.codion.swing.framework.ui.component.DefaultEditComponentFactory;
import is.codion.swing.framework.ui.component.EntitySearchField;
/**
* Provides a {@link EntitySearchField} using the {@link TrackSelectorFactory}.
*/
final class TrackEditComponentFactory extends DefaultEditComponentFactory<Entity, EntitySearchField> {
TrackEditComponentFactory(ForeignKey trackForeignKey) {
super(trackForeignKey);
}
@Override
protected EntitySearchField.SingleSelectionBuilder searchField(ForeignKey foreignKey,
EntityDefinition entityDefinition,
EntitySearchModel searchModel) {
return (EntitySearchField.SingleSelectionBuilder) super.searchField(foreignKey, entityDefinition, searchModel)
.selectorFactory(new TrackSelectorFactory());
}
}
TrackSelectorFactory
package is.codion.demos.chinook.ui;
import is.codion.demos.chinook.domain.api.Chinook.Track;
import is.codion.swing.framework.ui.component.EntitySearchField;
import is.codion.swing.framework.ui.component.EntitySearchField.Selector;
import is.codion.swing.framework.ui.component.EntitySearchField.TableSelector;
import java.awt.Dimension;
import java.util.function.Function;
import static is.codion.swing.framework.ui.component.EntitySearchField.tableSelector;
/**
* Provides a {@link TableSelector} for selecting tracks,
* displaying columns for the artist, album and track name.
*/
final class TrackSelectorFactory implements Function<EntitySearchField, Selector> {
@Override
public TableSelector apply(EntitySearchField searchField) {
TableSelector selector = tableSelector(searchField);
selector.table().columnModel().visible().set(Track.ARTIST_NAME, Track.ALBUM_FK, Track.NAME);
selector.table().model().sort().ascending(Track.ARTIST_NAME, Track.ALBUM_FK, Track.NAME);
selector.preferredSize(new Dimension(500, 300));
return selector;
}
}
Application
ChinookAppModel
package is.codion.demos.chinook.model;
import is.codion.common.version.Version;
import is.codion.demos.chinook.domain.api.Chinook.Customer;
import is.codion.framework.db.EntityConnectionProvider;
import is.codion.swing.framework.model.SwingEntityApplicationModel;
import is.codion.swing.framework.model.SwingEntityModel;
import java.util.List;
public final class ChinookAppModel extends SwingEntityApplicationModel {
public static final Version VERSION = Version.parse(ChinookAppModel.class, "/version.properties");
public ChinookAppModel(EntityConnectionProvider connectionProvider) {
super(connectionProvider, List.of(
createAlbumModel(connectionProvider),
createPlaylistModel(connectionProvider),
createCustomerModel(connectionProvider)), VERSION);
}
private static SwingEntityModel createAlbumModel(EntityConnectionProvider connectionProvider) {
AlbumModel albumModel = new AlbumModel(connectionProvider);
albumModel.tableModel().items().refresh();
return albumModel;
}
private static SwingEntityModel createPlaylistModel(EntityConnectionProvider connectionProvider) {
SwingEntityModel playlistModel = new SwingEntityModel(new PlaylistTableModel(connectionProvider));
SwingEntityModel playlistTrackModel = new SwingEntityModel(new PlaylistTrackEditModel(connectionProvider));
playlistModel.detailModels().add(playlistModel.link(playlistTrackModel)
.clearValueOnEmptySelection(true)
.active(true)
.build());
playlistModel.tableModel().items().refresh();
return playlistModel;
}
private static SwingEntityModel createCustomerModel(EntityConnectionProvider connectionProvider) {
SwingEntityModel customerModel = new SwingEntityModel(Customer.TYPE, connectionProvider);
customerModel.editModel().initializeComboBoxModels(Customer.SUPPORTREP_FK);
SwingEntityModel invoiceModel = new InvoiceModel(connectionProvider);
customerModel.detailModels().add(invoiceModel);
customerModel.tableModel().items().refresh();
return customerModel;
}
}
UI
ChinookAppPanel
package is.codion.demos.chinook.ui;
import is.codion.common.model.CancelException;
import is.codion.common.model.UserPreferences;
import is.codion.common.user.User;
import is.codion.demos.chinook.domain.api.Chinook;
import is.codion.demos.chinook.model.ArtistTableModel;
import is.codion.demos.chinook.model.ChinookAppModel;
import is.codion.demos.chinook.model.TrackTableModel;
import is.codion.framework.db.EntityConnectionProvider;
import is.codion.plugin.flatlaf.intellij.themes.materialtheme.MaterialTheme;
import is.codion.swing.common.ui.component.combobox.Completion;
import is.codion.swing.common.ui.component.indicator.ValidIndicatorFactory;
import is.codion.swing.common.ui.component.table.FilterTable;
import is.codion.swing.common.ui.component.table.FilterTableCellRenderer;
import is.codion.swing.common.ui.control.Control;
import is.codion.swing.common.ui.control.Controls;
import is.codion.swing.framework.model.SwingEntityModel;
import is.codion.swing.framework.ui.EntityApplicationPanel;
import is.codion.swing.framework.ui.EntityEditPanel;
import is.codion.swing.framework.ui.EntityPanel;
import is.codion.swing.framework.ui.EntityPanel.WindowType;
import is.codion.swing.framework.ui.EntityTablePanel;
import is.codion.swing.framework.ui.ReferentialIntegrityErrorHandling;
import is.codion.swing.framework.ui.TabbedDetailLayout;
import is.codion.swing.framework.ui.icon.FrameworkIcons;
import org.kordamp.ikonli.foundation.Foundation;
import javax.swing.ButtonGroup;
import javax.swing.JOptionPane;
import javax.swing.JPanel;
import javax.swing.JRadioButton;
import javax.swing.JTable;
import javax.swing.SwingConstants;
import java.awt.Dimension;
import java.util.List;
import java.util.Locale;
import java.util.Optional;
import java.util.ResourceBundle;
import static is.codion.demos.chinook.domain.api.Chinook.*;
import static is.codion.swing.common.ui.component.Components.gridLayoutPanel;
import static is.codion.swing.common.ui.component.Components.radioButton;
import static is.codion.swing.common.ui.key.KeyEvents.keyStroke;
import static is.codion.swing.framework.ui.EntityPanel.PanelState.HIDDEN;
import static java.awt.event.InputEvent.CTRL_DOWN_MASK;
import static java.util.ResourceBundle.getBundle;
import static javax.swing.JOptionPane.showMessageDialog;
public final class ChinookAppPanel extends EntityApplicationPanel<ChinookAppModel> {
private static final String LANGUAGE_PREFERENCES_KEY = ChinookAppPanel.class.getSimpleName() + ".language";
private static final String LANGUAGE_IS = "is";
private static final String LANGUAGE_EN = "en";
private static final Locale LOCALE_IS = Locale.of(LANGUAGE_IS, "IS");
private static final Locale LOCALE_EN = Locale.of(LANGUAGE_EN, "EN");
private static final String SELECT_LANGUAGE = "select_language";
/* Non-static so this is not initialized before main(), which sets the locale */
private final ResourceBundle bundle = getBundle(ChinookAppPanel.class.getName());
public ChinookAppPanel(ChinookAppModel applicationModel) {
super(applicationModel, createPanels(applicationModel), createLookupPanelBuilders());
}
private static List<EntityPanel> createPanels(ChinookAppModel applicationModel) {
return List.of(
new CustomerPanel(applicationModel.entityModels().get(Customer.TYPE)),
new AlbumPanel(applicationModel.entityModels().get(Album.TYPE)),
new PlaylistPanel(applicationModel.entityModels().get(Playlist.TYPE)));
}
private static List<EntityPanel.Builder> createLookupPanelBuilders() {
EntityPanel.Builder genrePanelBuilder =
EntityPanel.builder(Genre.TYPE,
ChinookAppPanel::createGenrePanel);
EntityPanel.Builder mediaTypePanelBuilder =
EntityPanel.builder(MediaType.TYPE,
ChinookAppPanel::createMediaTypePanel);
EntityPanel.Builder artistPanelBuilder =
EntityPanel.builder(Artist.TYPE,
ChinookAppPanel::createArtistPanel);
EntityPanel.Builder employeePanelBuilder =
EntityPanel.builder(Employee.TYPE,
ChinookAppPanel::createEmployeePanel);
EntityPanel.Builder preferencesPanelBuilder =
EntityPanel.builder(Preferences.TYPE,
ChinookAppPanel::createPreferencesPanel);
return List.of(artistPanelBuilder, genrePanelBuilder, mediaTypePanelBuilder, employeePanelBuilder, preferencesPanelBuilder);
}
private static EntityPanel createGenrePanel(EntityConnectionProvider connectionProvider) {
SwingEntityModel genreModel = new SwingEntityModel(Genre.TYPE, connectionProvider);
SwingEntityModel trackModel = new SwingEntityModel(new TrackTableModel(connectionProvider));
genreModel.detailModels().add(trackModel);
genreModel.tableModel().items().refresh();
EntityPanel genrePanel = new EntityPanel(genreModel,
new GenreEditPanel(genreModel.editModel()), config ->
config.detailLayout(entityPanel -> TabbedDetailLayout.builder(entityPanel)
.initialDetailState(HIDDEN)
.build()));
genrePanel.detailPanels().add(new EntityPanel(trackModel));
return genrePanel;
}
private static EntityPanel createMediaTypePanel(EntityConnectionProvider connectionProvider) {
SwingEntityModel mediaTypeModel = new SwingEntityModel(MediaType.TYPE, connectionProvider);
mediaTypeModel.tableModel().items().refresh();
return new EntityPanel(mediaTypeModel, new MediaTypeEditPanel(mediaTypeModel.editModel()));
}
private static EntityPanel createArtistPanel(EntityConnectionProvider connectionProvider) {
SwingEntityModel artistModel = new SwingEntityModel(new ArtistTableModel(connectionProvider));
artistModel.tableModel().items().refresh();
return new EntityPanel(artistModel,
new ArtistEditPanel(artistModel.editModel()),
new ArtistTablePanel(artistModel.tableModel()));
}
private static EntityPanel createEmployeePanel(EntityConnectionProvider connectionProvider) {
SwingEntityModel employeeModel = new SwingEntityModel(Employee.TYPE, connectionProvider);
SwingEntityModel customerModel = new SwingEntityModel(Customer.TYPE, connectionProvider);
employeeModel.detailModels().add(customerModel);
employeeModel.tableModel().items().refresh();
EntityPanel employeePanel = new EntityPanel(employeeModel,
new EmployeeTablePanel(employeeModel.tableModel()), config -> config
.detailLayout(entityPanel -> TabbedDetailLayout.builder(entityPanel)
.initialDetailState(HIDDEN)
.build()));
EntityPanel customerPanel = new EntityPanel(customerModel,
new CustomerTablePanel(customerModel.tableModel()));
employeePanel.detailPanels().add(customerPanel);
employeePanel.setPreferredSize(new Dimension(1000, 500));
return employeePanel;
}
private static EntityPanel createPreferencesPanel(EntityConnectionProvider connectionProvider) {
SwingEntityModel preferencesModel = new SwingEntityModel(Preferences.TYPE, connectionProvider);
preferencesModel.editModel().initializeComboBoxModels(Preferences.PREFERRED_GENRE_FK);
preferencesModel.tableModel().items().refresh();
return new EntityPanel(preferencesModel,
new PreferencesEditPanel(preferencesModel.editModel()));
}
@Override
protected Optional<Controls> createViewMenuControls() {
return super.createViewMenuControls()
.map(controls -> controls.copy()
.controlAt(2, Control.builder()
.command(this::selectLanguage)
.caption(bundle.getString(SELECT_LANGUAGE))
.build())
.build());
}
private void selectLanguage() {
String currentLanguage = UserPreferences.get(LANGUAGE_PREFERENCES_KEY, Locale.getDefault().getLanguage());
JPanel languagePanel = gridLayoutPanel(2, 1).build();
ButtonGroup buttonGroup = new ButtonGroup();
radioButton()
.text(bundle.getString("english"))
.selected(currentLanguage.equals(LANGUAGE_EN))
.buttonGroup(buttonGroup)
.build(languagePanel::add);
JRadioButton isButton = radioButton()
.text(bundle.getString("icelandic"))
.selected(currentLanguage.equals(LANGUAGE_IS))
.buttonGroup(buttonGroup)
.build(languagePanel::add);
showMessageDialog(this, languagePanel, bundle.getString("language"), JOptionPane.QUESTION_MESSAGE);
String selectedLanguage = isButton.isSelected() ? LANGUAGE_IS : LANGUAGE_EN;
if (!currentLanguage.equals(selectedLanguage)) {
UserPreferences.set(LANGUAGE_PREFERENCES_KEY, selectedLanguage);
showMessageDialog(this, bundle.getString("language_has_been_changed"));
}
}
public static void main(String[] args) throws CancelException {
String language = UserPreferences.get(LANGUAGE_PREFERENCES_KEY, Locale.getDefault().getLanguage());
Locale.setDefault(LANGUAGE_IS.equals(language) ? LOCALE_IS : LOCALE_EN);
FrameworkIcons.instance().add(Foundation.PLUS, Foundation.MINUS);
Completion.COMPLETION_MODE.set(Completion.Mode.AUTOCOMPLETE);
EntityApplicationPanel.CACHE_ENTITY_PANELS.set(true);
EntityPanel.Config.TOOLBAR_CONTROLS.set(true);
EntityPanel.Config.WINDOW_TYPE.set(WindowType.FRAME);
EntityEditPanel.Config.MODIFIED_WARNING.set(true);
// Add a CTRL modifier to the DELETE key shortcut for table panels
EntityTablePanel.ControlKeys.DELETE.defaultKeystroke().map(keyStroke ->
keyStroke(keyStroke.getKeyCode(), CTRL_DOWN_MASK));
EntityTablePanel.Config.COLUMN_SELECTION.set(EntityTablePanel.ColumnSelection.MENU);
EntityTablePanel.Config.AUTO_RESIZE_MODE_SELECTION.set(EntityTablePanel.AutoResizeModeSelection.MENU);
EntityTablePanel.Config.INCLUDE_FILTERS.set(true);
FilterTable.AUTO_RESIZE_MODE.set(JTable.AUTO_RESIZE_ALL_COLUMNS);
FilterTableCellRenderer.NUMERICAL_HORIZONTAL_ALIGNMENT.set(SwingConstants.CENTER);
FilterTableCellRenderer.TEMPORAL_HORIZONTAL_ALIGNMENT.set(SwingConstants.CENTER);
ValidIndicatorFactory.FACTORY_CLASS.set("is.codion.plugin.flatlaf.indicator.FlatLafValidIndicatorFactory");
ReferentialIntegrityErrorHandling.REFERENTIAL_INTEGRITY_ERROR_HANDLING
.set(ReferentialIntegrityErrorHandling.DISPLAY_DEPENDENCIES);
EntityApplicationPanel.builder(ChinookAppModel.class, ChinookAppPanel.class)
.applicationName("Chinook")
.applicationVersion(ChinookAppModel.VERSION)
.domainType(Chinook.DOMAIN)
.defaultLookAndFeel(MaterialTheme.class)
.defaultUser(User.parse("scott:tiger"))
.start();
}
}
Messages
ChinookResources
package is.codion.demos.chinook.i18n;
import is.codion.common.resource.Resources;
import is.codion.framework.i18n.FrameworkMessages;
import java.util.Locale;
/**
* Replace the english modified warning text and title.
*/
public final class ChinookResources implements Resources {
private static final String FRAMEWORK_MESSAGES =
FrameworkMessages.class.getName();
private final boolean english = Locale.getDefault()
.equals(Locale.of("en", "EN"));
@Override
public String getString(String baseBundleName, String key, String defaultString) {
if (english && baseBundleName.equals(FRAMEWORK_MESSAGES)) {
return switch (key) {
case "modified_warning" -> "Unsaved changes will be lost, continue?";
case "modified_warning_title" -> "Unsaved changes";
default -> defaultString;
};
}
return defaultString;
}
}
Authenticator
SQL
CREATE TABLE CHINOOK.USERS
(
ID LONG GENERATED BY DEFAULT AS IDENTITY,
USERNAME VARCHAR(20) NOT NULL,
PASSWORDHASH INTEGER NOT NULL,
CONSTRAINT PK_USER PRIMARY KEY (ID),
CONSTRAINT UK_USER UNIQUE (USERNAME)
);
ChinookAuthenticator
package is.codion.demos.chinook.server;
import is.codion.common.db.database.Database;
import is.codion.common.db.pool.ConnectionPoolFactory;
import is.codion.common.db.pool.ConnectionPoolWrapper;
import is.codion.common.rmi.server.Authenticator;
import is.codion.common.rmi.server.RemoteClient;
import is.codion.common.rmi.server.exception.LoginException;
import is.codion.common.rmi.server.exception.ServerAuthenticationException;
import is.codion.common.user.User;
import is.codion.framework.db.EntityConnection;
import is.codion.framework.domain.Domain;
import is.codion.framework.domain.DomainModel;
import is.codion.framework.domain.DomainType;
import is.codion.framework.domain.entity.EntityType;
import is.codion.framework.domain.entity.attribute.Column;
import java.util.Optional;
import static is.codion.framework.db.EntityConnection.Count.where;
import static is.codion.framework.db.local.LocalEntityConnection.localEntityConnection;
import static is.codion.framework.domain.DomainType.domainType;
import static is.codion.framework.domain.entity.condition.Condition.and;
import static java.lang.String.valueOf;
/**
* A {@link is.codion.common.rmi.server.Authenticator} implementation
* authenticating via a user lookup table.
*/
public final class ChinookAuthenticator implements Authenticator {
/**
* The Database instance we're connecting to.
*/
private final Database database = Database.instance();
/**
* The actual user credentials to return for successfully authenticated users.
*/
private final User databaseUser = User.parse("scott:tiger");
/**
* The Domain containing the authentication table.
*/
private final Domain domain = new Authentication();
/**
* The ConnectionPool used when authenticating users.
*/
private final ConnectionPoolWrapper connectionPool;
/**
* The user used for authenticating.
*/
private final User authenticationUser = User.user("sa");
public ChinookAuthenticator() {
connectionPool = ConnectionPoolFactory.instance().createConnectionPool(database, authenticationUser);
}
/**
* Handles logins from clients of this type
*/
@Override
public Optional<String> type() {
return Optional.of("is.codion.demos.chinook.ui.ChinookAppPanel");
}
@Override
public RemoteClient login(RemoteClient remoteClient) throws LoginException {
authenticateUser(remoteClient.user());
//Create a new RemoteClient based on the one received
//but with the actual database user
return remoteClient.withDatabaseUser(databaseUser);
}
@Override
public void close() {
connectionPool.close();
}
private void authenticateUser(User user) throws LoginException {
try (EntityConnection connection = fetchConnectionFromPool()) {
int rows = connection.count(where(and(
Authentication.User.USERNAME
.equalToIgnoreCase(user.username()),
Authentication.User.PASSWORD_HASH
.equalTo(valueOf(user.password()).hashCode()))));
if (rows == 0) {
throw new ServerAuthenticationException("Wrong username or password");
}
}
}
private EntityConnection fetchConnectionFromPool() {
return localEntityConnection(database, domain, connectionPool.connection(authenticationUser));
}
private static final class Authentication extends DomainModel {
private static final DomainType DOMAIN = domainType(Authentication.class);
interface User {
EntityType TYPE = DOMAIN.entityType("chinook.users");
Column<Integer> ID = TYPE.integerColumn("id");
Column<String> USERNAME = TYPE.stringColumn("username");
Column<Integer> PASSWORD_HASH = TYPE.integerColumn("passwordhash");
}
private Authentication() {
super(DOMAIN);
add(User.TYPE.define(
User.ID.define()
.primaryKey(),
User.USERNAME.define()
.column(),
User.PASSWORD_HASH.define()
.column())
.readOnly(true)
.build());
}
}
}
Domain unit test
package is.codion.demos.chinook.domain;
import is.codion.demos.chinook.domain.api.Chinook.Playlist.RandomPlaylistParameters;
import is.codion.framework.db.EntityConnection;
import is.codion.framework.domain.entity.Entity;
import is.codion.framework.domain.entity.attribute.Attribute;
import is.codion.framework.domain.test.DefaultEntityFactory;
import is.codion.framework.domain.test.DomainTest;
import org.junit.jupiter.api.Test;
import java.util.List;
import static is.codion.demos.chinook.domain.api.Chinook.*;
import static org.junit.jupiter.api.Assertions.assertEquals;
public class ChinookTest extends DomainTest {
public ChinookTest() {
super(new ChinookImpl(), ChinookEntityFactory::new);
}
@Test
void album() {
test(Album.TYPE);
}
@Test
void artist() {
test(Artist.TYPE);
}
@Test
void customer() {
test(Customer.TYPE);
}
@Test
void employee() {
test(Employee.TYPE);
}
@Test
void genre() {
test(Genre.TYPE);
}
@Test
void invoce() {
test(Invoice.TYPE);
}
@Test
void invoiceLine() {
test(InvoiceLine.TYPE);
}
@Test
void mediaType() {
test(MediaType.TYPE);
}
@Test
void playlist() {
test(Playlist.TYPE);
}
@Test
void playlistTrack() {
test(PlaylistTrack.TYPE);
}
@Test
void track() {
test(Track.TYPE);
}
@Test
void randomPlaylist() {
EntityConnection connection = connection();
connection.startTransaction();
try {
Entity genre = connection.selectSingle(Genre.NAME.equalTo("Metal"));
int noOfTracks = 10;
String playlistName = "MetalPlaylistTest";
RandomPlaylistParameters parameters = new RandomPlaylistParameters(playlistName, noOfTracks, List.of(genre));
Entity playlist = connection.execute(Playlist.RANDOM_PLAYLIST, parameters);
assertEquals(playlistName, playlist.get(Playlist.NAME));
List<Entity> playlistTracks = connection.select(PlaylistTrack.PLAYLIST_FK.equalTo(playlist));
assertEquals(noOfTracks, playlistTracks.size());
playlistTracks.stream()
.map(playlistTrack -> playlistTrack.get(PlaylistTrack.TRACK_FK))
.forEach(track -> assertEquals(genre, track.get(Track.GENRE_FK)));
}
finally {
connection.rollbackTransaction();
}
}
private static final class ChinookEntityFactory extends DefaultEntityFactory {
private ChinookEntityFactory(EntityConnection connection) {
super(connection);
}
@Override
public void modify(Entity entity) {
super.modify(entity);
if (entity.type().equals(Album.TYPE)) {
entity.set(Album.TAGS, List.of("tag_one", "tag_two", "tag_three"));
}
}
@Override
protected <T> T value(Attribute<T> attribute) {
if (attribute.equals(Album.TAGS)) {
return (T) List.of("tag_one", "tag_two");
}
return super.value(attribute);
}
}
}
Load test
package is.codion.demos.chinook.client.loadtest;
import is.codion.common.user.User;
import is.codion.demos.chinook.client.loadtest.scenarios.InsertDeleteAlbum;
import is.codion.demos.chinook.client.loadtest.scenarios.InsertDeleteInvoice;
import is.codion.demos.chinook.client.loadtest.scenarios.LogoutLogin;
import is.codion.demos.chinook.client.loadtest.scenarios.RaisePrices;
import is.codion.demos.chinook.client.loadtest.scenarios.RandomPlaylist;
import is.codion.demos.chinook.client.loadtest.scenarios.UpdateTotals;
import is.codion.demos.chinook.client.loadtest.scenarios.ViewAlbum;
import is.codion.demos.chinook.client.loadtest.scenarios.ViewCustomerReport;
import is.codion.demos.chinook.client.loadtest.scenarios.ViewGenre;
import is.codion.demos.chinook.client.loadtest.scenarios.ViewInvoice;
import is.codion.demos.chinook.domain.api.Chinook;
import is.codion.demos.chinook.model.ChinookAppModel;
import is.codion.demos.chinook.ui.ChinookAppPanel;
import is.codion.framework.db.EntityConnectionProvider;
import is.codion.tools.loadtest.LoadTest;
import is.codion.tools.loadtest.LoadTest.Scenario;
import is.codion.tools.loadtest.model.LoadTestModel;
import java.util.Collection;
import java.util.List;
import java.util.function.Function;
import static is.codion.tools.loadtest.LoadTest.Scenario.scenario;
import static is.codion.tools.loadtest.ui.LoadTestPanel.loadTestPanel;
public final class ChinookLoadTest {
private static final User UNIT_TEST_USER =
User.parse(System.getProperty("codion.test.user", "scott:tiger"));
private static final Collection<Scenario<EntityConnectionProvider>> SCENARIOS = List.of(
scenario(new ViewGenre(), 10),
scenario(new ViewCustomerReport(), 2),
scenario(new ViewInvoice(), 10),
scenario(new ViewAlbum(), 10),
scenario(new UpdateTotals(), 1),
scenario(new InsertDeleteAlbum(), 3),
scenario(new LogoutLogin(), 1),
scenario(new RaisePrices(), 1),
scenario(new RandomPlaylist(), 1),
scenario(new InsertDeleteInvoice(), 3));
private static final class ConnectionProviderFactory implements Function<User, EntityConnectionProvider> {
@Override
public EntityConnectionProvider apply(User user) {
EntityConnectionProvider connectionProvider = EntityConnectionProvider.builder()
.domainType(Chinook.DOMAIN)
.clientType(ChinookAppPanel.class.getName())
.clientVersion(ChinookAppModel.VERSION)
.user(user)
.build();
connectionProvider.connection();
return connectionProvider;
}
}
public static void main(String[] args) {
LoadTest<EntityConnectionProvider> loadTest =
LoadTest.builder(new ConnectionProviderFactory(), EntityConnectionProvider::close)
.scenarios(SCENARIOS)
.user(UNIT_TEST_USER)
.build();
loadTestPanel(LoadTestModel.loadTestModel(loadTest)).run();
}
}
Scenarios
package is.codion.demos.chinook.client.loadtest.scenarios;
import java.util.Random;
final class LoadTestUtil {
private static final int MAX_ARTIST_ID = 275;
private static final int MAX_CUSTOMER_ID = 59;
private static final int MAX_TRACK_ID = 3503;
static final Random RANDOM = new Random();
static long randomArtistId() {
return RANDOM.nextInt(MAX_ARTIST_ID) + 1;
}
static long randomCustomerId() {
return RANDOM.nextInt(MAX_CUSTOMER_ID) + 1;
}
static long randomTrackId() {
return RANDOM.nextInt(MAX_TRACK_ID) + 1;
}
}
package is.codion.demos.chinook.client.loadtest.scenarios;
import is.codion.demos.chinook.domain.api.Chinook.Album;
import is.codion.demos.chinook.domain.api.Chinook.Artist;
import is.codion.demos.chinook.domain.api.Chinook.Genre;
import is.codion.demos.chinook.domain.api.Chinook.MediaType;
import is.codion.demos.chinook.domain.api.Chinook.Track;
import is.codion.framework.db.EntityConnection;
import is.codion.framework.db.EntityConnectionProvider;
import is.codion.framework.domain.entity.Entity;
import is.codion.tools.loadtest.LoadTest.Scenario.Performer;
import java.math.BigDecimal;
import java.util.ArrayList;
import java.util.Collection;
import java.util.List;
import static is.codion.demos.chinook.client.loadtest.scenarios.LoadTestUtil.RANDOM;
import static is.codion.demos.chinook.client.loadtest.scenarios.LoadTestUtil.randomArtistId;
import static is.codion.framework.domain.entity.condition.Condition.all;
public final class InsertDeleteAlbum implements Performer<EntityConnectionProvider> {
private static final BigDecimal UNIT_PRICE = BigDecimal.valueOf(2);
@Override
public void perform(EntityConnectionProvider connectionProvider) throws Exception {
EntityConnection connection = connectionProvider.connection();
Entity artist = connection.selectSingle(Artist.ID.equalTo(randomArtistId()));
Entity album = connectionProvider.entities().builder(Album.TYPE)
.with(Album.ARTIST_FK, artist)
.with(Album.TITLE, "Title")
.build();
album = connection.insertSelect(album);
List<Entity> genres = connection.select(all(Genre.TYPE));
List<Entity> mediaTypes = connection.select(all(MediaType.TYPE));
Collection<Entity> tracks = new ArrayList<>(10);
for (int i = 0; i < 10; i++) {
Entity track = connectionProvider.entities().builder(Track.TYPE)
.with(Track.ALBUM_FK, album)
.with(Track.NAME, "Track " + i)
.with(Track.BYTES, RANDOM.nextInt(1_000_000))
.with(Track.COMPOSER, "Composer")
.with(Track.MILLISECONDS, RANDOM.nextInt(1_000_000))
.with(Track.UNITPRICE, UNIT_PRICE)
.with(Track.GENRE_FK, genres.get(RANDOM.nextInt(genres.size())))
.with(Track.MEDIATYPE_FK, mediaTypes.get(RANDOM.nextInt(mediaTypes.size())))
.with(Track.RATING, 5)
.build();
tracks.add(track);
}
tracks = connection.insertSelect(tracks);
Collection<Entity.Key> toDelete = new ArrayList<>(Entity.primaryKeys(tracks));
toDelete.add(album.primaryKey());
connection.delete(toDelete);
}
}
package is.codion.demos.chinook.client.loadtest.scenarios;
import is.codion.framework.db.EntityConnectionProvider;
import is.codion.tools.loadtest.LoadTest.Scenario.Performer;
import static is.codion.demos.chinook.client.loadtest.scenarios.LoadTestUtil.RANDOM;
public final class LogoutLogin implements Performer<EntityConnectionProvider> {
@Override
public void perform(EntityConnectionProvider connectionProvider) {
try {
connectionProvider.close();
Thread.sleep(RANDOM.nextInt(1500));
connectionProvider.connection();
}
catch (InterruptedException ignored) {/*ignored*/}
}
}
package is.codion.demos.chinook.client.loadtest.scenarios;
import is.codion.demos.chinook.domain.api.Chinook.Genre;
import is.codion.demos.chinook.domain.api.Chinook.Playlist;
import is.codion.demos.chinook.domain.api.Chinook.Playlist.RandomPlaylistParameters;
import is.codion.demos.chinook.domain.api.Chinook.PlaylistTrack;
import is.codion.framework.db.EntityConnection;
import is.codion.framework.db.EntityConnectionProvider;
import is.codion.framework.domain.entity.Entity;
import is.codion.tools.loadtest.LoadTest.Scenario.Performer;
import java.util.Collection;
import java.util.List;
import java.util.UUID;
import static is.codion.demos.chinook.client.loadtest.scenarios.LoadTestUtil.RANDOM;
import static is.codion.framework.db.EntityConnection.transaction;
public final class RandomPlaylist implements Performer<EntityConnectionProvider> {
private static final String PLAYLIST_NAME = "Random playlist";
private static final Collection<String> GENRES =
List.of("Alternative", "Rock", "Metal", "Heavy Metal", "Pop");
@Override
public void perform(EntityConnectionProvider connectionProvider) {
EntityConnection connection = connectionProvider.connection();
List<Entity> playlistGenres = connection.select(Genre.NAME.in(GENRES));
RandomPlaylistParameters parameters = new RandomPlaylistParameters(PLAYLIST_NAME + " " + UUID.randomUUID(),
RANDOM.nextInt(20) + 25, playlistGenres);
Entity playlist = transaction(connection, () -> connection.execute(Playlist.RANDOM_PLAYLIST, parameters));
Collection<Entity> playlistTracks = connection.select(PlaylistTrack.PLAYLIST_FK.equalTo(playlist));
Collection<Entity.Key> toDelete = Entity.primaryKeys(playlistTracks);
toDelete.add(playlist.primaryKey());
connection.delete(toDelete);
}
}
package is.codion.demos.chinook.client.loadtest.scenarios;
import is.codion.demos.chinook.domain.api.Chinook.Album;
import is.codion.demos.chinook.domain.api.Chinook.Artist;
import is.codion.demos.chinook.domain.api.Chinook.Track;
import is.codion.demos.chinook.domain.api.Chinook.Track.RaisePriceParameters;
import is.codion.framework.db.EntityConnection;
import is.codion.framework.db.EntityConnectionProvider;
import is.codion.framework.domain.entity.Entity;
import is.codion.tools.loadtest.LoadTest.Scenario.Performer;
import java.math.BigDecimal;
import java.util.Collection;
import java.util.List;
import static is.codion.demos.chinook.client.loadtest.scenarios.LoadTestUtil.randomArtistId;
import static is.codion.framework.db.EntityConnection.Select.where;
public final class RaisePrices implements Performer<EntityConnectionProvider> {
private static final BigDecimal PRICE_INCREASE = BigDecimal.valueOf(0.01);
@Override
public void perform(EntityConnectionProvider connectionProvider) throws Exception {
EntityConnection connection = connectionProvider.connection();
Entity artist = connection.selectSingle(Artist.ID.equalTo(randomArtistId()));
List<Entity> albums = connection.select(where(Album.ARTIST_FK.equalTo(artist))
.limit(1)
.build());
if (!albums.isEmpty()) {
List<Entity> tracks = connection.select(Track.ALBUM_FK.equalTo(albums.getFirst()));
Collection<Long> trackIds = Entity.values(Track.ID, tracks);
connection.execute(Track.RAISE_PRICE, new RaisePriceParameters(trackIds, PRICE_INCREASE));
}
}
}
package is.codion.demos.chinook.client.loadtest.scenarios;
import is.codion.demos.chinook.domain.api.Chinook.Customer;
import is.codion.demos.chinook.domain.api.Chinook.Invoice;
import is.codion.demos.chinook.domain.api.Chinook.InvoiceLine;
import is.codion.framework.db.EntityConnection;
import is.codion.framework.db.EntityConnectionProvider;
import is.codion.framework.domain.entity.Entity;
import is.codion.tools.loadtest.LoadTest.Scenario.Performer;
import java.util.Collection;
import java.util.List;
import static is.codion.demos.chinook.client.loadtest.scenarios.LoadTestUtil.RANDOM;
import static is.codion.demos.chinook.client.loadtest.scenarios.LoadTestUtil.randomCustomerId;
import static is.codion.framework.db.EntityConnection.transaction;
import static is.codion.framework.domain.entity.Entity.distinct;
import static java.util.stream.Collectors.toList;
public final class UpdateTotals implements Performer<EntityConnectionProvider> {
@Override
public void perform(EntityConnectionProvider connectionProvider) {
EntityConnection connection = connectionProvider.connection();
Entity customer = connection.selectSingle(Customer.ID.equalTo(randomCustomerId()));
List<Long> invoiceIds = connection.select(Invoice.ID, Invoice.CUSTOMER_FK.equalTo(customer));
if (!invoiceIds.isEmpty()) {
Entity invoice = connection.selectSingle(Invoice.ID.equalTo(invoiceIds.get(RANDOM.nextInt(invoiceIds.size()))));
updateInvoiceLines(connection.select(InvoiceLine.INVOICE_FK.equalTo(invoice)).stream()
.map(invoiceLine -> invoiceLine.copy().builder()
.with(InvoiceLine.QUANTITY, invoiceLine.get(InvoiceLine.QUANTITY) + 1)
.build())
.collect(toList()), connection);
}
}
private static void updateInvoiceLines(Collection<Entity> invoiceLines, EntityConnection connection) {
transaction(connection, () -> {
connection.update(invoiceLines);
connection.execute(Invoice.UPDATE_TOTALS, distinct(InvoiceLine.INVOICE_ID, invoiceLines));
});
}
}
package is.codion.demos.chinook.client.loadtest.scenarios;
import is.codion.demos.chinook.domain.api.Chinook;
import is.codion.demos.chinook.domain.api.Chinook.Album;
import is.codion.demos.chinook.domain.api.Chinook.Artist;
import is.codion.framework.db.EntityConnection;
import is.codion.framework.db.EntityConnectionProvider;
import is.codion.framework.domain.entity.Entity;
import is.codion.tools.loadtest.LoadTest.Scenario.Performer;
import java.util.List;
import static is.codion.demos.chinook.client.loadtest.scenarios.LoadTestUtil.randomArtistId;
import static is.codion.framework.db.EntityConnection.Select.where;
public final class ViewAlbum implements Performer<EntityConnectionProvider> {
@Override
public void perform(EntityConnectionProvider connectionProvider) throws Exception {
EntityConnection connection = connectionProvider.connection();
Entity artist = connection.selectSingle(Artist.ID.equalTo(randomArtistId()));
List<Entity> albums = connection.select(where(Album.ARTIST_FK.equalTo(artist))
.limit(1)
.build());
if (!albums.isEmpty()) {
connection.select(Chinook.Track.ALBUM_FK.equalTo(albums.getFirst()));
}
}
}
package is.codion.demos.chinook.client.loadtest.scenarios;
import is.codion.demos.chinook.domain.api.Chinook.Customer;
import is.codion.framework.db.EntityConnection;
import is.codion.framework.db.EntityConnectionProvider;
import is.codion.tools.loadtest.LoadTest.Scenario.Performer;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import static is.codion.demos.chinook.client.loadtest.scenarios.LoadTestUtil.randomCustomerId;
public final class ViewCustomerReport implements Performer<EntityConnectionProvider> {
@Override
public void perform(EntityConnectionProvider connectionProvider) throws Exception {
EntityConnection connection = connectionProvider.connection();
Map<String, Object> reportParameters = new HashMap<>();
reportParameters.put("CUSTOMER_IDS", List.of(randomCustomerId()));
connection.report(Customer.REPORT, reportParameters);
}
}
package is.codion.demos.chinook.client.loadtest.scenarios;
import is.codion.demos.chinook.domain.api.Chinook.Genre;
import is.codion.demos.chinook.domain.api.Chinook.Track;
import is.codion.framework.db.EntityConnection;
import is.codion.framework.db.EntityConnectionProvider;
import is.codion.framework.domain.entity.Entity;
import is.codion.tools.loadtest.LoadTest.Scenario.Performer;
import java.util.ArrayList;
import java.util.List;
import static is.codion.demos.chinook.client.loadtest.scenarios.LoadTestUtil.RANDOM;
import static is.codion.framework.domain.entity.condition.Condition.all;
public final class ViewGenre implements Performer<EntityConnectionProvider> {
@Override
public void perform(EntityConnectionProvider connectionProvider) throws Exception {
EntityConnection connection = connectionProvider.connection();
List<Entity> genres = connection.select(all(Genre.TYPE));
List<Entity> tracks = connection.select(Track.GENRE_FK.equalTo(genres.get(RANDOM.nextInt(genres.size()))));
if (!tracks.isEmpty()) {
connection.dependencies(new ArrayList<>(tracks.subList(0, Math.min(10, tracks.size()))));
}
}
}
package is.codion.demos.chinook.client.loadtest.scenarios;
import is.codion.demos.chinook.domain.api.Chinook.Customer;
import is.codion.demos.chinook.domain.api.Chinook.Invoice;
import is.codion.demos.chinook.domain.api.Chinook.InvoiceLine;
import is.codion.framework.db.EntityConnection;
import is.codion.framework.db.EntityConnectionProvider;
import is.codion.framework.domain.entity.Entity;
import is.codion.tools.loadtest.LoadTest.Scenario.Performer;
import java.util.List;
import static is.codion.demos.chinook.client.loadtest.scenarios.LoadTestUtil.RANDOM;
import static is.codion.demos.chinook.client.loadtest.scenarios.LoadTestUtil.randomCustomerId;
public final class ViewInvoice implements Performer<EntityConnectionProvider> {
@Override
public void perform(EntityConnectionProvider connectionProvider) throws Exception {
EntityConnection connection = connectionProvider.connection();
Entity customer = connection.selectSingle(Customer.ID.equalTo(randomCustomerId()));
List<Long> invoiceIds = connection.select(Invoice.ID, Invoice.CUSTOMER_FK.equalTo(customer));
if (!invoiceIds.isEmpty()) {
Entity invoice = connection.selectSingle(Invoice.ID.equalTo(invoiceIds.get(RANDOM.nextInt(invoiceIds.size()))));
connection.select(InvoiceLine.INVOICE_FK.equalTo(invoice));
}
}
}
Database Migration
This section demonstrates a hybrid approach to database migrations, combining raw JDBC for executing migration scripts with Codion’s entity framework for tracking migration history.
Migration SQL Scripts
V1: Initial Schema
-- This migration represents the initial schema
-- In a real application, this would be the starting point
-- For this demo, the schema is already created by create_schema.sql
-- So this migration just records that V1 is already applied
-- This migration is intentionally empty as the schema already exists
-- Future migrations will add to this base schema
V2: Add Track Play Count
-- Add play count tracking to tracks
ALTER TABLE CHINOOK.TRACK ADD COLUMN IF NOT EXISTS PLAY_COUNT INTEGER DEFAULT 0;
-- Create index for performance when querying popular tracks
CREATE INDEX IF NOT EXISTS IDX_TRACK_PLAY_COUNT ON CHINOOK.TRACK(PLAY_COUNT DESC);
V3: Add Preferences
-- Add preferences table for storing user-specific settings
CREATE TABLE IF NOT EXISTS CHINOOK.PREFERENCES (
CUSTOMER_ID LONG NOT NULL,
PREFERRED_GENRE_ID LONG,
NEWSLETTER_SUBSCRIBED BOOLEAN DEFAULT FALSE,
CONSTRAINT PK_PREFERENCES PRIMARY KEY (CUSTOMER_ID),
CONSTRAINT FK_PREFERENCES_CUSTOMER FOREIGN KEY (CUSTOMER_ID) REFERENCES CHINOOK.CUSTOMER(ID),
CONSTRAINT FK_PREFERENCES_GENRE FOREIGN KEY (PREFERRED_GENRE_ID) REFERENCES CHINOOK.GENRE(ID)
);
MigrationManager
package is.codion.demos.chinook.migration;
import is.codion.common.db.database.Database;
import is.codion.common.db.exception.DatabaseException;
import is.codion.common.property.PropertyValue;
import is.codion.common.user.User;
import is.codion.demos.chinook.migration.MigrationDomain.Migration;
import java.io.IOException;
import java.sql.Connection;
import java.sql.SQLException;
import java.util.List;
import static is.codion.common.Configuration.booleanValue;
/**
* A simple database migration manager for the Chinook demo.
* This demonstrates how Codion's Domain.configure(Database) method
* can be used for database migrations without external dependencies.
*/
public final class MigrationManager {
// In JPMS, we need to list resources explicitly
private static final String[] MIGRATION_FILES = new String[] {
"V1__Initial_schema.sql",
"V2__Add_track_play_count.sql",
"V3__Add_preferences.sql"
};
public static final PropertyValue<Boolean> MIGRATION_ENABLED =
booleanValue("chinook.migration.enabled", true);
private static final MigrationDomain DOMAIN = new MigrationDomain();
private MigrationManager() {}
public static void migrate(Database database) throws DatabaseException {
if (!MIGRATION_ENABLED.get()) {
System.out.println("[MigrationManager] Database migration disabled");
return;
}
try (Connection connection = database.createConnection(User.parse("scott:tiger"))) {
List<Migration> pendingMigrations = DOMAIN.pendingMigrations(connection, MIGRATION_FILES);
if (pendingMigrations.isEmpty()) {
System.out.println("[MigrationManager] Database is up to date");
return;
}
System.out.println("[MigrationManager] Found " + pendingMigrations.size() + " pending migration(s)");
for (Migration migration : pendingMigrations) {
DOMAIN.applyMigration(connection, database, migration);
}
connection.commit();
System.out.println("[MigrationManager] All migrations completed successfully");
}
catch (SQLException e) {
throw new DatabaseException(e, "Migration failed");
}
catch (IOException e) {
throw new DatabaseException("Migration failed: " + e.getMessage());
}
}
}
MigrationDomain
package is.codion.demos.chinook.migration;
import is.codion.common.db.database.Database;
import is.codion.common.db.exception.DatabaseException;
import is.codion.common.db.result.ResultPacker;
import is.codion.common.user.User;
import is.codion.framework.db.EntityConnection;
import is.codion.framework.db.local.LocalEntityConnection;
import is.codion.framework.domain.DomainModel;
import is.codion.framework.domain.DomainType;
import is.codion.framework.domain.entity.EntityType;
import is.codion.framework.domain.entity.attribute.Column;
import java.io.BufferedReader;
import java.io.IOException;
import java.io.InputStream;
import java.io.InputStreamReader;
import java.nio.charset.StandardCharsets;
import java.sql.Connection;
import java.sql.ResultSet;
import java.sql.SQLException;
import java.sql.Statement;
import java.time.LocalDateTime;
import java.util.ArrayList;
import java.util.List;
import java.util.Objects;
import java.util.regex.Matcher;
import java.util.regex.Pattern;
import static is.codion.framework.domain.DomainType.domainType;
import static java.lang.Integer.parseInt;
import static java.lang.System.currentTimeMillis;
import static java.lang.System.getProperty;
import static java.util.Comparator.comparingInt;
import static java.util.stream.Collectors.joining;
/**
* Domain model for migration tracking - used only for recording migration history
*/
final class MigrationDomain extends DomainModel {
static final User MIGRATION_USER = User.parse("scott:tiger");
private static final String MIGRATION_PATH = "/db/migration/";
private static final Pattern MIGRATION_PATTERN = Pattern.compile("V(\\d+)__(.+)\\.sql");
private static final DomainType DOMAIN = domainType(MigrationDomain.class);
private static final String MIGRATION_TABLE = "CHINOOK.SCHEMA_MIGRATION";
private static final String CREATE_MIGRATION_TABLE = """
CREATE TABLE IF NOT EXISTS CHINOOK.SCHEMA_MIGRATION (
VERSION INTEGER NOT NULL,
DESCRIPTION VARCHAR(200) NOT NULL,
SCRIPT VARCHAR(200) NOT NULL,
CHECKSUM INTEGER NOT NULL,
EXECUTED_BY VARCHAR(100) NOT NULL,
EXECUTED_AT TIMESTAMP NOT NULL,
EXECUTION_TIME INTEGER NOT NULL,
SUCCESS BOOLEAN NOT NULL,
CONSTRAINT PK_SCHEMA_MIGRATION PRIMARY KEY (VERSION)
)
""";
private static final String APPLIED_VERSIONS_QUERY = "SELECT VERSION FROM " + MIGRATION_TABLE + " WHERE SUCCESS = TRUE ORDER BY VERSION";
private static final ResultPacker<Integer> VERSION_PACKER = resultSet -> resultSet.getInt("VERSION");
record Migration(int version, String description, String filename, String content) {
int checksum() {
return Objects.hash(content);
}
}
interface SchemaMigration {
EntityType TYPE = DOMAIN.entityType("chinook.schema_migration");
Column<Integer> VERSION = TYPE.integerColumn("version");
Column<String> DESCRIPTION = TYPE.stringColumn("description");
Column<String> SCRIPT = TYPE.stringColumn("script");
Column<Integer> CHECKSUM = TYPE.integerColumn("checksum");
Column<String> EXECUTED_BY = TYPE.stringColumn("executed_by");
Column<LocalDateTime> EXECUTED_AT = TYPE.localDateTimeColumn("executed_at");
Column<Integer> EXECUTION_TIME = TYPE.integerColumn("execution_time");
Column<Boolean> SUCCESS = TYPE.booleanColumn("success");
}
MigrationDomain() {
super(DOMAIN);
add(SchemaMigration.TYPE.define(
SchemaMigration.VERSION.define()
.primaryKey(),
SchemaMigration.DESCRIPTION.define()
.column()
.maximumLength(200)
.nullable(false),
SchemaMigration.SCRIPT.define()
.column()
.maximumLength(200)
.nullable(false),
SchemaMigration.CHECKSUM.define()
.column()
.nullable(false),
SchemaMigration.EXECUTED_BY.define()
.column()
.maximumLength(100)
.nullable(false),
SchemaMigration.EXECUTED_AT.define()
.column()
.nullable(false),
SchemaMigration.EXECUTION_TIME.define()
.column()
.nullable(false),
SchemaMigration.SUCCESS.define()
.column()
.nullable(false))
.build());
}
List<Migration> pendingMigrations(Connection connection, String[] migrationFiles) throws SQLException, IOException {
createMigrationTable(connection);
List<Integer> appliedVersions = appliedVersions(connection);
return loadMigrations(migrationFiles).stream()
.filter(migration -> !appliedVersions.contains(migration.version))
.toList();
}
void applyMigration(Connection connection, Database database, Migration migration) throws SQLException {
System.out.println("[MigrationManager] Applying migration V" + migration.version + ": " + migration.description);
long startTime = currentTimeMillis();
try (Statement statement = connection.createStatement()) {
// Execute the migration script
statement.execute(migration.content);
// Record successful migration
recordMigration(database, migration, true, currentTimeMillis() - startTime);
System.out.println("[MigrationManager] Migration V" + migration.version + " completed successfully");
}
catch (SQLException | DatabaseException e) {
// Record failed migration
recordMigration(database, migration, false, currentTimeMillis() - startTime);
throw new SQLException("Migration V" + migration.version + " failed: " + e.getMessage(), e);
}
}
private void recordMigration(Database database, Migration migration, boolean success,
long executionTime) throws SQLException, DatabaseException {
// Create a separate connection for entity operations to avoid auto-commit conflicts
try (Connection connection = database.createConnection(MIGRATION_USER);
EntityConnection entityConnection = LocalEntityConnection.localEntityConnection(database, this, connection)) {
entityConnection.insert(entityConnection.entities().builder(SchemaMigration.TYPE)
.with(SchemaMigration.VERSION, migration.version)
.with(SchemaMigration.DESCRIPTION, migration.description)
.with(SchemaMigration.SCRIPT, migration.filename)
.with(SchemaMigration.CHECKSUM, migration.checksum())
.with(SchemaMigration.EXECUTED_BY, getProperty("user.name", "unknown"))
.with(SchemaMigration.EXECUTED_AT, LocalDateTime.now())
.with(SchemaMigration.EXECUTION_TIME, (int) executionTime)
.with(SchemaMigration.SUCCESS, success)
.build());
}
}
private static void createMigrationTable(Connection connection) throws SQLException {
try (Statement statement = connection.createStatement()) {
statement.execute(CREATE_MIGRATION_TABLE);
}
}
private static List<Integer> appliedVersions(Connection connection) throws SQLException {
try (Statement statement = connection.createStatement();
ResultSet resultSet = statement.executeQuery(APPLIED_VERSIONS_QUERY)) {
return VERSION_PACKER.pack(resultSet);
}
}
private static List<Migration> loadMigrations(String[] migrationFiles) throws IOException {
List<Migration> migrations = new ArrayList<>();
for (String filename : migrationFiles) {
Matcher matcher = MIGRATION_PATTERN.matcher(filename);
if (matcher.matches()) {
int version = parseInt(matcher.group(1));
String description = matcher.group(2).replace('_', ' ');
String content = loadMigrationContent(MIGRATION_PATH + filename);
migrations.add(new Migration(version, description, filename, content));
}
}
migrations.sort(comparingInt(migration -> migration.version));
return migrations;
}
private static String loadMigrationContent(String resourcePath) throws IOException {
try (InputStream stream = MigrationDomain.class.getResourceAsStream(resourcePath)) {
if (stream == null) {
throw new IOException("Migration script not found: " + resourcePath);
}
return new BufferedReader(new InputStreamReader(stream, StandardCharsets.UTF_8))
.lines()
.collect(joining("\n"));
}
}
}
MigrationManagerTest
package is.codion.demos.chinook.migration;
import is.codion.common.db.database.Database;
import is.codion.common.user.User;
import org.junit.jupiter.api.Test;
import java.sql.Connection;
import java.sql.ResultSet;
import java.sql.Statement;
import static org.junit.jupiter.api.Assertions.assertTrue;
public class MigrationManagerTest {
@Test
void testMigration() throws Exception {
// This test verifies that migrations can be loaded and would execute
// In a real test environment, you'd use an in-memory database
// Create a mock database that would normally be injected
Database database = Database.instance();
try (Connection connection = database.createConnection(User.parse("scott:tiger"))) {
// Create the schema
try (Statement stmt = connection.createStatement()) {
stmt.execute("CREATE SCHEMA IF NOT EXISTS CHINOOK");
}
// Run migrations
MigrationManager.migrate(database);
// Verify migration table was created
try (Statement statement = connection.createStatement();
ResultSet resultSet = statement.executeQuery(
"SELECT COUNT(*) FROM INFORMATION_SCHEMA.TABLES " +
"WHERE TABLE_SCHEMA = 'CHINOOK' AND TABLE_NAME = 'SCHEMA_MIGRATION'")) {
assertTrue(resultSet.next());
assertTrue(resultSet.getInt(1) > 0, "Migration table should exist");
}
}
}
}
Preferences
The Preferences entity demonstrates how migrations can add new tables to an existing schema.
Domain
API
interface Preferences {
EntityType TYPE = DOMAIN.entityType("chinook.preferences", Preferences.class.getName());
Column<Long> CUSTOMER_ID = TYPE.longColumn("customer_id");
Column<Long> PREFERRED_GENRE_ID = TYPE.longColumn("preferred_genre_id");
Column<Boolean> NEWSLETTER_SUBSCRIBED = TYPE.booleanColumn("newsletter_subscribed");
ForeignKey CUSTOMER_FK = TYPE.foreignKey("customer_fk", CUSTOMER_ID, Customer.ID);
ForeignKey PREFERRED_GENRE_FK = TYPE.foreignKey("preferred_genre_fk", PREFERRED_GENRE_ID, Genre.ID);
}
Implementation
EntityDefinition preferences() {
return Preferences.TYPE.define(
Preferences.CUSTOMER_ID.define()
.primaryKey(),
Preferences.CUSTOMER_FK.define()
.foreignKey(),
Preferences.PREFERRED_GENRE_ID.define()
.column(),
Preferences.PREFERRED_GENRE_FK.define()
.foreignKey()
.attributes(Genre.NAME),
Preferences.NEWSLETTER_SUBSCRIBED.define()
.column()
.nullable(false)
.defaultValue(false))
.caption("Preferences")
.build();
}
UI
PreferencesEditPanel
package is.codion.demos.chinook.ui;
import is.codion.demos.chinook.domain.api.Chinook.Preferences;
import is.codion.swing.framework.model.SwingEntityEditModel;
import is.codion.swing.framework.ui.EntityEditPanel;
import static is.codion.swing.common.ui.layout.Layouts.flexibleGridLayout;
public final class PreferencesEditPanel extends EntityEditPanel {
public PreferencesEditPanel(SwingEntityEditModel editModel) {
super(editModel);
}
@Override
protected void initializeUI() {
focus().initial().set(Preferences.CUSTOMER_FK);
createSearchField(Preferences.CUSTOMER_FK)
.columns(14);
createComboBox(Preferences.PREFERRED_GENRE_FK)
.preferredWidth(160);
createCheckBox(Preferences.NEWSLETTER_SUBSCRIBED);
setLayout(flexibleGridLayout(3, 1));
addInputPanel(Preferences.CUSTOMER_FK);
addInputPanel(Preferences.PREFERRED_GENRE_FK);
addInputPanel(Preferences.NEWSLETTER_SUBSCRIBED);
}
}
Service
package is.codion.demos.chinook.service;
import is.codion.common.property.PropertyValue;
import is.codion.demos.chinook.service.connection.ConnectionSupplier;
import is.codion.demos.chinook.service.handler.AlbumHandler;
import is.codion.demos.chinook.service.handler.ArtistHandler;
import is.codion.demos.chinook.service.handler.GenreHandler;
import is.codion.demos.chinook.service.handler.MediaTypeHandler;
import is.codion.demos.chinook.service.handler.TrackHandler;
import io.javalin.Javalin;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import java.util.concurrent.ExecutorService;
import static is.codion.common.Configuration.integerValue;
import static java.util.concurrent.Executors.newSingleThreadExecutor;
final class ChinookService {
private static final Logger LOG = LoggerFactory.getLogger(ChinookService.class);
static final PropertyValue<Integer> PORT =
integerValue("chinook.service.port", 8089);
private final Javalin javalin = Javalin.create(config -> config.useVirtualThreads = true);
private final ConnectionSupplier connectionSupplier = new ConnectionSupplier();
private final ArtistHandler artists = new ArtistHandler(connectionSupplier);
private final AlbumHandler albums = new AlbumHandler(connectionSupplier);
private final TrackHandler tracks = new TrackHandler(connectionSupplier);
private final MediaTypeHandler mediaType = new MediaTypeHandler(connectionSupplier);
private final GenreHandler genre = new GenreHandler(connectionSupplier);
ChinookService() {
javalin.get("/artists", artists::artists);
javalin.get("/artists/id/{id}", artists::byId);
javalin.get("/artists/name/{name}", artists::byName);
javalin.post("/artists", artists::insert);
javalin.get("/albums", albums::albums);
javalin.get("/albums/id/{id}", albums::byId);
javalin.get("/albums/title/{title}", albums::byTitle);
javalin.get("/albums/artist/name/{name}", albums::byArtistName);
javalin.post("/albums", albums::insert);
javalin.get("/tracks", tracks::tracks);
javalin.get("/tracks/id/{id}", tracks::byId);
javalin.get("/tracks/name/{name}", tracks::byName);
javalin.get("/tracks/artist/name/{name}", tracks::byArtistName);
javalin.post("/tracks", tracks::insert);
javalin.post("/mediatypes", mediaType::insert);
javalin.post("/genres", genre::insert);
}
void start() {
LOG.info("Chinook service starting on port: {}", PORT.get());
try {
javalin.start(PORT.get());
}
catch (RuntimeException e) {
LOG.error(e.getMessage(), e);
throw e;
}
}
void stop() {
javalin.stop();
}
public static void main(String[] args) throws Exception {
try (ExecutorService service = newSingleThreadExecutor()) {
service.submit(new ChinookService()::start).get();
}
catch (Exception e) {
LOG.error(e.getMessage(), e);
}
}
}
Connection
package is.codion.demos.chinook.service.connection;
import is.codion.common.db.database.Database;
import is.codion.common.db.exception.DatabaseException;
import is.codion.common.db.pool.ConnectionPoolFactory;
import is.codion.common.db.pool.ConnectionPoolStatistics;
import is.codion.common.db.pool.ConnectionPoolWrapper;
import is.codion.common.scheduler.TaskScheduler;
import is.codion.common.user.User;
import is.codion.demos.chinook.domain.api.Chinook;
import is.codion.demos.chinook.domain.api.Chinook.Album;
import is.codion.demos.chinook.domain.api.Chinook.Artist;
import is.codion.demos.chinook.domain.api.Chinook.Genre;
import is.codion.demos.chinook.domain.api.Chinook.MediaType;
import is.codion.demos.chinook.domain.api.Chinook.Track;
import is.codion.demos.chinook.migration.MigrationManager;
import is.codion.framework.db.local.LocalEntityConnection;
import is.codion.framework.domain.Domain;
import is.codion.framework.domain.DomainModel;
import is.codion.framework.domain.entity.Entities;
import org.slf4j.Logger;
import java.util.concurrent.TimeUnit;
import java.util.function.Supplier;
import static is.codion.framework.db.local.LocalEntityConnection.localEntityConnection;
import static java.lang.System.currentTimeMillis;
import static org.slf4j.LoggerFactory.getLogger;
public final class ConnectionSupplier implements Supplier<LocalEntityConnection> {
private static final Logger LOG = getLogger(ConnectionSupplier.class);
private static final User USER = User.parse("scott:tiger");
private static final int STATISTICS_PRINT_RATE = 5;
private final Domain domain = new ServiceDomain();
// Relies on the codion-dbms-h2 module and the h2 driver being
// on the classpath and the 'codion.db.url' system property
private final Database database = Database.instance();
private final ConnectionPoolWrapper connectionPool =
// Relies on codion-plugin-hikari-pool being on the classpath
database.createConnectionPool(ConnectionPoolFactory.instance(), USER);
public ConnectionSupplier() {
connectionPool.setCollectCheckOutTimes(true);
LOG.info("Database: {}", database.url());
LOG.info("Connection pool: {}", connectionPool.user());
LOG.info("Domain: {}", domain.type().name());
TaskScheduler.builder(this::printStatistics)
.interval(STATISTICS_PRINT_RATE, TimeUnit.SECONDS)
.start();
}
@Override
public LocalEntityConnection get() {
try {
return localEntityConnection(database, domain, connectionPool.connection(USER));
}
catch (DatabaseException e) {
LOG.error(e.getMessage(), e);
throw new RuntimeException(e.getMessage());
}
}
public Entities entities() {
return domain.entities();
}
private void printStatistics() {
ConnectionPoolStatistics poolStatistics =
// fetch statistics collected since last time we fetched
connectionPool.statistics(currentTimeMillis() - STATISTICS_PRINT_RATE * 1_000);
System.out.println("#### Connection Pool ############");
System.out.println("# Requests per second: " + poolStatistics.requestsPerSecond());
System.out.println("# Average check out time: " + poolStatistics.averageTime() + " ms");
System.out.println("# Size: " + poolStatistics.size() + ", in use: " + poolStatistics.inUse());
System.out.println("#### Database ###################");
Database.Statistics databaseStatistics = database.statistics();
System.out.println("# Queries per second: " + databaseStatistics.queriesPerSecond());
System.out.println("#################################");
}
private static final class ServiceDomain extends DomainModel {
private ServiceDomain() {
super(Chinook.DOMAIN);
// Relies on the Chinook domain model
// being registered as a service
Entities entities = Domain.domains().getFirst().entities();
// Only add the entities required for this service
add(entities.definition(Genre.TYPE));
add(entities.definition(MediaType.TYPE));
add(entities.definition(Artist.TYPE));
add(entities.definition(Album.TYPE));
add(entities.definition(Track.TYPE));
}
@Override
public void configure(Database database) throws DatabaseException {
MigrationManager.migrate(database);
}
}
}
Handlers
package is.codion.demos.chinook.service.handler;
import is.codion.demos.chinook.service.connection.ConnectionSupplier;
import is.codion.framework.db.local.LocalEntityConnection;
import is.codion.framework.domain.entity.Entities;
import com.fasterxml.jackson.databind.ObjectMapper;
import io.javalin.http.Context;
import org.eclipse.jetty.http.HttpStatus;
import org.slf4j.Logger;
import static org.slf4j.LoggerFactory.getLogger;
public abstract class AbstractHandler {
private static final Logger LOG = getLogger(AbstractHandler.class);
private static final ObjectMapper MAPPER = new ObjectMapper();
private final ConnectionSupplier connection;
protected AbstractHandler(ConnectionSupplier connection) {
this.connection = connection;
}
protected final LocalEntityConnection connection() {
return connection.get();
}
protected final ObjectMapper mapper() {
return MAPPER;
}
protected final Entities entities() {
return connection.entities();
}
protected static void handleException(Context context, Exception exception) {
LOG.error(exception.getMessage(), exception);
context.status(HttpStatus.INTERNAL_SERVER_ERROR_500);
}
}
package is.codion.demos.chinook.service.handler;
import is.codion.demos.chinook.domain.api.Chinook.Artist;
import is.codion.demos.chinook.service.connection.ConnectionSupplier;
import is.codion.framework.domain.entity.Entity;
import io.javalin.http.Context;
import org.eclipse.jetty.http.HttpStatus;
import static io.javalin.http.HttpStatus.OK;
import static is.codion.framework.db.EntityConnection.Select.all;
import static java.lang.Long.parseLong;
import static java.util.stream.StreamSupport.stream;
public final class ArtistHandler extends AbstractHandler {
public ArtistHandler(ConnectionSupplier connection) {
super(connection);
}
public void artists(Context context) {
try (var connection = connection();
var iterator = connection.iterator(all(Artist.TYPE).build())) {
context.status(HttpStatus.OK_200)
.writeJsonStream(stream(iterator.spliterator(), false)
.map(Artist::dto));
}
catch (Exception exception) {
handleException(context, exception);
}
}
public void byName(Context context) {
try (var connection = connection()) {
context.status(HttpStatus.OK_200)
.result(mapper().writeValueAsString(connection.select(
Artist.NAME.equalToIgnoreCase(context.pathParam("name")))
.stream()
.map(Artist::dto)
.toList()));
}
catch (Exception exception) {
handleException(context, exception);
}
}
public void byId(Context context) {
try (var connection = connection()) {
context.status(HttpStatus.OK_200)
.result(mapper().writeValueAsString(
Artist.dto(connection.selectSingle(
Artist.ID.equalTo(parseLong(context.pathParam("id")))))));
}
catch (Exception exception) {
handleException(context, exception);
}
}
public void insert(Context context) {
try (var connection = connection()) {
Artist.Dto artistDto = context.bodyStreamAsClass(Artist.Dto.class);
Entity artist = connection.insertSelect(artistDto.entity(entities()));
context.status(OK)
.result(mapper().writeValueAsString(Artist.dto(artist)));
}
catch (Exception e) {
handleException(context, e);
}
}
}
package is.codion.demos.chinook.service.handler;
import is.codion.demos.chinook.domain.api.Chinook.Album;
import is.codion.demos.chinook.domain.api.Chinook.Artist;
import is.codion.demos.chinook.service.connection.ConnectionSupplier;
import is.codion.framework.domain.entity.Entity;
import io.javalin.http.Context;
import org.eclipse.jetty.http.HttpStatus;
import static io.javalin.http.HttpStatus.OK;
import static is.codion.framework.db.EntityConnection.Select.all;
import static java.lang.Long.parseLong;
import static java.util.stream.StreamSupport.stream;
public final class AlbumHandler extends AbstractHandler {
public AlbumHandler(ConnectionSupplier connection) {
super(connection);
}
public void albums(Context context) {
try (var connection = connection();
var iterator = connection.iterator(all(Album.TYPE).build())) {
context.status(HttpStatus.OK_200)
.writeJsonStream(stream(iterator.spliterator(), false)
.map(Album::dto));
}
catch (Exception exception) {
handleException(context, exception);
}
}
public void byTitle(Context context) {
try (var connection = connection()) {
context.status(HttpStatus.OK_200)
.result(mapper().writeValueAsString(connection.select(
Album.TITLE.equalToIgnoreCase(context.pathParam("title")))
.stream()
.map(Album::dto)
.toList()));
}
catch (Exception exception) {
handleException(context, exception);
}
}
public void byArtistName(Context context) {
try (var connection = connection()) {
var artistIds = connection.select(Artist.ID,
Artist.NAME.equalToIgnoreCase(context.pathParam("name")));
context.status(HttpStatus.OK_200)
.result(mapper().writeValueAsString(
connection.select(Album.ARTIST_ID.in(artistIds))
.stream()
.map(Album::dto)
.toList()));
}
catch (Exception exception) {
handleException(context, exception);
}
}
public void byId(Context context) {
try (var connection = connection()) {
context.status(HttpStatus.OK_200)
.result(mapper().writeValueAsString(
Album.dto(connection.selectSingle(
Album.ID.equalTo(parseLong(context.pathParam("id")))))));
}
catch (Exception exception) {
handleException(context, exception);
}
}
public void insert(Context context) {
try (var connection = connection()) {
Album.Dto albumDto = context.bodyStreamAsClass(Album.Dto.class);
Entity album = connection.insertSelect(albumDto.entity(entities()));
context.status(OK)
.result(mapper().writeValueAsString(Album.dto(album)));
}
catch (Exception e) {
handleException(context, e);
}
}
}
package is.codion.demos.chinook.service.handler;
import is.codion.demos.chinook.domain.api.Chinook.Album;
import is.codion.demos.chinook.domain.api.Chinook.Artist;
import is.codion.demos.chinook.domain.api.Chinook.Track;
import is.codion.demos.chinook.service.connection.ConnectionSupplier;
import is.codion.framework.domain.entity.Entity;
import io.javalin.http.Context;
import org.eclipse.jetty.http.HttpStatus;
import static io.javalin.http.HttpStatus.OK;
import static is.codion.framework.db.EntityConnection.Select.all;
import static java.lang.Long.parseLong;
import static java.util.stream.StreamSupport.stream;
public final class TrackHandler extends AbstractHandler {
public TrackHandler(ConnectionSupplier connection) {
super(connection);
}
public void tracks(Context context) {
try (var connection = connection();
var iterator = connection.iterator(all(Track.TYPE).build())) {
context.status(HttpStatus.OK_200)
.writeJsonStream(stream(iterator.spliterator(), false)
.map(Track::dto));
}
catch (Exception exception) {
handleException(context, exception);
}
}
public void byName(Context context) {
try (var connection = connection()) {
context.status(HttpStatus.OK_200)
.result(mapper().writeValueAsString(connection.select(
Track.NAME.equalToIgnoreCase(context.pathParam("name")))
.stream()
.map(Track::dto)
.toList()));
}
catch (Exception exception) {
handleException(context, exception);
}
}
public void byArtistName(Context context) {
try (var connection = connection()) {
var artistIds = connection.select(Artist.ID,
Artist.NAME.equalToIgnoreCase(context.pathParam("name")));
var albumIds = connection.select(Album.ID,
Album.ARTIST_ID.in(artistIds));
context.status(HttpStatus.OK_200)
.result(mapper().writeValueAsString(
connection.select(Track.ALBUM_ID.in(albumIds))
.stream()
.map(Track::dto)
.toList()));
}
catch (Exception exception) {
handleException(context, exception);
}
}
public void byId(Context context) {
try (var connection = connection()) {
context.status(HttpStatus.OK_200)
.result(mapper().writeValueAsString(
Track.dto(connection.selectSingle(
Track.ID.equalTo(parseLong(context.pathParam("id")))))));
}
catch (Exception exception) {
handleException(context, exception);
}
}
public void insert(Context context) {
try (var connection = connection()) {
Track.Dto trackDto = context.bodyStreamAsClass(Track.Dto.class);
Entity track = connection.insertSelect(trackDto.entity(entities()));
context.status(OK)
.result(mapper().writeValueAsString(Track.dto(track)));
}
catch (Exception e) {
handleException(context, e);
}
}
}
Unit test
package is.codion.demos.chinook.service;
import is.codion.demos.chinook.domain.api.Chinook.Album;
import is.codion.demos.chinook.domain.api.Chinook.Artist;
import is.codion.demos.chinook.domain.api.Chinook.Genre;
import is.codion.demos.chinook.domain.api.Chinook.MediaType;
import is.codion.demos.chinook.domain.api.Chinook.Track;
import com.fasterxml.jackson.databind.ObjectMapper;
import io.javalin.http.HttpStatus;
import org.junit.jupiter.api.AfterAll;
import org.junit.jupiter.api.BeforeAll;
import org.junit.jupiter.api.Test;
import java.math.BigDecimal;
import java.net.URI;
import java.net.http.HttpClient;
import java.net.http.HttpRequest;
import java.net.http.HttpResponse;
import java.util.concurrent.ExecutorService;
import static io.javalin.http.HttpStatus.INTERNAL_SERVER_ERROR;
import static io.javalin.http.HttpStatus.OK;
import static is.codion.demos.chinook.service.ChinookService.PORT;
import static java.net.URLEncoder.encode;
import static java.net.http.HttpClient.newHttpClient;
import static java.net.http.HttpRequest.BodyPublishers.ofString;
import static java.net.http.HttpResponse.BodyHandlers.ofString;
import static java.nio.charset.StandardCharsets.UTF_8;
import static java.util.concurrent.Executors.newSingleThreadExecutor;
import static org.junit.jupiter.api.Assertions.assertEquals;
public class ChinookServiceTest {
private static final ExecutorService EXECUTOR = newSingleThreadExecutor();
private static final String BASE_URL = "http://localhost:" + PORT.get();
private static final ObjectMapper OBJECT_MAPPER = new ObjectMapper();
private static ChinookService SERVICE;
@BeforeAll
static void setUp() {
SERVICE = new ChinookService();
EXECUTOR.submit(SERVICE::start);
}
@AfterAll
static void tearDown() {
SERVICE.stop();
EXECUTOR.shutdownNow();
}
@Test
public void get() throws Exception {
try (HttpClient client = newHttpClient()) {
assertGet("/artists/id/42", OK, client);
assertGet("/artists/id/-42", INTERNAL_SERVER_ERROR, client);
assertGet("/artists", OK, client);
assertGet("/artists/name/metallica", OK, client);
assertGet("/albums/id/42", OK, client);
assertGet("/albums/id/-42", INTERNAL_SERVER_ERROR, client);
assertGet("/albums", OK, client);
assertGet("/albums/title/" + encode("master of puppets", UTF_8), OK, client);
assertGet("/albums/artist/name/metallica", OK, client);
assertGet("/tracks/id/42", OK, client);
assertGet("/tracks/id/-42", INTERNAL_SERVER_ERROR, client);
assertGet("/tracks", OK, client);
assertGet("/tracks/name/orion", OK, client);
assertGet("/tracks/artist/name/metallica", OK, client);
}
}
@Test
public void post() throws Exception {
try (HttpClient client = newHttpClient()) {
String payload = OBJECT_MAPPER.writeValueAsString(new MediaType.Dto(null, "New mediatype"));
MediaType.Dto mediaType = OBJECT_MAPPER.readerFor(MediaType.Dto.class)
.readValue(assertPost("/mediatypes", OK, client, payload));
assertEquals("New mediatype", mediaType.name());
payload = OBJECT_MAPPER.writeValueAsString(new Genre.Dto(null, "New genre"));
Genre.Dto genre = OBJECT_MAPPER.readerFor(Genre.Dto.class)
.readValue(assertPost("/genres", OK, client, payload));
assertEquals("New genre", genre.name());
payload = OBJECT_MAPPER.writeValueAsString(new Artist.Dto(null, "New artist"));
Artist.Dto artist = OBJECT_MAPPER.readerFor(Artist.Dto.class)
.readValue(assertPost("/artists", OK, client, payload));
assertEquals("New artist", artist.name());
payload = OBJECT_MAPPER.writeValueAsString(new Album.Dto(null, "New album", artist));
Album.Dto album = OBJECT_MAPPER.readerFor(Album.Dto.class)
.readValue(assertPost("/albums", OK, client, payload));
assertEquals("New album", album.title());
payload = OBJECT_MAPPER.writeValueAsString(new Track.Dto(null, "New track", artist.name(), album, genre,
mediaType, 10_000_000, 7, BigDecimal.ONE, 0));
Track.Dto track = OBJECT_MAPPER.readerFor(Track.Dto.class)
.readValue(assertPost("/tracks", OK, client, payload));
assertEquals("New track", track.name());
assertEquals(album, track.album());
assertEquals(genre, track.genre());
assertEquals(mediaType, track.mediaType());
assertEquals(10_000_000, track.milliseconds());
assertEquals(7, track.rating());
assertEquals(BigDecimal.ONE, track.unitPrice());
}
}
private void assertGet(String url, HttpStatus status, HttpClient client) throws Exception {
assertEquals(status.getCode(),
client.send(HttpRequest.newBuilder()
.uri(URI.create(BASE_URL + url))
.GET()
.build(), ofString())
.statusCode());
}
private String assertPost(String url, HttpStatus status, HttpClient client,
String payload) throws Exception {
HttpResponse<String> response = client.send(HttpRequest.newBuilder()
.uri(URI.create(BASE_URL + url))
.POST(ofString(payload))
.build(), ofString());
assertEquals(status.getCode(), response.statusCode());
return response.body();
}
}
Service load test
package is.codion.demos.chinook.service.loadtest;
import is.codion.common.property.PropertyValue;
import is.codion.demos.chinook.service.loadtest.scenarios.AlbumById;
import is.codion.demos.chinook.service.loadtest.scenarios.Albums;
import is.codion.demos.chinook.service.loadtest.scenarios.ArtistById;
import is.codion.demos.chinook.service.loadtest.scenarios.Artists;
import is.codion.demos.chinook.service.loadtest.scenarios.NewArtist;
import is.codion.demos.chinook.service.loadtest.scenarios.TrackById;
import is.codion.demos.chinook.service.loadtest.scenarios.Tracks;
import is.codion.tools.loadtest.LoadTest;
import is.codion.tools.loadtest.LoadTest.Scenario;
import is.codion.tools.loadtest.model.LoadTestModel;
import java.net.http.HttpClient;
import java.util.List;
import static is.codion.common.Configuration.integerValue;
import static is.codion.common.user.User.user;
import static is.codion.tools.loadtest.LoadTest.Scenario.scenario;
import static is.codion.tools.loadtest.ui.LoadTestPanel.loadTestPanel;
import static java.net.http.HttpClient.newHttpClient;
public final class ChinookServiceLoadTest {
private static final PropertyValue<Integer> PORT =
integerValue("chinook.service.port", 8089);
private static final String BASE_URL = "http://localhost:" + PORT.get();
private static final List<Scenario<HttpClient>> SCENARIOS = List.of(
scenario(new Artists(BASE_URL)),
scenario(new ArtistById(BASE_URL)),
scenario(new Albums(BASE_URL)),
scenario(new AlbumById(BASE_URL)),
scenario(new Tracks(BASE_URL)),
scenario(new TrackById(BASE_URL)),
scenario(new NewArtist(BASE_URL))
);
public static void main(String[] args) {
LoadTest<HttpClient> loadTest =
LoadTest.builder(user -> newHttpClient(), HttpClient::close)
.scenarios(SCENARIOS)
.user(user("n/a"))
.build();
loadTestPanel(LoadTestModel.loadTestModel(loadTest)).run();
}
}
Scenarios
package is.codion.demos.chinook.service.loadtest.scenarios;
import is.codion.tools.loadtest.LoadTest.Scenario.Performer;
import java.net.URI;
import java.net.http.HttpClient;
import java.net.http.HttpRequest;
import java.util.Random;
import static java.net.http.HttpResponse.BodyHandlers.ofString;
public final class ArtistById implements Performer<HttpClient> {
private static final Random RANDOM = new Random();
private final String baseUrl;
public ArtistById(String baseUrl) {
this.baseUrl = baseUrl;
}
@Override
public void perform(HttpClient client) throws Exception {
if (client.send(HttpRequest.newBuilder()
.uri(URI.create(baseUrl + "/artists/id/" + randomArtistId()))
.build(), ofString()).statusCode() != 200) {
throw new Exception(toString());
}
}
@Override
public String toString() {
return ArtistById.class.getSimpleName();
}
private static int randomArtistId() {
return RANDOM.nextInt(275) + 1;
}
}
package is.codion.demos.chinook.service.loadtest.scenarios;
import is.codion.tools.loadtest.LoadTest.Scenario.Performer;
import java.net.URI;
import java.net.http.HttpClient;
import java.net.http.HttpRequest;
import static java.net.http.HttpResponse.BodyHandlers.ofString;
public final class Artists implements Performer<HttpClient> {
private final String baseUrl;
public Artists(String baseUrl) {
this.baseUrl = baseUrl;
}
@Override
public void perform(HttpClient client) throws Exception {
if (client.send(HttpRequest.newBuilder()
.uri(URI.create(baseUrl + "/artists"))
.build(), ofString()).statusCode() != 200) {
throw new Exception(toString());
}
}
@Override
public String toString() {
return Artists.class.getSimpleName();
}
}
package is.codion.demos.chinook.service.loadtest.scenarios;
import is.codion.demos.chinook.domain.api.Chinook.Artist;
import is.codion.tools.loadtest.LoadTest.Scenario.Performer;
import com.fasterxml.jackson.databind.ObjectMapper;
import java.net.URI;
import java.net.http.HttpClient;
import java.net.http.HttpRequest;
import static java.lang.System.currentTimeMillis;
import static java.net.http.HttpRequest.BodyPublishers.ofString;
import static java.net.http.HttpResponse.BodyHandlers.ofString;
public final class NewArtist implements Performer<HttpClient> {
private static final ObjectMapper OBJECT_MAPPER = new ObjectMapper();
private final String baseUrl;
public NewArtist(String baseUrl) {
this.baseUrl = baseUrl;
}
@Override
public void perform(HttpClient client) throws Exception {
if (client.send(HttpRequest.newBuilder()
.uri(URI.create(baseUrl + "/artists"))
.POST(ofString(OBJECT_MAPPER.writeValueAsString(
new Artist.Dto(null, Long.toString(currentTimeMillis())))))
.build(), ofString()).statusCode() != 200) {
throw new Exception(toString());
}
}
@Override
public String toString() {
return NewArtist.class.getSimpleName();
}
}
package is.codion.demos.chinook.service.loadtest.scenarios;
import is.codion.tools.loadtest.LoadTest.Scenario.Performer;
import java.net.URI;
import java.net.http.HttpClient;
import java.net.http.HttpRequest;
import java.util.Random;
import static java.net.http.HttpResponse.BodyHandlers.ofString;
public final class AlbumById implements Performer<HttpClient> {
private static final Random RANDOM = new Random();
private final String baseUrl;
public AlbumById(String baseUrl) {
this.baseUrl = baseUrl;
}
@Override
public void perform(HttpClient client) throws Exception {
if (client.send(HttpRequest.newBuilder()
.uri(URI.create(baseUrl + "/albums/id/" + randomAlbumId()))
.build(), ofString()).statusCode() != 200) {
throw new Exception(toString());
}
}
@Override
public String toString() {
return AlbumById.class.getSimpleName();
}
private static int randomAlbumId() {
return RANDOM.nextInt(347) + 1;
}
}
package is.codion.demos.chinook.service.loadtest.scenarios;
import is.codion.tools.loadtest.LoadTest.Scenario.Performer;
import java.net.URI;
import java.net.http.HttpClient;
import java.net.http.HttpRequest;
import static java.net.http.HttpResponse.BodyHandlers.ofString;
public final class Albums implements Performer<HttpClient> {
private final String baseUrl;
public Albums(String baseUrl) {
this.baseUrl = baseUrl;
}
@Override
public void perform(HttpClient client) throws Exception {
if (client.send(HttpRequest.newBuilder()
.uri(URI.create(baseUrl + "/albums"))
.build(), ofString()).statusCode() != 200) {
throw new Exception(toString());
}
}
@Override
public String toString() {
return Albums.class.getSimpleName();
}
}
package is.codion.demos.chinook.service.loadtest.scenarios;
import is.codion.tools.loadtest.LoadTest.Scenario.Performer;
import java.net.URI;
import java.net.http.HttpClient;
import java.net.http.HttpRequest;
import java.util.Random;
import static java.net.http.HttpResponse.BodyHandlers.ofString;
public final class TrackById implements Performer<HttpClient> {
private static final Random RANDOM = new Random();
private final String baseUrl;
public TrackById(String baseUrl) {
this.baseUrl = baseUrl;
}
@Override
public void perform(HttpClient client) throws Exception {
if (client.send(HttpRequest.newBuilder()
.uri(URI.create(baseUrl + "/tracks/id/" + randomTrackId()))
.build(), ofString()).statusCode() != 200) {
throw new Exception(toString());
}
}
@Override
public String toString() {
return TrackById.class.getSimpleName();
}
private static int randomTrackId() {
return RANDOM.nextInt(3503) + 1;
}
}
package is.codion.demos.chinook.service.loadtest.scenarios;
import is.codion.tools.loadtest.LoadTest.Scenario.Performer;
import java.net.URI;
import java.net.http.HttpClient;
import java.net.http.HttpRequest;
import static java.net.http.HttpResponse.BodyHandlers.ofString;
public final class Tracks implements Performer<HttpClient> {
private final String baseUrl;
public Tracks(String baseUrl) {
this.baseUrl = baseUrl;
}
@Override
public void perform(HttpClient client) throws Exception {
if (client.send(HttpRequest.newBuilder()
.uri(URI.create(baseUrl + "/tracks"))
.build(), ofString()).statusCode() != 200) {
throw new Exception(toString());
}
}
@Override
public String toString() {
return Tracks.class.getSimpleName();
}
}
Module Info
Domain
API
/**
* Domain API.
*/
module is.codion.demos.chinook.domain.api {
requires is.codion.common.db;
requires is.codion.framework.db.core;
requires transitive is.codion.plugin.jasperreports;
requires org.apache.commons.logging;
exports is.codion.demos.chinook.domain.api;
//for accessing i18n resources
opens is.codion.demos.chinook.domain.api;
}
Implementation
/**
* Domain implementation.
*/
module is.codion.demos.chinook.domain {
requires is.codion.common.db;
requires is.codion.common.rmi;
requires is.codion.framework.db.core;
requires is.codion.framework.db.local;
requires transitive is.codion.demos.chinook.domain.api;
opens is.codion.demos.chinook.domain;//report resource
exports is.codion.demos.chinook.domain;
exports is.codion.demos.chinook.server;
exports is.codion.demos.chinook.migration;
provides is.codion.framework.domain.Domain
with is.codion.demos.chinook.domain.ChinookImpl;
provides is.codion.common.rmi.server.Authenticator
with is.codion.demos.chinook.server.ChinookAuthenticator;
}
Client
/**
* Client.
*/
module is.codion.demos.chinook.client {
requires is.codion.swing.common.ui;
requires is.codion.swing.framework.ui;
requires is.codion.plugin.imagepanel;
requires is.codion.plugin.flatlaf;
requires is.codion.plugin.flatlaf.intellij.themes;
requires is.codion.demos.chinook.domain.api;
requires org.kordamp.ikonli.foundation;
requires net.sf.jasperreports.core;
requires net.sf.jasperreports.pdf;
requires org.apache.commons.logging;
requires com.github.librepdf.openpdf;
exports is.codion.demos.chinook.ui;
exports is.codion.demos.chinook.model;
provides is.codion.common.resource.Resources
with is.codion.demos.chinook.i18n.ChinookResources;
}
Load Test
/**
* Load test.
*/
module is.codion.demos.chinook.client.loadtest {
requires is.codion.common.model;
requires is.codion.tools.loadtest.ui;
requires is.codion.framework.db.core;
requires is.codion.demos.chinook.domain.api;
requires is.codion.demos.chinook.client;
}
Build
buildSrc
settings.gradle
dependencyResolutionManagement {
versionCatalogs {
libs {
version("jlink", "3.1.1")
version("extra-module-info", "1.12")
version("spotless", "7.0.1")
library("jlink", "org.beryx", "badass-jlink-plugin").versionRef("jlink")
library("extra-module-info", "org.gradlex", "extra-java-module-info").versionRef("extra-module-info")
library("spotless", "com.diffplug.spotless", "spotless-plugin-gradle").versionRef("spotless")
}
}
}
build.gradle.kts
plugins {
id("groovy-gradle-plugin")
}
repositories {
gradlePluginPortal()
}
dependencies {
implementation(libs.jlink)
implementation(libs.extra.module.info)
implementation(libs.spotless)
}
chinook.jasperreports.modules.gradle
// extra-java-module-info configuration for core jasper reports library dependencies
plugins {
id "org.gradlex.extra-java-module-info"
}
extraJavaModuleInfo {
automaticModule("commons-collections:commons-collections", "commons.collections")
automaticModule("commons-beanutils:commons-beanutils", "commons.beanutils")
}
chinook.jasperreports.pdf.modules.gradle
// Additional extra-java-module-info configuration for client side jasper reports dependencies,
// including the pdf export module, with a necessary jlink plugin related trick
plugins {
id("chinook.jasperreports.modules")
}
extraJavaModuleInfo {
// We transform the jasperreports-pdf automatic module into a full fledged module,
// otherwise the jlink plugin combines the contents of that jar with the jasperreports
// core jar in the so-called merged module, overwriting the jasperreports_extension.properties
// file from the core jar, causing errors that are *incredibly* hard to debug.
module("net.sf.jasperreports:jasperreports-pdf", "net.sf.jasperreports.pdf") {
exportAllPackages()
requires("net.sf.jasperreports.core")
requires("java.desktop")
requires("org.apache.commons.logging")
requires("com.github.librepdf.openpdf")
}
knownModule("commons-logging:commons-logging", "org.apache.commons.logging")
knownModule("com.github.librepdf:openpdf", "com.github.librepdf.openpdf")
automaticModule("org.eclipse.jdt:ecj", "org.eclipse.jdt.ecj")
automaticModule("com.adobe.xmp:xmpcore", "com.adobe.xmp.xmpcore")
}
chinook.spotless.plugin.gradle
plugins {
id "com.diffplug.spotless"
}
spotless {
java {
licenseHeaderFile("${rootDir}/license_header").yearSeparator(" - ")
}
format "javaMisc", {
target "src/**/package-info.java", "src/**/module-info.java"
licenseHeaderFile("${rootDir}/license_header", "\\/\\*\\*").yearSeparator(" - ")
}
}
Project
Properties
gradle.properties
serverHost=localhost
serverPort=2223
serverHttpPort=8088
serverRegistryPort=1098
serverAdminPort=4445
servicePort=8089
Settings
settings.gradle
plugins {
id("org.gradle.toolchains.foojay-resolver-convention") version "0.9.0"
}
rootProject.name = "chinook"
include "chinook-domain-api"
include "chinook-domain"
include "chinook-domain-generator"
include "chinook-client"
include "chinook-client-local"
include "chinook-client-remote"
include "chinook-client-http"
include "chinook-load-test"
include "chinook-load-test-remote"
include "chinook-load-test-http"
include "chinook-server"
include "chinook-server-monitor"
include "chinook-service"
include "chinook-service-load-test"
include "documentation"
dependencyResolutionManagement {
repositories {
mavenCentral()
mavenLocal()
}
versionCatalogs {
libs {
version("codion", "0.18.38")
version("h2", "2.3.232")
version("javalin", "6.6.0")
version("jackson", "2.17.3")
version("ikonli.foundation", "12.4.0")
version("jasperreports", "7.0.3")
library("codion-common-rmi", "is.codion", "codion-common-rmi").versionRef("codion")
library("codion-dbms-h2", "is.codion", "codion-dbms-h2").versionRef("codion")
library("codion-tools-loadtest-ui", "is.codion", "codion-tools-loadtest-ui").versionRef("codion")
library("codion-framework-i18n", "is.codion", "codion-framework-i18n").versionRef("codion")
library("codion-framework-domain", "is.codion", "codion-framework-domain").versionRef("codion")
library("codion-framework-domain-test", "is.codion", "codion-framework-domain-test").versionRef("codion")
library("codion-framework-db-core", "is.codion", "codion-framework-db-core").versionRef("codion")
library("codion-framework-db-rmi", "is.codion", "codion-framework-db-rmi").versionRef("codion")
library("codion-framework-db-local", "is.codion", "codion-framework-db-local").versionRef("codion")
library("codion-framework-db-http", "is.codion", "codion-framework-db-http").versionRef("codion")
library("codion-framework-json-domain", "is.codion", "codion-framework-json-domain").versionRef("codion")
library("codion-tools-generator-model", "is.codion", "codion-tools-generator-model").versionRef("codion")
library("codion-swing-framework-ui", "is.codion", "codion-swing-framework-ui").versionRef("codion")
library("codion-tools-generator-ui", "is.codion", "codion-tools-generator-ui").versionRef("codion")
library("codion-plugin-jasperreports", "is.codion", "codion-plugin-jasperreports").versionRef("codion")
library("codion-plugin-logback-proxy", "is.codion", "codion-plugin-logback-proxy").versionRef("codion")
library("codion-plugin-imagepanel", "is.codion", "codion-plugin-imagepanel").versionRef("codion")
library("codion-plugin-hikari-pool", "is.codion", "codion-plugin-hikari-pool").versionRef("codion")
library("codion-plugin-flatlaf", "is.codion", "codion-plugin-flatlaf").versionRef("codion")
library("codion-plugin-flatlaf-intellij-themes", "is.codion", "codion-plugin-flatlaf-intellij-themes").versionRef("codion")
library("codion-framework-server", "is.codion", "codion-framework-server").versionRef("codion")
library("codion-framework-servlet", "is.codion", "codion-framework-servlet").versionRef("codion")
library("codion-tools-monitor-ui", "is.codion", "codion-tools-monitor-ui").versionRef("codion")
library("javalin", "io.javalin", "javalin").versionRef("javalin")
library("jackson-databind", "com.fasterxml.jackson.core", "jackson-databind").versionRef("jackson")
library("h2", "com.h2database", "h2").versionRef("h2")
library("ikonli-foundation", "org.kordamp.ikonli", "ikonli-foundation-pack").versionRef("ikonli.foundation")
library("jasperreports-jdt", "net.sf.jasperreports", "jasperreports-jdt").versionRef("jasperreports")
library("jasperreports-pdf", "net.sf.jasperreports", "jasperreports-pdf").versionRef("jasperreports")
}
}
}
Build
build.gradle.kts
import com.github.breadmoirai.githubreleaseplugin.GithubReleaseTask
plugins {
java
id("com.github.breadmoirai.github-release") version "2.5.2" apply false
}
java {
toolchain {
languageVersion.set(JavaLanguageVersion.of(24))
}
}
configure(subprojects) {
apply(plugin = "java")
version = rootProject.libs.versions.codion.get().replace("-SNAPSHOT", "")
tasks.withType<JavaCompile>().configureEach {
options.encoding = "UTF-8"
options.isDeprecation = true
}
testing {
suites {
val test by getting(JvmTestSuite::class) {
useJUnitJupiter()
targets {
all {
testTask.configure {
systemProperty("codion.db.url", "jdbc:h2:mem:h2db")
systemProperty("codion.db.initScripts", "classpath:create_schema.sql")
systemProperty("codion.test.user", "scott:tiger")
}
}
}
}
}
}
tasks.withType<GithubReleaseTask>().configureEach {
dependsOn(tasks.named("jlinkZip"))
dependsOn(tasks.named("jpackage"))
}
}
Modules
chinook-domain-api
build.gradle.kts
plugins {
`java-library`
id("chinook.jasperreports.modules")
id("chinook.spotless.plugin")
}
dependencies {
api(libs.codion.framework.domain)
api(libs.codion.framework.db.core)
api(libs.codion.plugin.jasperreports) {
exclude(group = "org.apache.xmlgraphics")
}
}
chinook-domain
build.gradle.kts
plugins {
`java-library`
id("chinook.jasperreports.modules")
id("chinook.spotless.plugin")
id("io.github.f-cramer.jasperreports") version "0.0.4"
}
dependencies {
api(project(":chinook-domain-api"))
implementation(libs.codion.common.rmi)
implementation(libs.codion.framework.db.local)
jasperreportsClasspath(libs.jasperreports.jdt) {
exclude(group = "net.sf.jasperreports")
}
testImplementation(libs.codion.framework.domain.test)
testRuntimeOnly(libs.codion.dbms.h2)
testRuntimeOnly(libs.h2)
}
jasperreports {
classpath.from(project.sourceSets.main.get().compileClasspath)
}
sourceSets.main {
resources.srcDir(tasks.compileAllReports)
}
chinook-domain-generator
Note
|
Configuration only, domain-generator. |
build.gradle.kts
plugins {
id("application")
}
dependencies {
runtimeOnly(libs.codion.tools.generator.ui)
runtimeOnly(libs.codion.dbms.h2)
runtimeOnly(libs.h2)
}
application {
mainModule = "is.codion.tools.generator.ui"
mainClass = "is.codion.tools.generator.ui.DomainGeneratorPanel"
applicationDefaultJvmArgs = listOf(
"-Xmx64m",
"-Dcodion.db.url=jdbc:h2:mem:h2db",
"-Dcodion.db.initScripts=../chinook-domain/src/main/resources/create_schema.sql,",
"-Dcodion.domain.generator.defaultDomainPackage=is.codion.demos.chinook.domain.api",
"-Dcodion.domain.generator.defaultUser=sa",
"-Dsun.awt.disablegrab=true"
)
}
chinook-client
build.gradle.kts
plugins {
`java-library`
id("chinook.jasperreports.pdf.modules")
id("chinook.spotless.plugin")
}
dependencies {
implementation(project(":chinook-domain-api"))
implementation(libs.codion.swing.framework.ui)
implementation(libs.codion.plugin.imagepanel)
implementation(libs.codion.plugin.flatlaf)
implementation(libs.codion.plugin.flatlaf.intellij.themes)
implementation(libs.ikonli.foundation)
implementation(libs.jasperreports.pdf) {
exclude(group = "net.sf.jasperreports")
}
runtimeOnly(libs.codion.plugin.logback.proxy)
testImplementation(project(":chinook-domain"))
testImplementation(libs.codion.framework.db.local)
testRuntimeOnly(libs.codion.dbms.h2)
testRuntimeOnly(libs.h2)
}
tasks.register<WriteProperties>("writeVersion") {
destinationFile = file("${temporaryDir.absolutePath}/version.properties")
property("version", libs.versions.codion.get().replace("-SNAPSHOT", ""))
}
tasks.processResources {
from(tasks.named("writeVersion"))
}
chinook-client-local
Note
|
Configuration only, client with a local JDBC connection. |
build.gradle.kts
import org.gradle.internal.os.OperatingSystem
plugins {
id("org.beryx.jlink")
id("com.github.breadmoirai.github-release")
}
dependencies {
implementation(project(":chinook-client"))
runtimeOnly(project(":chinook-domain"))
runtimeOnly(libs.codion.framework.db.local)
runtimeOnly(libs.codion.dbms.h2)
runtimeOnly(libs.h2)
}
application {
mainModule = "is.codion.demos.chinook.client"
mainClass = "is.codion.demos.chinook.ui.ChinookAppPanel"
applicationDefaultJvmArgs = listOf(
"-Xmx64m",
"-Dcodion.client.connectionType=local",
"-Dcodion.db.url=jdbc:h2:mem:h2db",
"-Dcodion.db.initScripts=classpath:create_schema.sql",
"-Dis.codion.swing.framework.ui.EntityTablePanel.includeQueryInspector=true",
"-Dis.codion.swing.framework.ui.EntityEditPanel.includeQueryInspector=true",
"-Dsun.awt.disablegrab=true"
)
}
jlink {
imageName = project.name + "-" + project.version + "-" +
OperatingSystem.current().familyName.replace(" ", "").lowercase()
moduleName = application.mainModule
options = listOf(
"--strip-debug",
"--no-header-files",
"--no-man-pages",
"--add-modules",
"is.codion.framework.db.local,is.codion.dbms.h2," +
"is.codion.plugin.logback.proxy,is.codion.demos.chinook.domain"
)
addExtraDependencies("slf4j-api")
jpackage {
if (OperatingSystem.current().isLinux) {
icon = "../chinook.png"
installerType = "deb"
installerOptions = listOf(
"--linux-shortcut"
)
}
if (OperatingSystem.current().isWindows) {
icon = "../chinook.ico"
installerType = "msi"
installerOptions = listOf(
"--win-menu",
"--win-shortcut"
)
}
if (OperatingSystem.current().isMacOsX) {
icon = "../chinook.icns"
installerType = "dmg"
}
}
}
if (properties.containsKey("githubAccessToken")) {
githubRelease {
token(properties["githubAccessToken"] as String)
owner = "codion-is"
repo = "chinook"
allowUploadToExisting = true
releaseAssets.from(tasks.named("jlinkZip").get().outputs.files)
releaseAssets.from(fileTree(tasks.named("jpackage").get().outputs.files.singleFile) {
exclude(project.name + "/**", project.name + ".app/**")
})
}
}
chinook-client-remote
Note
|
Configuration only, client with a remote connection. |
build.gradle.kts
import org.gradle.internal.os.OperatingSystem
plugins {
id("org.beryx.jlink")
id("chinook.jasperreports.pdf.modules")
id("com.github.breadmoirai.github-release")
}
dependencies {
implementation(project(":chinook-client"))
runtimeOnly(libs.codion.framework.db.rmi)
}
val serverHost: String by project
val serverRegistryPort: String by project
application {
mainModule = "is.codion.demos.chinook.client"
mainClass = "is.codion.demos.chinook.ui.ChinookAppPanel"
applicationDefaultJvmArgs = listOf(
"-Xmx64m",
"-Dcodion.client.connectionType=remote",
"-Dcodion.server.hostname=${serverHost}",
"-Dcodion.server.registryPort=${serverRegistryPort}",
"-Dcodion.client.trustStore=truststore.jks",
"-Dcodion.client.trustStorePassword=crappypass",
"-Dsun.awt.disablegrab=true"
)
}
jlink {
imageName = project.name + "-" + project.version + "-" +
OperatingSystem.current().familyName.replace(" ", "").lowercase()
moduleName = application.mainModule
options = listOf(
"--strip-debug",
"--no-header-files",
"--no-man-pages",
"--add-modules",
"is.codion.framework.db.rmi,is.codion.plugin.logback.proxy"
)
jpackage {
if (OperatingSystem.current().isLinux) {
icon = "../chinook.png"
installerType = "deb"
installerOptions = listOf(
"--linux-shortcut"
)
}
if (OperatingSystem.current().isWindows) {
icon = "../chinook.ico"
installerType = "msi"
installerOptions = listOf(
"--win-menu",
"--win-shortcut"
)
}
if (OperatingSystem.current().isMacOsX) {
icon = "../chinook.icns"
installerType = "dmg"
}
}
}
tasks.prepareMergedJarsDir {
doLast {
copy {
from("src/main/resources")
into("build/jlinkbase/mergedjars")
}
}
}
if (properties.containsKey("githubAccessToken")) {
githubRelease {
token(properties["githubAccessToken"] as String)
owner = "codion-is"
repo = "chinook"
allowUploadToExisting = true
releaseAssets.from(tasks.named("jlinkZip").get().outputs.files)
releaseAssets.from(fileTree(tasks.named("jpackage").get().outputs.files.singleFile) {
exclude(project.name + "/**", project.name + ".app/**")
})
}
}
chinook-client-http
Note
|
Configuration only, client with an HTTP connection. |
build.gradle.kts
import org.gradle.internal.os.OperatingSystem
plugins {
id("org.beryx.jlink")
id("chinook.jasperreports.pdf.modules")
id("com.github.breadmoirai.github-release")
}
dependencies {
implementation(project(":chinook-client"))
runtimeOnly(libs.codion.framework.db.http)
}
val serverHost: String by project
val serverHttpPort: String by project
application {
mainModule = "is.codion.demos.chinook.client"
mainClass = "is.codion.demos.chinook.ui.ChinookAppPanel"
applicationDefaultJvmArgs = listOf(
"-Xmx64m",
"-Dcodion.client.connectionType=http",
"-Dcodion.client.http.secure=false",
"-Dcodion.client.http.hostname=${serverHost}",
"-Dcodion.client.http.port=${serverHttpPort}",
"-Dlogback.configurationFile=src/main/config/logback.xml",
"-Dsun.awt.disablegrab=true"
)
}
jlink {
imageName = project.name + "-" + project.version + "-" +
OperatingSystem.current().familyName.replace(" ", "").lowercase()
moduleName = application.mainModule
options = listOf(
"--strip-debug",
"--no-header-files",
"--no-man-pages",
"--add-modules",
"is.codion.framework.db.http,is.codion.plugin.logback.proxy"
)
jpackage {
if (OperatingSystem.current().isLinux) {
icon = "../chinook.png"
installerType = "deb"
installerOptions = listOf(
"--linux-shortcut"
)
}
if (OperatingSystem.current().isWindows) {
icon = "../chinook.ico"
installerType = "msi"
installerOptions = listOf(
"--win-menu",
"--win-shortcut"
)
}
if (OperatingSystem.current().isMacOsX) {
icon = "../chinook.icns"
installerType = "dmg"
}
}
}
tasks.prepareMergedJarsDir {
doLast {
copy {
from("src/main/resources")
into("build/jlinkbase/mergedjars")
}
}
}
if (properties.containsKey("githubAccessToken")) {
githubRelease {
token(properties["githubAccessToken"] as String)
owner = "codion-is"
repo = "chinook"
allowUploadToExisting = true
releaseAssets.from(tasks.named("jlinkZip").get().outputs.files)
releaseAssets.from(fileTree(tasks.named("jpackage").get().outputs.files.singleFile) {
exclude(project.name + "/**", project.name + ".app/**")
})
}
}
chinook-load-test
build.gradle.kts
plugins {
id("java-library")
id("chinook.jasperreports.pdf.modules")
id("chinook.spotless.plugin")
}
dependencies {
implementation(project(":chinook-domain-api"))
implementation(project(":chinook-client"))
implementation(libs.codion.tools.loadtest.ui)
implementation(libs.codion.swing.framework.ui)
implementation(libs.codion.tools.generator.model)
}
chinook-load-test-remote
Note
|
Configuration only, load test with a remote connection. |
build.gradle.kts
import org.gradle.internal.os.OperatingSystem
plugins {
id("org.beryx.jlink")
id("chinook.jasperreports.pdf.modules")
id("com.github.breadmoirai.github-release")
}
dependencies {
implementation(project(":chinook-load-test"))
runtimeOnly(libs.codion.framework.db.rmi)
}
val serverHost: String by project
val serverRegistryPort: String by project
application {
mainModule = "is.codion.demos.chinook.client.loadtest"
mainClass = "is.codion.demos.chinook.client.loadtest.ChinookLoadTest"
applicationDefaultJvmArgs = listOf(
"-Xmx1024m",
"-Dcodion.client.connectionType=remote",
"-Dcodion.server.hostname=${serverHost}",
"-Dcodion.server.registryPort=${serverRegistryPort}",
"-Dcodion.client.connectionType=remote",
"-Dcodion.client.trustStore=truststore.jks",
"-Dcodion.client.trustStorePassword=crappypass"
)
}
jlink {
imageName = project.name + "-" + project.version + "-" +
OperatingSystem.current().familyName.replace(" ", "").lowercase()
moduleName = application.mainModule
options = listOf(
"--strip-debug",
"--no-header-files",
"--no-man-pages",
"--add-modules",
"is.codion.framework.db.rmi,is.codion.plugin.logback.proxy"
)
jpackage {
if (OperatingSystem.current().isLinux) {
icon = "../chinook.png"
installerType = "deb"
installerOptions = listOf(
"--linux-shortcut"
)
}
if (OperatingSystem.current().isWindows) {
icon = "../chinook.ico"
installerType = "msi"
installerOptions = listOf(
"--win-menu",
"--win-shortcut"
)
}
if (OperatingSystem.current().isMacOsX) {
icon = "../chinook.icns"
installerType = "dmg"
}
}
}
if (properties.containsKey("githubAccessToken")) {
githubRelease {
token(properties["githubAccessToken"] as String)
owner = "codion-is"
repo = "chinook"
allowUploadToExisting = true
releaseAssets.from(tasks.named("jlinkZip").get().outputs.files)
releaseAssets.from(fileTree(tasks.named("jpackage").get().outputs.files.singleFile) {
exclude(project.name + "/**", project.name + ".app/**")
})
}
}
chinook-load-test-http
Note
|
Configuration only, load test with an HTTP connection. |
build.gradle.kts
import org.gradle.internal.os.OperatingSystem
plugins {
id("org.beryx.jlink")
id("chinook.jasperreports.pdf.modules")
id("com.github.breadmoirai.github-release")
}
dependencies {
implementation(project(":chinook-load-test"))
runtimeOnly(libs.codion.framework.db.http)
}
val serverHost: String by project
val serverHttpPort: String by project
application {
mainModule = "is.codion.demos.chinook.client.loadtest"
mainClass = "is.codion.demos.chinook.client.loadtest.ChinookLoadTest"
applicationDefaultJvmArgs = listOf(
"-Xmx1024m",
"-Dcodion.client.connectionType=http",
"-Dcodion.client.http.secure=false",
"-Dcodion.client.http.hostname=$serverHost",
"-Dcodion.client.http.port=$serverHttpPort"
)
}
jlink {
imageName = project.name + "-" + project.version + "-" +
OperatingSystem.current().familyName.replace(" ", "").lowercase()
moduleName = application.mainModule
options = listOf(
"--strip-debug",
"--no-header-files",
"--no-man-pages",
"--add-modules",
"is.codion.framework.db.http,is.codion.plugin.logback.proxy"
)
jpackage {
if (OperatingSystem.current().isLinux) {
icon = "../chinook.png"
installerType = "deb"
installerOptions = listOf(
"--linux-shortcut"
)
}
if (OperatingSystem.current().isWindows) {
icon = "../chinook.ico"
installerType = "msi"
installerOptions = listOf(
"--win-menu",
"--win-shortcut"
)
}
if (OperatingSystem.current().isMacOsX) {
icon = "../chinook.icns"
installerType = "dmg"
}
}
}
if (properties.containsKey("githubAccessToken")) {
githubRelease {
token(properties["githubAccessToken"] as String)
owner = "codion-is"
repo = "chinook"
allowUploadToExisting = true
releaseAssets.from(tasks.named("jlinkZip").get().outputs.files)
releaseAssets.from(fileTree(tasks.named("jpackage").get().outputs.files.singleFile) {
exclude(project.name + "/**", project.name + ".app/**")
})
}
}
chinook-server
Note
|
Configuration only, runs the server the chinook domain. |
build.gradle.kts
import org.gradle.internal.os.OperatingSystem
plugins {
id("org.beryx.jlink")
id("chinook.jasperreports.modules")
id("com.github.breadmoirai.github-release")
}
dependencies {
runtimeOnly(libs.codion.framework.server)
runtimeOnly(libs.codion.framework.servlet)
runtimeOnly(libs.codion.plugin.hikari.pool)
runtimeOnly(libs.codion.dbms.h2)
runtimeOnly(libs.h2)
runtimeOnly(project(":chinook-domain"))
//logging library, skipping the email stuff
runtimeOnly(libs.codion.plugin.logback.proxy) {
exclude(group = "com.sun.mail", module = "javax.mail")
}
}
val serverHost: String by project
val serverRegistryPort: String by project
val serverPort: String by project
val serverHttpPort: String by project
val serverAdminPort: String by project
application {
mainModule = "is.codion.framework.server"
mainClass = "is.codion.framework.server.EntityServer"
applicationDefaultJvmArgs = listOf(
"-Xmx512m",
"-Dlogback.configurationFile=logback.xml",
//RMI configuration
"-Djava.rmi.server.hostname=${serverHost}",
"-Dcodion.server.registryPort=${serverRegistryPort}",
"-Djava.rmi.server.randomIDs=true",
"-Djava.rmi.server.useCodebaseOnly=true",
//The serialization whitelist
"-Dcodion.server.objectInputFilterFactoryClassName=is.codion.common.rmi.server.SerializationFilterFactory",
"-Dcodion.server.serialization.filter.patternFile=classpath:serialization-filter-patterns.txt",
//SSL configuration
"-Dcodion.server.classpathKeyStore=keystore.jks",
"-Djavax.net.ssl.keyStorePassword=crappypass",
//The port used by clients
"-Dcodion.server.port=${serverPort}",
//The servlet server
"-Dcodion.server.auxiliaryServerFactoryClassNames=is.codion.framework.servlet.EntityServiceFactory",
"-Dcodion.server.http.secure=false",
"-Dcodion.server.http.port=${serverHttpPort}",
"-Dcodion.server.http.useVirtualThreads=true",
//The port for the admin interface, used by the server monitor
"-Dcodion.server.admin.port=${serverAdminPort}",
//The admin user credentials, used by the server monitor application
"-Dcodion.server.admin.user=scott:tiger",
//Database configuration
"-Dcodion.db.url=jdbc:h2:mem:h2db",
"-Dcodion.db.initScripts=classpath:create_schema.sql",
"-Dcodion.db.countQueries=true",
//A connection pool based on this user is created on startup
"-Dcodion.server.connectionPoolUsers=scott:tiger",
//Client logging disabled by default
"-Dcodion.server.clientLogging=false"
)
}
jlink {
imageName = project.name + "-" + project.version + "-" +
OperatingSystem.current().familyName.replace(" ", "").lowercase()
moduleName = application.mainModule
options = listOf(
"--strip-debug",
"--no-header-files",
"--no-man-pages",
"--ignore-signing-information",
"--add-modules",
"is.codion.framework.db.local,is.codion.dbms.h2,is.codion.plugin.hikari.pool," +
"is.codion.plugin.logback.proxy,is.codion.demos.chinook.domain,is.codion.framework.servlet"
)
addExtraDependencies("slf4j-api")
mergedModule {
excludeRequires("jetty.servlet.api")
}
forceMerge("kotlin")
jpackage {
if (OperatingSystem.current().isLinux) {
icon = "../chinook.png"
installerType = "deb"
installerOptions = listOf(
"--linux-shortcut"
)
}
if (OperatingSystem.current().isWindows) {
icon = "../chinook.ico"
installerType = "msi"
installerOptions = listOf(
"--win-menu",
"--win-shortcut"
)
imageOptions = imageOptions + listOf("--win-console")
}
if (OperatingSystem.current().isMacOsX) {
icon = "../chinook.icns"
installerType = "dmg"
}
}
}
tasks.prepareMergedJarsDir {
doLast {
copy {
from("src/main/resources")
into("build/jlinkbase/mergedjars")
}
}
}
if (properties.containsKey("githubAccessToken")) {
githubRelease {
token(properties["githubAccessToken"] as String)
owner = "codion-is"
repo = "chinook"
allowUploadToExisting = true
releaseAssets.from(tasks.named("jlinkZip").get().outputs.files)
releaseAssets.from(fileTree(tasks.named("jpackage").get().outputs.files.singleFile) {
exclude(project.name + "/**", project.name + ".app/**")
})
}
}
chinook-server-monitor
Note
|
Configuration only, server-monitor. |
build.gradle.kts
import org.gradle.internal.os.OperatingSystem
plugins {
id("org.beryx.jlink")
id("com.github.breadmoirai.github-release")
}
dependencies {
runtimeOnly(libs.codion.tools.monitor.ui)
runtimeOnly(libs.codion.plugin.logback.proxy)
}
val serverHost: String by project
val serverRegistryPort: String by project
application {
mainModule = "is.codion.tools.monitor.ui"
mainClass = "is.codion.tools.monitor.ui.EntityServerMonitorPanel"
applicationDefaultJvmArgs = listOf(
"-Xmx512m",
"-Dcodion.server.hostname=${serverHost}",
"-Dcodion.server.registryPort=${serverRegistryPort}",
"-Dcodion.client.trustStore=truststore.jks",
"-Dcodion.client.trustStorePassword=crappypass",
"-Dlogback.configurationFile=logback.xml",
"-Dcodion.server.admin.user=scott:tiger"
)
}
jlink {
imageName = project.name + "-" + project.version + "-" +
OperatingSystem.current().familyName.replace(" ", "").lowercase()
moduleName = application.mainModule
options = listOf(
"--strip-debug",
"--no-header-files",
"--no-man-pages",
"--add-modules",
"java.naming,is.codion.plugin.logback.proxy"
)
jpackage {
if (OperatingSystem.current().isLinux) {
icon = "../chinook.png"
installerOptions = listOf(
"--linux-shortcut"
)
}
if (OperatingSystem.current().isWindows) {
icon = "../chinook.ico"
installerOptions = listOf(
"--win-menu",
"--win-shortcut"
)
}
}
}
tasks.prepareMergedJarsDir {
doLast {
copy {
from("src/main/resources")
into("build/jlinkbase/mergedjars")
}
}
}
if (properties.containsKey("githubAccessToken")) {
githubRelease {
token(properties["githubAccessToken"] as String)
owner = "codion-is"
repo = "chinook"
allowUploadToExisting = true
releaseAssets.from(tasks.named("jlinkZip").get().outputs.files)
releaseAssets.from(fileTree(tasks.named("jpackage").get().outputs.files.singleFile) {
exclude(project.name + "/**", project.name + ".app/**")
})
}
}
chinook-service
build.gradle.kts
import org.gradle.internal.os.OperatingSystem
plugins {
id("org.beryx.jlink")
id("chinook.jasperreports.modules")
id("chinook.spotless.plugin")
id("com.github.breadmoirai.github-release")
}
dependencies {
implementation(project(":chinook-domain"))
implementation(libs.codion.framework.db.local)
implementation(libs.codion.plugin.hikari.pool)
implementation(libs.jackson.databind)
implementation(libs.javalin) {
exclude(group = "org.jetbrains")
}
runtimeOnly(libs.codion.plugin.logback.proxy)
runtimeOnly(libs.codion.dbms.h2)
runtimeOnly(libs.h2)
}
tasks.test {
systemProperty("chinook.service.port", "8899")
}
val servicePort: String by project
application {
mainModule = "is.codion.demos.chinook.service"
mainClass = "is.codion.demos.chinook.service.ChinookService"
applicationDefaultJvmArgs = listOf(
"-Xmx512m",
"-Dcodion.db.url=jdbc:h2:mem:h2db",
"-Dcodion.db.initScripts=classpath:create_schema.sql",
"-Dcodion.db.countQueries=true",
"-Dchinook.service.port=${servicePort}"
)
}
jlink {
imageName = project.name + "-" + project.version + "-" +
OperatingSystem.current().familyName.replace(" ", "").lowercase()
moduleName = application.mainModule
options = listOf(
"--strip-debug",
"--no-header-files",
"--no-man-pages",
"--add-modules",
"is.codion.framework.db.local,is.codion.dbms.h2," +
"is.codion.plugin.logback.proxy,is.codion.plugin.hikari.pool"
)
addExtraDependencies("slf4j-api", "jetty-jakarta-servlet-api")
}
githubRelease {
token(properties["githubAccessToken"] as String)
owner = "codion-is"
repo = "chinook"
allowUploadToExisting = true
releaseAssets.from(tasks.named("jlinkZip").get().outputs.files)
}
chinook-service-load-test
build.gradle.kts
import org.gradle.internal.os.OperatingSystem
plugins {
id("org.beryx.jlink")
id("chinook.jasperreports.modules")
id("chinook.spotless.plugin")
id("com.github.breadmoirai.github-release")
}
dependencies {
implementation(project(":chinook-domain-api"))
implementation(libs.codion.tools.loadtest.ui)
implementation(libs.jackson.databind)
}
application {
mainModule = "is.codion.demos.chinook.service.loadtest"
mainClass = "is.codion.demos.chinook.service.loadtest.ChinookServiceLoadTest"
applicationDefaultJvmArgs = listOf(
"-Xmx512m"
)
}
jlink {
imageName = project.name + "-" + project.version + "-" +
OperatingSystem.current().familyName.replace(" ", "").lowercase()
moduleName = application.mainModule
options = listOf(
"--strip-debug",
"--no-header-files",
"--no-man-pages"
)
jpackage {
if (OperatingSystem.current().isLinux) {
icon = "../chinook.png"
installerType = "deb"
installerOptions = listOf(
"--linux-shortcut"
)
}
if (OperatingSystem.current().isWindows) {
icon = "../chinook.ico"
installerType = "msi"
installerOptions = listOf(
"--win-menu",
"--win-shortcut"
)
}
if (OperatingSystem.current().isMacOsX) {
icon = "../chinook.icns"
installerType = "dmg"
}
}
}
if (properties.containsKey("githubAccessToken")) {
githubRelease {
token(properties["githubAccessToken"] as String)
owner = "codion-is"
repo = "chinook"
allowUploadToExisting = true
releaseAssets.from(tasks.named("jlinkZip").get().outputs.files)
releaseAssets.from(fileTree(tasks.named("jpackage").get().outputs.files.singleFile) {
exclude(project.name + "/**", project.name + ".app/**")
})
}
}
Distribution
This section covers the distribution setup for the Chinook application, demonstrating how to create native installers and custom runtime images using jlink and jpackage. The configuration leverages the Badass JLink Plugin and custom module handling to create multiple distribution variants from a single codebase.
Overview
The Chinook project creates several distribution variants:
-
Client distributions: Local, Remote (RMI), and HTTP connections
-
Server distributions: Application server and monitoring tools
-
Load test distributions: Performance testing tools for different connection types
-
Service distribution: REST API server
Each distribution is carefully configured to include only the necessary dependencies and modules, resulting in optimized runtime images.
Key Plugins and Tools
Badass JLink Plugin
The Badass JLink Plugin (org.beryx.jlink
) is the cornerstone of the distribution setup. It solves the fundamental problem that jlink cannot work with automatic modules by taking a pragmatic approach: combining all non-modular dependencies into a single merged module.
Note
|
Applying the Badass JLink Plugin automatically applies the Application plugin, providing access to mainClass , applicationDefaultJvmArgs , and other application configuration properties.
|
Key capabilities:
-
Custom Runtime Images: Creates optimized Java runtime images containing only required modules
-
Native Installers: Generates platform-specific installers (.msi, .deb, .dmg) via jpackage
-
Automatic Module Handling: Converts non-modular JARs into explicit modules automatically
-
Cross-Platform Builds: Support for targeting multiple platforms from a single build
-
Dependency Management: Handles complex modular and non-modular dependency graphs
Extra-Java-Module-Info Plugin
The Extra Java Module Info Plugin (org.gradlex.extra-java-module-info
) addresses a fundamental limitation: adding proper module information to libraries that lack it. This enables Gradle to place these dependencies on the module path during compilation, testing, and execution.
Core Problem Solved:
Many legacy libraries either:
- Have no module information at all (plain JARs)
- Are automatic modules (unreliable Automatic-Module-Name
)
- Have broken or incomplete module-info.class
files
Plugin Capabilities:
-
Module Transformation: Converts non-modular JARs to explicit modules with full
module-info.class
-
Dependency Resolution: Properly handles
requires
,exports
,opens
directives -
Service Provider Integration: Automatically converts
META-INF/services
toprovides…with
declarations -
Split Package Resolution: Merges JARs or removes packages to resolve conflicts
-
Automatic Module Enhancement: Upgrades automatic modules to full modules
Chinook’s Implementation
Chinook implements this through custom Gradle plugins in buildSrc
that demonstrate advanced techniques:
chinook.jasperreports.modules.gradle - Core Dependencies:
// extra-java-module-info configuration for core jasper reports library dependencies
plugins {
id "org.gradlex.extra-java-module-info"
}
extraJavaModuleInfo {
automaticModule("commons-collections:commons-collections", "commons.collections")
automaticModule("commons-beanutils:commons-beanutils", "commons.beanutils")
}
Key Techniques Shown:
- Simple Module Definition: module("coords", "module.name")
for basic conversion
- Dependency Mapping: Converting Maven coordinates to module names
chinook.jasperreports.pdf.modules.gradle - Advanced Patterns:
// Additional extra-java-module-info configuration for client side jasper reports dependencies,
// including the pdf export module, with a necessary jlink plugin related trick
plugins {
id("chinook.jasperreports.modules")
}
extraJavaModuleInfo {
// We transform the jasperreports-pdf automatic module into a full fledged module,
// otherwise the jlink plugin combines the contents of that jar with the jasperreports
// core jar in the so-called merged module, overwriting the jasperreports_extension.properties
// file from the core jar, causing errors that are *incredibly* hard to debug.
module("net.sf.jasperreports:jasperreports-pdf", "net.sf.jasperreports.pdf") {
exportAllPackages()
requires("net.sf.jasperreports.core")
requires("java.desktop")
requires("org.apache.commons.logging")
requires("com.github.librepdf.openpdf")
}
knownModule("commons-logging:commons-logging", "org.apache.commons.logging")
knownModule("com.github.librepdf:openpdf", "com.github.librepdf.openpdf")
automaticModule("org.eclipse.jdt:ecj", "org.eclipse.jdt.ecj")
automaticModule("com.adobe.xmp:xmpcore", "com.adobe.xmp.xmpcore")
}
Advanced Techniques Demonstrated:
Critical JasperReports Pattern:
module("net.sf.jasperreports:jasperreports-pdf", "net.sf.jasperreports.pdf") {
exports("net.sf.jasperreports.export.pdf")
// ... other exports
}
Warning
|
The most important technique here is converting jasperreports-pdf from an automatic module to a closed explicit module. This prevents the Badass JLink Plugin’s merged module from overwriting jasperreports_extension.properties files, which would cause incredibly hard to debug runtime errors.
|
Advanced Configuration Methods:
-
exports("package")
- Explicitly controls package visibility -
requires("module")
- Declares module dependencies -
exportAllPackages()
- Exports all packages found in the JAR -
requireAllDefinedDependencies()
- Auto-generates requires from metadata
Plugin Configuration Options
Basic Module Definition:
extraJavaModuleInfo {
module("commons-beanutils:commons-beanutils", "org.apache.commons.beanutils") {
exports("org.apache.commons.beanutils")
requires("java.sql")
requires("java.desktop")
requiresTransitive("org.apache.commons.logging")
}
}
Automatic Module Creation:
automaticModule("commons-logging:commons-logging", "org.apache.commons.logging")
Advanced Features:
-
Split Package Resolution:
mergeJar("other-coordinates")
orremovePackage("problematic.package")
-
Service Provider Control:
ignoreServiceProvider("service.interface", "impl.class")
-
Broken Module Fixes:
patchRealModule()
andpreserveExisting()
-
Classifier Support: Handle JARs with classifiers like
"artifact|linux-x86_64"
Best Practices from Chinook
-
Convention Plugin Approach: Apply via buildSrc for consistent multi-project configuration
-
Conservative Exports: Use
closeModule()
and explicitexports()
instead ofexportAllPackages()
-
Service Provider Awareness: Let the plugin auto-convert
META-INF/services
toprovides…with
-
Automatic to Explicit: Convert problematic automatic modules to explicit modules for better control
-
Split Package Prevention: Address split packages early to avoid runtime issues
Common Use Cases
Legacy Library Integration:
module("old-library:old-library", "old.library") {
exportAllPackages()
requireAllDefinedDependencies()
}
JavaFX Libraries (Common Pattern):
module("javafx-library:javafx-library", "javafx.library") {
requires("javafx.controls")
requires("javafx.fxml")
exports("com.library.javafx")
}
Fixing Broken Modules:
module("broken:library", "broken.library") {
patchRealModule() // Overwrite existing module-info.class
requires("missing.dependency")
}
Configuration-Only Modules
Several modules exist solely to create different distribution configurations. They contain no code, only build configuration:
-
chinook-client-local
- Client with embedded database connection -
chinook-client-remote
- Client using RMI for remote connections -
chinook-client-http
- Client using HTTP for remote connections -
chinook-load-test-remote
- Load testing tool using RMI -
chinook-load-test-http
- Load testing tool using HTTP
Each configuration module:
-
Depends on the base implementation module
-
Adds connection-specific dependencies
-
Configures jlink/jpackage for that specific use case
JLink Configuration Techniques
Common JLink Options
All distributions use these optimization options:
jlink {
imageName = project.name + "-" + project.version + "-" +
OperatingSystem.current().familyName.replace(" ", "").lowercase()
moduleName = application.mainModule
options = listOf(
"--strip-debug",
"--no-header-files",
"--no-man-pages",
"--add-modules",
"is.codion.framework.db.local,is.codion.dbms.h2," +
"is.codion.plugin.logback.proxy,is.codion.demos.chinook.domain"
)
addExtraDependencies("slf4j-api")
Standard optimization options:
-
--strip-debug
- Removes debug information to reduce image size -
--no-header-files
- Excludes C header files (reduces size) -
--no-man-pages
- Excludes manual pages (reduces size)
Advanced Module Management
The server distribution demonstrates sophisticated techniques:
jlink {
imageName = project.name + "-" + project.version + "-" +
OperatingSystem.current().familyName.replace(" ", "").lowercase()
moduleName = application.mainModule
options = listOf(
"--strip-debug",
"--no-header-files",
"--no-man-pages",
"--ignore-signing-information",
"--add-modules",
"is.codion.framework.db.local,is.codion.dbms.h2,is.codion.plugin.hikari.pool," +
"is.codion.plugin.logback.proxy,is.codion.demos.chinook.domain,is.codion.framework.servlet"
)
addExtraDependencies("slf4j-api")
mergedModule {
excludeRequires("jetty.servlet.api")
}
forceMerge("kotlin")
Key Configuration Methods
addExtraDependencies(String… jarPrefixes)
Treats JARs matching given prefixes as dependencies of the merged module. Essential for libraries that don’t declare all their dependencies:
-
Use case: Libraries using JavaFX often don’t specify JavaFX dependencies (removed from JDK in Java 11)
-
Chinook usage:
addExtraDependencies("slf4j-api")
handles SLF4J’s missing runtime dependencies
forceMerge(String… jarPrefixes)
Forces modular JARs to be treated as non-modular and included in the merged module:
-
Use case: Resolves split package conflicts or cyclic dependencies
-
Chinook usage:
forceMerge("kotlin")
handles Kotlin’s split packages across multiple runtime JARs
mergedModule.excludeRequires(String… modules)
Removes specific module requirements from the generated merged module descriptor:
-
Use case: Eliminates problematic or unnecessary module dependencies
-
Chinook usage:
excludeRequires("jetty.servlet.api")
removes conflicting Jetty requirements
Advanced jlink Options
-
--ignore-signing-information
- Bypasses JAR signature verification (essential when dependencies contain signed JARs) -
--add-modules
- Explicitly includes runtime-only modules not detected by dependency analysis
How the Plugin Works
The plugin implements a sophisticated module merging strategy:
-
Identifies non-modular JARs in the dependency graph
-
Combines them into a single merged module with generated module descriptor
-
Creates delegating modules for each original non-modular dependency
-
Resolves cyclic dependencies by automatically detecting and merging problematic modular JARs
-
Generates optimized module graph suitable for jlink processing
Resource Handling
Copy resources (configuration files, keystores) into the jlink build:
tasks.prepareMergedJarsDir {
doLast {
copy {
from("src/main/resources")
into("build/jlinkbase/mergedjars")
}
}
}
This ensures resources like logback.xml
, truststore.jks
, and keystore.jks
are available in the runtime image.
Signed JAR Handling
When dependencies contain signed JARs, the --ignore-signing-information
option is used (see the advanced jlink configuration above).
JPackage Configuration
The jpackage integration creates professional, platform-specific installers from the jlink runtime images.
Platform-Specific Installers
Each platform gets appropriate installer configuration:
jpackage {
if (OperatingSystem.current().isLinux) {
icon = "../chinook.png"
installerType = "deb"
installerOptions = listOf(
"--linux-shortcut"
)
}
if (OperatingSystem.current().isWindows) {
icon = "../chinook.ico"
installerType = "msi"
installerOptions = listOf(
"--win-menu",
"--win-shortcut"
)
imageOptions = imageOptions + listOf("--win-console")
}
if (OperatingSystem.current().isMacOsX) {
icon = "../chinook.icns"
installerType = "dmg"
}
}
Platform-Specific Features:
-
Linux: Creates .deb packages with desktop shortcuts and menu integration
-
Windows: Creates .msi installers with Start menu integration, desktop shortcuts, and console output for server applications
-
macOS: Creates .dmg disk images with drag-and-drop installation
Key Configuration Options:
-
installerType
- Specifies installer format (msi, deb, dmg, rpm, etc.) -
installerOptions
- Platform-specific installer options (--win-menu, --linux-shortcut, etc.) -
imageOptions
- Runtime image options (--win-console for server applications) -
icon
- Platform-specific application icons (.ico, .png, .icns)
Advanced JPackage Features
Application Metadata:
-
appVersion
- Application version for installer metadata -
vendor
- Vendor name for installer properties -
installerName
/imageName
- Control application naming
Resource Management:
-
resourceDir
- Custom resources for installer customization -
outputDir
- Consolidated output directory for both images and installers
Cross-Platform Considerations:
-
Single Platform: jpackage can only create installers for the platform it runs on
-
CI/CD Integration: Use GitHub Actions or similar for multi-platform builds
-
Target Platform: Use
targetPlatformName
when combined with jlink’stargetPlatform
method
Dynamic Naming Strategy
Chinook uses dynamic naming to include version and platform information:
-
Pattern:
{project-name}-{version}-{platform}
-
Benefits: Easy identification, parallel builds, artifact organization
-
Implementation: Automatic OS detection with
OperatingSystem.current()
Example: Client Distribution Variants
Let’s examine how different client distributions are configured:
chinook-client-local
Local embedded database connection:
import org.gradle.internal.os.OperatingSystem
plugins {
id("org.beryx.jlink")
id("com.github.breadmoirai.github-release")
}
dependencies {
implementation(project(":chinook-client"))
runtimeOnly(project(":chinook-domain"))
runtimeOnly(libs.codion.framework.db.local)
runtimeOnly(libs.codion.dbms.h2)
runtimeOnly(libs.h2)
}
application {
mainModule = "is.codion.demos.chinook.client"
mainClass = "is.codion.demos.chinook.ui.ChinookAppPanel"
applicationDefaultJvmArgs = listOf(
"-Xmx64m",
"-Dcodion.client.connectionType=local",
"-Dcodion.db.url=jdbc:h2:mem:h2db",
"-Dcodion.db.initScripts=classpath:create_schema.sql",
"-Dis.codion.swing.framework.ui.EntityTablePanel.includeQueryInspector=true",
"-Dis.codion.swing.framework.ui.EntityEditPanel.includeQueryInspector=true",
"-Dsun.awt.disablegrab=true"
)
}
jlink {
imageName = project.name + "-" + project.version + "-" +
OperatingSystem.current().familyName.replace(" ", "").lowercase()
moduleName = application.mainModule
options = listOf(
"--strip-debug",
"--no-header-files",
"--no-man-pages",
"--add-modules",
"is.codion.framework.db.local,is.codion.dbms.h2," +
"is.codion.plugin.logback.proxy,is.codion.demos.chinook.domain"
)
addExtraDependencies("slf4j-api")
jpackage {
if (OperatingSystem.current().isLinux) {
icon = "../chinook.png"
installerType = "deb"
installerOptions = listOf(
"--linux-shortcut"
)
}
if (OperatingSystem.current().isWindows) {
icon = "../chinook.ico"
installerType = "msi"
installerOptions = listOf(
"--win-menu",
"--win-shortcut"
)
}
if (OperatingSystem.current().isMacOsX) {
icon = "../chinook.icns"
installerType = "dmg"
}
}
}
if (properties.containsKey("githubAccessToken")) {
githubRelease {
token(properties["githubAccessToken"] as String)
owner = "codion-is"
repo = "chinook"
allowUploadToExisting = true
releaseAssets.from(tasks.named("jlinkZip").get().outputs.files)
releaseAssets.from(fileTree(tasks.named("jpackage").get().outputs.files.singleFile) {
exclude(project.name + "/**", project.name + ".app/**")
})
}
}
Key aspects:
-
Includes H2 database driver
-
No remote connection dependencies
-
Simpler module requirements
chinook-client-remote
RMI-based remote connection:
import org.gradle.internal.os.OperatingSystem
plugins {
id("org.beryx.jlink")
id("chinook.jasperreports.pdf.modules")
id("com.github.breadmoirai.github-release")
}
dependencies {
implementation(project(":chinook-client"))
runtimeOnly(libs.codion.framework.db.rmi)
}
val serverHost: String by project
val serverRegistryPort: String by project
application {
mainModule = "is.codion.demos.chinook.client"
mainClass = "is.codion.demos.chinook.ui.ChinookAppPanel"
applicationDefaultJvmArgs = listOf(
"-Xmx64m",
"-Dcodion.client.connectionType=remote",
"-Dcodion.server.hostname=${serverHost}",
"-Dcodion.server.registryPort=${serverRegistryPort}",
"-Dcodion.client.trustStore=truststore.jks",
"-Dcodion.client.trustStorePassword=crappypass",
"-Dsun.awt.disablegrab=true"
)
}
jlink {
imageName = project.name + "-" + project.version + "-" +
OperatingSystem.current().familyName.replace(" ", "").lowercase()
moduleName = application.mainModule
options = listOf(
"--strip-debug",
"--no-header-files",
"--no-man-pages",
"--add-modules",
"is.codion.framework.db.rmi,is.codion.plugin.logback.proxy"
)
jpackage {
if (OperatingSystem.current().isLinux) {
icon = "../chinook.png"
installerType = "deb"
installerOptions = listOf(
"--linux-shortcut"
)
}
if (OperatingSystem.current().isWindows) {
icon = "../chinook.ico"
installerType = "msi"
installerOptions = listOf(
"--win-menu",
"--win-shortcut"
)
}
if (OperatingSystem.current().isMacOsX) {
icon = "../chinook.icns"
installerType = "dmg"
}
}
}
tasks.prepareMergedJarsDir {
doLast {
copy {
from("src/main/resources")
into("build/jlinkbase/mergedjars")
}
}
}
if (properties.containsKey("githubAccessToken")) {
githubRelease {
token(properties["githubAccessToken"] as String)
owner = "codion-is"
repo = "chinook"
allowUploadToExisting = true
releaseAssets.from(tasks.named("jlinkZip").get().outputs.files)
releaseAssets.from(fileTree(tasks.named("jpackage").get().outputs.files.singleFile) {
exclude(project.name + "/**", project.name + ".app/**")
})
}
}
Key aspects:
-
Includes RMI proxy implementation
-
Adds
truststore.jks
for secure connections -
Additional RMI-related modules
chinook-client-http
HTTP-based remote connection:
import org.gradle.internal.os.OperatingSystem
plugins {
id("org.beryx.jlink")
id("chinook.jasperreports.pdf.modules")
id("com.github.breadmoirai.github-release")
}
dependencies {
implementation(project(":chinook-client"))
runtimeOnly(libs.codion.framework.db.http)
}
val serverHost: String by project
val serverHttpPort: String by project
application {
mainModule = "is.codion.demos.chinook.client"
mainClass = "is.codion.demos.chinook.ui.ChinookAppPanel"
applicationDefaultJvmArgs = listOf(
"-Xmx64m",
"-Dcodion.client.connectionType=http",
"-Dcodion.client.http.secure=false",
"-Dcodion.client.http.hostname=${serverHost}",
"-Dcodion.client.http.port=${serverHttpPort}",
"-Dlogback.configurationFile=src/main/config/logback.xml",
"-Dsun.awt.disablegrab=true"
)
}
jlink {
imageName = project.name + "-" + project.version + "-" +
OperatingSystem.current().familyName.replace(" ", "").lowercase()
moduleName = application.mainModule
options = listOf(
"--strip-debug",
"--no-header-files",
"--no-man-pages",
"--add-modules",
"is.codion.framework.db.http,is.codion.plugin.logback.proxy"
)
jpackage {
if (OperatingSystem.current().isLinux) {
icon = "../chinook.png"
installerType = "deb"
installerOptions = listOf(
"--linux-shortcut"
)
}
if (OperatingSystem.current().isWindows) {
icon = "../chinook.ico"
installerType = "msi"
installerOptions = listOf(
"--win-menu",
"--win-shortcut"
)
}
if (OperatingSystem.current().isMacOsX) {
icon = "../chinook.icns"
installerType = "dmg"
}
}
}
tasks.prepareMergedJarsDir {
doLast {
copy {
from("src/main/resources")
into("build/jlinkbase/mergedjars")
}
}
}
if (properties.containsKey("githubAccessToken")) {
githubRelease {
token(properties["githubAccessToken"] as String)
owner = "codion-is"
repo = "chinook"
allowUploadToExisting = true
releaseAssets.from(tasks.named("jlinkZip").get().outputs.files)
releaseAssets.from(fileTree(tasks.named("jpackage").get().outputs.files.singleFile) {
exclude(project.name + "/**", project.name + ".app/**")
})
}
}
Key aspects:
-
Includes HTTP proxy implementation
-
No truststore needed (uses system trust)
-
Lighter module requirements
Server Distribution Configuration
The server distribution demonstrates advanced techniques:
import org.gradle.internal.os.OperatingSystem
plugins {
id("org.beryx.jlink")
id("chinook.jasperreports.modules")
id("com.github.breadmoirai.github-release")
}
dependencies {
runtimeOnly(libs.codion.framework.server)
runtimeOnly(libs.codion.framework.servlet)
runtimeOnly(libs.codion.plugin.hikari.pool)
runtimeOnly(libs.codion.dbms.h2)
runtimeOnly(libs.h2)
runtimeOnly(project(":chinook-domain"))
//logging library, skipping the email stuff
runtimeOnly(libs.codion.plugin.logback.proxy) {
exclude(group = "com.sun.mail", module = "javax.mail")
}
}
val serverHost: String by project
val serverRegistryPort: String by project
val serverPort: String by project
val serverHttpPort: String by project
val serverAdminPort: String by project
application {
mainModule = "is.codion.framework.server"
mainClass = "is.codion.framework.server.EntityServer"
applicationDefaultJvmArgs = listOf(
"-Xmx512m",
"-Dlogback.configurationFile=logback.xml",
//RMI configuration
"-Djava.rmi.server.hostname=${serverHost}",
"-Dcodion.server.registryPort=${serverRegistryPort}",
"-Djava.rmi.server.randomIDs=true",
"-Djava.rmi.server.useCodebaseOnly=true",
//The serialization whitelist
"-Dcodion.server.objectInputFilterFactoryClassName=is.codion.common.rmi.server.SerializationFilterFactory",
"-Dcodion.server.serialization.filter.patternFile=classpath:serialization-filter-patterns.txt",
//SSL configuration
"-Dcodion.server.classpathKeyStore=keystore.jks",
"-Djavax.net.ssl.keyStorePassword=crappypass",
//The port used by clients
"-Dcodion.server.port=${serverPort}",
//The servlet server
"-Dcodion.server.auxiliaryServerFactoryClassNames=is.codion.framework.servlet.EntityServiceFactory",
"-Dcodion.server.http.secure=false",
"-Dcodion.server.http.port=${serverHttpPort}",
"-Dcodion.server.http.useVirtualThreads=true",
//The port for the admin interface, used by the server monitor
"-Dcodion.server.admin.port=${serverAdminPort}",
//The admin user credentials, used by the server monitor application
"-Dcodion.server.admin.user=scott:tiger",
//Database configuration
"-Dcodion.db.url=jdbc:h2:mem:h2db",
"-Dcodion.db.initScripts=classpath:create_schema.sql",
"-Dcodion.db.countQueries=true",
//A connection pool based on this user is created on startup
"-Dcodion.server.connectionPoolUsers=scott:tiger",
//Client logging disabled by default
"-Dcodion.server.clientLogging=false"
)
}
jlink {
imageName = project.name + "-" + project.version + "-" +
OperatingSystem.current().familyName.replace(" ", "").lowercase()
moduleName = application.mainModule
options = listOf(
"--strip-debug",
"--no-header-files",
"--no-man-pages",
"--ignore-signing-information",
"--add-modules",
"is.codion.framework.db.local,is.codion.dbms.h2,is.codion.plugin.hikari.pool," +
"is.codion.plugin.logback.proxy,is.codion.demos.chinook.domain,is.codion.framework.servlet"
)
addExtraDependencies("slf4j-api")
mergedModule {
excludeRequires("jetty.servlet.api")
}
forceMerge("kotlin")
jpackage {
if (OperatingSystem.current().isLinux) {
icon = "../chinook.png"
installerType = "deb"
installerOptions = listOf(
"--linux-shortcut"
)
}
if (OperatingSystem.current().isWindows) {
icon = "../chinook.ico"
installerType = "msi"
installerOptions = listOf(
"--win-menu",
"--win-shortcut"
)
imageOptions = imageOptions + listOf("--win-console")
}
if (OperatingSystem.current().isMacOsX) {
icon = "../chinook.icns"
installerType = "dmg"
}
}
}
tasks.prepareMergedJarsDir {
doLast {
copy {
from("src/main/resources")
into("build/jlinkbase/mergedjars")
}
}
}
if (properties.containsKey("githubAccessToken")) {
githubRelease {
token(properties["githubAccessToken"] as String)
owner = "codion-is"
repo = "chinook"
allowUploadToExisting = true
releaseAssets.from(tasks.named("jlinkZip").get().outputs.files)
releaseAssets.from(fileTree(tasks.named("jpackage").get().outputs.files.singleFile) {
exclude(project.name + "/**", project.name + ".app/**")
})
}
}
Notable configurations:
-
--ignore-signing-information
for signed dependencies -
Console output on Windows with
--win-console
-
Includes both
keystore.jks
and serialization filter patterns -
Force merges Kotlin modules
GitHub Release Integration
All distributions integrate with GitHub releases:
if (properties.containsKey("githubAccessToken")) {
githubRelease {
token(properties["githubAccessToken"] as String)
owner = "codion-is"
repo = "chinook"
allowUploadToExisting = true
releaseAssets.from(tasks.named("jlinkZip").get().outputs.files)
releaseAssets.from(fileTree(tasks.named("jpackage").get().outputs.files.singleFile) {
exclude(project.name + "/**", project.name + ".app/**")
})
}
}
This automatically uploads both the jlink ZIP and native installers to GitHub releases when the githubAccessToken
property is provided.
Troubleshooting Distribution Issues
Module Resolution Problems
Module Not Found Errors:
-
Check modularity: Use
jar --describe-module <jar-file>
to verify if dependency is modular -
Add missing dependencies: Use
addExtraDependencies("jar-prefix")
for libraries with undeclared dependencies -
Include runtime modules: Add runtime-only modules explicitly with
--add-modules
-
Examine module graph: Run
suggestMergedModuleInfo
task to see recommended module descriptor
Cyclic Dependency Issues:
-
The plugin automatically detects and resolves most cycles by merging problematic modular JARs
-
Use
forceMerge("jar-prefix")
to manually force modular JARs into the merged module -
Check the plugin’s console output for automatic cycle resolution messages
Dependency and Resource Conflicts
"Duplicate Resource" Errors:
-
JasperReports Pattern: Convert automatic modules to full modules to prevent resource file overwrites
-
Custom Exclusions: Use
excludeResources
patterns inmergedModule
block -
Split Package Issues: Use
forceMerge()
to consolidate conflicting packages
Missing Runtime Resources:
-
Resource Copy Strategy: Use
prepareMergedJarsDir
task customization to copy resources -
Module Accessibility: Ensure module-info.java has proper
opens
directives for reflection access -
Classpath Resources: Verify resources are in the correct module location
Signed JAR Issues:
-
Signature Verification: Use
--ignore-signing-information
option for signed dependencies -
Mixed Signatures: Consider the impact on security when bypassing signature verification
Platform and Build Issues
Cross-Platform Builds:
-
Target Platform Limitations: jpackage can only create installers for the current platform
-
JDK Compatibility: Ensure target platform JDK versions match source platform requirements
-
Path Separators: Use platform-neutral path handling in custom scripts
Performance and Size Issues:
-
Image Size: Use
--strip-debug
,--no-header-files
,--no-man-pages
for smaller images -
Module Selection: Review included modules with
jdeps
to identify unnecessary dependencies -
Compression: Consider jlink’s
--compress
option (levels 0-2)
JVM and Application Issues:
-
Memory Settings: Configure appropriate JVM memory settings for target environments
-
Environment Variables: Use Mustache syntax
{{VAR_NAME}}
for environment variable substitution -
Path Resolution: Use
{{BIN_DIR}}
placeholder for relative path calculations
Common Plugin Configuration Mistakes
Incorrect Module Names:
-
Verify
moduleName
matches your module-info.java declaration -
Ensure
mergedModuleName
doesn’t conflict with existing modules
Application Configuration Issues:
-
Verify
mainClass
andmainModule
are correctly configured
Resource Directory Issues:
-
Check that custom resource directories exist before plugin execution
-
Verify resource copying happens in the correct task lifecycle phase
Best Practices and Lessons Learned
Development and Configuration
-
Start Simple: Begin with basic jlink configuration and gradually add complexity
-
Use Suggested Module Info: Run
suggestMergedModuleInfo
task to understand plugin recommendations -
Separate Concerns: Use configuration-only modules (like Chinook’s client variants) for different distribution types
-
Document Module Requirements: Clearly document why each module is included and any special handling
Dependency Management
-
Handle Non-Modular Dependencies Early: Use extra-java-module-info plugin to convert automatic modules
-
Address Split Packages: Use
forceMerge()
for libraries with package conflicts (like Kotlin runtime) -
Minimize Dependencies: Regularly review and remove unnecessary dependencies to reduce image size
-
Test Dependency Changes: Verify that dependency updates don’t break module resolution
Performance and Size Optimization
-
Standard Optimization: Always use
--strip-debug
,--no-header-files
,--no-man-pages
-
Compression: Consider
--compress
option for further size reduction (trade-off with startup time) -
Module Selection: Use
jdeps
to analyze and eliminate unnecessary module dependencies -
Resource Cleanup: Remove unused resources and files from the final image
Build and Release Management
-
Dynamic Naming: Include version and platform in image names for easy identification
-
Automated Testing: Create tests that verify each distribution variant works correctly
-
CI/CD Integration: Use GitHub Actions or similar for multi-platform builds
-
Artifact Management: Organize build outputs clearly and consistently
Platform Considerations
-
Test on Target Platforms: Don’t rely solely on cross-compilation; test actual installers
-
Platform-Specific Resources: Provide appropriate icons and resources for each platform
-
Installation Paths: Consider platform conventions for installation directories
-
Security Implications: Understand the impact of
--ignore-signing-information
on security
Resource and Configuration Management
-
Centralized Resources: Use
prepareMergedJarsDir
customization for resource copying -
Environment Variables: Use Mustache syntax for flexible configuration
-
Path Handling: Use plugin-provided placeholders like
{{BIN_DIR}}
for portable scripts -
Configuration Files: Ensure application configuration files are properly included
Advanced Techniques (Chinook-Specific Insights)
-
JasperReports Pattern: Convert automatic modules to explicit modules to prevent resource conflicts
-
Hybrid Module Systems: Combine entity framework patterns with raw JDBC using appropriate module boundaries
-
Connection Abstraction: Use configuration modules to provide different connection types (local, RMI, HTTP)
-
Service Layer Modularity: Separate domain, service, and client layers for maximum flexibility
Troubleshooting Strategy
-
Incremental Debugging: Isolate issues by temporarily removing complex dependencies
-
Plugin Diagnostics: Use plugin’s diagnostic output to understand automatic decisions
-
Module Graph Analysis: Understand the final module graph structure and dependency flow
-
Build Reproducibility: Ensure builds are reproducible across different environments
The Chinook distribution setup demonstrates that while creating modular Java applications with native installers requires careful configuration, the Badass JLink Plugin’s sophisticated dependency management combined with proper module design patterns make it achievable even with complex dependencies like JasperReports, Kotlin runtimes, and multi-tier architectures.