This demo application is based on the Chinook music store sample database.

Demonstrated functionality

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(), 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()));
  }

The domain implementation sections below continue the ChinookImpl class.

Customers

customers

Customer

SQL

CREATE TABLE CHINOOK.CUSTOMER
(
    CUSTOMERID 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,
    SUPPORTREPID INTEGER,
    CONSTRAINT PK_CUSTOMER PRIMARY KEY (CUSTOMERID),
    CONSTRAINT FK_EMPLOYEE_CUSTOMER FOREIGN KEY (SUPPORTREPID) REFERENCES CHINOOK.EMPLOYEE(EMPLOYEEID)
);

Domain

API
  interface Customer {
    EntityType TYPE = DOMAIN.entityType("chinook.customer", Customer.class.getName());

    Column<Long> ID = TYPE.longColumn("customerid");
    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("supportrepid");

    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;

    @Override
    public String apply(Entity customer) {
      return new StringBuilder()
              .append(customer.get(Customer.LASTNAME))
              .append(", ")
              .append(customer.get(Customer.FIRSTNAME))
              .append(customer.optional(Customer.EMAIL)
                      .map(email -> " <" + email + ">")
                      .orElse(""))
              .toString();
    }
  }
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()));

    addDetailPanel(new InvoicePanel(customerModel.detailModel(Invoice.TYPE)));
  }
}
CustomerEditPanel
package is.codion.demos.chinook.ui;

import is.codion.demos.chinook.domain.api.Chinook.Customer;
import is.codion.swing.common.ui.dialog.Dialogs;
import is.codion.swing.framework.model.SwingEntityEditModel;
import is.codion.swing.framework.ui.EntityEditPanel;

import javax.swing.JPanel;
import java.util.Collection;
import java.util.function.Supplier;

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.flexibleGridLayout;

public final class CustomerEditPanel extends EntityEditPanel {

  public CustomerEditPanel(SwingEntityEditModel editModel) {
    super(editModel);
  }

  @Override
  protected void initializeUI() {
    initialFocusAttribute().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 one of the supplied values
            .selector(Dialogs.singleSelector(new StatesSupplier()));
    createTextField(Customer.COUNTRY)
            .columns(8);
    createTextField(Customer.PHONE)
            .columns(12);
    createTextField(Customer.FAX)
            .columns(12);
    createForeignKeyComboBox(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 class StatesSupplier implements Supplier<Collection<String>> {

    @Override
    public Collection<String> get() {
      return editModel().connection().select(Customer.STATE);
    }
  }
}
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)
            .name(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
(
    INVOICEID LONG GENERATED BY DEFAULT AS IDENTITY,
    CUSTOMERID INTEGER 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 (INVOICEID),
    CONSTRAINT FK_CUSTOMER_INVOICE FOREIGN KEY (CUSTOMERID) REFERENCES CHINOOK.CUSTOMER(CUSTOMERID)
);

Domain

API
  interface Invoice {
    EntityType TYPE = DOMAIN.entityType("chinook.invoice", Invoice.class.getName());

    Column<Long> ID = TYPE.longColumn("invoiceid");
    Column<Long> CUSTOMER_ID = TYPE.longColumn("customerid");
    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 invoiceid = invoice.invoiceid""")
                            .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.put(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.model.ForeignKeyDetailModelLink;
import is.codion.swing.framework.model.SwingEntityModel;

import javax.swing.SwingUtilities;

public final class InvoiceModel extends SwingEntityModel {

  public InvoiceModel(EntityConnectionProvider connectionProvider) {
    super(new InvoiceEditModel(connectionProvider));

    InvoiceLineEditModel invoiceLineEditModel = new InvoiceLineEditModel(connectionProvider);

    SwingEntityModel invoiceLineModel = new SwingEntityModel(invoiceLineEditModel);
    ForeignKeyDetailModelLink<?, ?, ?> detailModelLink = addDetailModel(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.
    detailModelLink.clearForeignKeyValueOnEmptySelection().set(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.
    detailModelLink.active().set(true);

    // 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.addTotalsUpdatedConsumer(updatedInvoices ->
            SwingUtilities.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.swing.framework.model.SwingEntityEditModel;

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
    value(Invoice.CUSTOMER_FK).persist().set(false);
    // We populate the invoice address fields with
    // the customer address when the customer is edited
    value(Invoice.CUSTOMER_FK).edited().addConsumer(this::setAddress);
  }

  private void setAddress(Entity customer) {
    // We only populate the address fields
    // when we are editing a new invoice
    if (entity().exists().not().get()) {
      if (customer == null) {
        value(Invoice.BILLINGADDRESS).clear();
        value(Invoice.BILLINGCITY).clear();
        value(Invoice.BILLINGPOSTALCODE).clear();
        value(Invoice.BILLINGSTATE).clear();
        value(Invoice.BILLINGCOUNTRY).clear();
      }
      else {
        value(Invoice.BILLINGADDRESS).set(customer.get(Customer.ADDRESS));
        value(Invoice.BILLINGCITY).set(customer.get(Customer.CITY));
        value(Invoice.BILLINGPOSTALCODE).set(customer.get(Customer.POSTALCODE));
        value(Invoice.BILLINGSTATE).set(customer.get(Customer.STATE));
        value(Invoice.BILLINGCOUNTRY).set(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.detailModel(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 = editPanel();
    // We still add the InvoiceLine panel as a detail panel for keyboard navigation
    addDetailPanel(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.framework.model.EntitySearchModel;
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;
import static javax.swing.SortOrder.ASCENDING;

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() {
    initialFocusAttribute().set(Invoice.CUSTOMER_FK);

    createForeignKeySearchField(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<EntitySearchModel, Selector> {

    @Override
    public Selector apply(EntitySearchModel searchModel) {
      // We use the TableSelector, provided by EntitySearchField,
      // configuring the the visible table columns, the sorting and size
      TableSelector selector = EntitySearchField.tableSelector(searchModel);
      selector.table().columnModel().visible().set(Customer.LASTNAME, Customer.FIRSTNAME, Customer.EMAIL);
      selector.table().model().sorter().setSortOrder(Customer.LASTNAME, ASCENDING);
      selector.table().model().sorter().addSortOrder(Customer.FIRSTNAME, ASCENDING);
      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.common.model.condition.TableConditionModel;
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.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.Arrays.asList;
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().conditions(), attribute -> columnModel.column(attribute).getHeaderValue().toString());
    setLayout(new BorderLayout());
    this.simpleConditionPanel = new SimpleConditionPanel(tableModel.queryModel().conditions(), tableModel);
    this.advancedConditionPanel = filterTableConditionPanel(tableModel.queryModel().conditions(),
            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 <T extends ConditionPanel<?>> T panel(Attribute<?> attribute) {
    if (view().isNotEqualTo(ADVANCED)) {
      return (T) 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(TableConditionModel<Attribute<?>> tableConditionModel,
                                 SwingEntityTableModel tableModel) {
      super(new BorderLayout());
      setBorder(createEmptyBorder(5, 5, 5, 5));
      customerConditionPanel = new CustomerConditionPanel(tableConditionModel.get(Invoice.CUSTOMER_FK), tableModel.entityDefinition());
      dateConditionPanel = new DateConditionPanel(tableConditionModel.get(Invoice.DATE));
      dateConditionPanel.yearValue.addListener(tableModel::refresh);
      dateConditionPanel.monthValue.addListener(tableModel::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.condition().operator().set(Operator.IN);
      dateConditionPanel.condition().operator().set(Operator.BETWEEN);
      customerConditionPanel.requestInputFocus();
    }

    private static final class CustomerConditionPanel extends ConditionPanel<Entity> {

      private final EntitySearchField searchField;

      private CustomerConditionPanel(ConditionModel<Entity> condition, EntityDefinition definition) {
        super(condition);
        setLayout(new BorderLayout());
        setBorder(createTitledBorder(createEmptyBorder(), definition.attributes().definition(Invoice.CUSTOMER_FK).caption()));
        ForeignKeyConditionModel foreignKeyCondition = (ForeignKeyConditionModel) condition;
        foreignKeyCondition.operands().in().value().link(foreignKeyCondition.operands().equal());
        searchField = EntitySearchField.builder(foreignKeyCondition.inSearchModel())
                .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);
        condition().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 asList(yearValue.component(), monthValue.component());
      }

      @Override
      public void requestInputFocus() {
        monthValue.component().requestFocusInWindow();
      }

      private void updateCondition() {
        condition().operands().lowerBound().set(lowerBound());
        condition().operands().upperBound().set(upperBound());
      }

      private LocalDate lowerBound() {
        int year = yearValue.optional().orElse(LocalDate.now().getYear());
        Month month = monthValue.optional().orElse(JANUARY);

        return LocalDate.of(year, month, 1);
      }

      private LocalDate upperBound() {
        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
(
    INVOICELINEID LONG GENERATED BY DEFAULT AS IDENTITY,
    INVOICEID INTEGER NOT NULL,
    TRACKID INTEGER NOT NULL,
    UNITPRICE DOUBLE NOT NULL,
    QUANTITY INTEGER NOT NULL,
    CONSTRAINT PK_INVOICELINE PRIMARY KEY (INVOICELINEID),
    CONSTRAINT FK_TRACK_INVOICELINE FOREIGN KEY (TRACKID) REFERENCES CHINOOK.TRACK(TRACKID),
    CONSTRAINT FK_INVOICE_INVOICELINE FOREIGN KEY (INVOICEID) REFERENCES CHINOOK.INVOICE(INVOICEID),
    CONSTRAINT UK_INVOICELINE_INVOICE_TRACK UNIQUE (INVOICEID, TRACKID)
);

Domain

API
  interface InvoiceLine {
    EntityType TYPE = DOMAIN.entityType("chinook.invoiceline", InvoiceLine.class.getName());

    Column<Long> ID = TYPE.longColumn("invoicelineid");
    Column<Long> INVOICE_ID = TYPE.longColumn("invoiceid");
    Column<Long> TRACK_ID = TYPE.longColumn("trackid");
    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(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(new InvoiceLineTotalProvider(),
                                    InvoiceLine.QUANTITY, InvoiceLine.UNITPRICE))
            .keyGenerator(identity())
            .build();
  }

Model

InvoiceLineEditModel
package is.codion.demos.chinook.model;

import is.codion.common.event.Event;
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 java.util.function.Consumer;

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
    value(InvoiceLine.TRACK_FK).edited().addConsumer(this::setUnitPrice);
  }

  void addTotalsUpdatedConsumer(Consumer<Collection<Entity>> consumer) {
    totalsUpdatedEvent.addConsumer(consumer);
  }

  @Override
  protected Collection<Entity> insert(Collection<Entity> invoiceLines, EntityConnection connection) {
    // Use a transaction to update the invoice totals when a 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 a 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 a invoice line is deleted
    transaction(connection, () -> {
      connection.delete(primaryKeys(invoiceLines));
      updateTotals(invoiceLines, connection);
    });
  }

  private void setUnitPrice(Entity track) {
    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.value(InvoiceLine.INVOICE_FK).set(invoice);
      editModel.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.entity().defaults();
      editModel.value(InvoiceLine.INVOICE_FK).set(invoice);
      editModel.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.put(Track.UNITPRICE, BigDecimal.valueOf(2));
      theCallOfKtulu = connection.updateSelect(theCallOfKtulu);

      editModel.entity().set(invoiceLineBattery);
      editModel.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.value(InvoiceLine.TRACK_FK).persist().set(false);
  }

  @Override
  protected void initializeUI() {
    initialFocusAttribute().set(InvoiceLine.TRACK_FK);

    createForeignKeySearchField(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 TrackComponentFactory(InvoiceLine.TRACK_FK)));
    table().setAutoResizeMode(JTable.AUTO_RESIZE_ALL_COLUMNS);
    setPreferredSize(new Dimension(360, 40));
  }
}

Employees

employees

Employee

SQL

CREATE TABLE CHINOOK.EMPLOYEE
(
    EMPLOYEEID LONG GENERATED BY DEFAULT AS IDENTITY,
    LASTNAME VARCHAR(20) NOT NULL,
    FIRSTNAME VARCHAR(20) NOT NULL,
    TITLE VARCHAR(30),
    REPORTSTO INTEGER,
    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 (EMPLOYEEID),
    CONSTRAINT FK_EMPLOYEE_REPORTSTO FOREIGN KEY (REPORTSTO) REFERENCES CHINOOK.EMPLOYEE(EMPLOYEEID)
);

Domain

API
  interface Employee {
    EntityType TYPE = DOMAIN.entityType("chinook.employee", Employee.class.getName());

    Column<Long> ID = TYPE.longColumn("employeeid");
    Column<String> LASTNAME = TYPE.stringColumn("lastname");
    Column<String> FIRSTNAME = TYPE.stringColumn("firstname");
    Column<String> TITLE = TYPE.stringColumn("title");
    Column<Long> REPORTSTO = TYPE.longColumn("reportsto");
    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.layout.Layouts.flexibleGridLayout;

public final class EmployeeEditPanel extends EntityEditPanel {

  public EmployeeEditPanel(SwingEntityEditModel editModel) {
    super(editModel);
  }

  @Override
  protected void initializeUI() {
    initialFocusAttribute().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);
    createForeignKeyComboBox(Employee.REPORTSTO_FK)
            .preferredWidth(120)
            // Disable transfer focus on Enter, so the Enter key triggers
            // the default dialog button, when inserting and updating
            .transferFocusOnEnter(false);

    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.swing.framework.model.SwingEntityTableModel;
import is.codion.swing.framework.ui.EntityTablePanel;

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()));
  }
}

Albums

albums

We start with a few support tables, artist, genre and media type.

Artist

SQL

CREATE TABLE CHINOOK.ARTIST
(
    ARTISTID LONG GENERATED BY DEFAULT AS IDENTITY,
    NAME VARCHAR(120) NOT NULL,
    CONSTRAINT PK_ARTIST PRIMARY KEY (ARTISTID),
    CONSTRAINT UK_ARTIST UNIQUE (NAME)
);

Domain

API
  interface Artist {
    EntityType TYPE = DOMAIN.entityType("chinook.artist", Artist.class.getName());

    Column<Long> ID = TYPE.longColumn("artistid");
    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.artistid = artist.artistid"""),
                    Artist.NUMBER_OF_TRACKS.define()
                            .subquery("""
                                    SELECT COUNT(*)
                                    FROM chinook.track
                                    JOIN chinook.album ON track.albumid = album.albumid
                                    WHERE album.artistid = artist.artistid"""))
            .keyGenerator(identity())
            .orderBy(ascending(Artist.NAME))
            .stringFactory(Artist.NAME)
            .build();
  }

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() {
    initialFocusAttribute().set(Artist.NAME);

    createTextField(Artist.NAME)
            .columns(18);

    setLayout(gridLayout(1, 1));
    addInputPanel(Artist.NAME);
  }
}

Genre

SQL

CREATE TABLE CHINOOK.GENRE
(
    GENREID LONG GENERATED BY DEFAULT AS IDENTITY,
    NAME VARCHAR(120) NOT NULL,
    CONSTRAINT PK_GENRE PRIMARY KEY (GENREID),
    CONSTRAINT UK_GENRE UNIQUE (NAME)
);

Domain

API
  interface Genre {
    EntityType TYPE = DOMAIN.entityType("chinook.genre", Genre.class.getName());

    Column<Long> ID = TYPE.longColumn("genreid");
    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() {
    initialFocusAttribute().set(Genre.NAME);

    createTextField(Genre.NAME);

    setLayout(gridLayout(1, 1));
    addInputPanel(Genre.NAME);
  }
}

Media Type

SQL

CREATE TABLE CHINOOK.MEDIATYPE
(
    MEDIATYPEID LONG GENERATED BY DEFAULT AS IDENTITY,
    NAME VARCHAR(120) NOT NULL,
    CONSTRAINT PK_MEDIATYPE PRIMARY KEY (MEDIATYPEID),
    CONSTRAINT UK_MEDIATYPE UNIQUE (NAME)
);

Domain

API
  interface MediaType {
    EntityType TYPE = DOMAIN.entityType("chinook.mediatype", MediaType.class.getName());

    Column<Long> ID = TYPE.longColumn("mediatypeid");
    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() {
    initialFocusAttribute().set(MediaType.NAME);

    createTextField(MediaType.NAME);

    setLayout(gridLayout(1, 1));
    addInputPanel(MediaType.NAME);
  }
}

Artist

SQL

CREATE TABLE CHINOOK.ARTIST
(
    ARTISTID LONG GENERATED BY DEFAULT AS IDENTITY,
    NAME VARCHAR(120) NOT NULL,
    CONSTRAINT PK_ARTIST PRIMARY KEY (ARTISTID),
    CONSTRAINT UK_ARTIST UNIQUE (NAME)
);

Domain

API
  interface Artist {
    EntityType TYPE = DOMAIN.entityType("chinook.artist", Artist.class.getName());

    Column<Long> ID = TYPE.longColumn("artistid");
    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.artistid = artist.artistid"""),
                    Artist.NUMBER_OF_TRACKS.define()
                            .subquery("""
                                    SELECT COUNT(*)
                                    FROM chinook.track
                                    JOIN chinook.album ON track.albumid = album.albumid
                                    WHERE album.artistid = artist.artistid"""))
            .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() {
    initialFocusAttribute().set(Artist.NAME);

    createTextField(Artist.NAME)
            .columns(18);

    setLayout(gridLayout(1, 1));
    addInputPanel(Artist.NAME);
  }
}

Album

SQL

CREATE TABLE CHINOOK.ALBUM
(
    ALBUMID LONG GENERATED BY DEFAULT AS IDENTITY,
    TITLE VARCHAR(160) NOT NULL,
    ARTISTID INTEGER NOT NULL,
    COVER BLOB,
    TAGS VARCHAR ARRAY,
    CONSTRAINT PK_ALBUM PRIMARY KEY (ALBUMID),
    CONSTRAINT FK_ARTIST_ALBUM FOREIGN KEY (ARTISTID) REFERENCES CHINOOK.ARTIST(ARTISTID)
);

Domain

API
  interface Album {
    EntityType TYPE = DOMAIN.entityType("chinook.album", Album.class.getName());

    Column<Long> ID = TYPE.longColumn("albumid");
    Column<String> TITLE = TYPE.stringColumn("title");
    Column<Long> ARTIST_ID = TYPE.longColumn("artistid");
    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.albumid = album.albumid"""),
                    Album.TAGS.define()
                            .column()
                            .columnClass(Array.class, new TagsConverter(), ResultSet::getArray),
                    Album.RATING.define()
                            .subquery("""
                                    SELECT AVG(rating)
                                    FROM chinook.track
                                    WHERE track.albumid = album.albumid"""))
            .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));
    addDetailModel(trackModel);
    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().conditions().setEqualOperand(Album.TITLE, MASTER_OF_PUPPETS);
      albumTableModel.refresh();
      assertEquals(1, albumTableModel.items().count());

      List<Entity> modifiedTracks = connection.select(Track.ALBUM_FK.equalTo(masterOfPuppets)).stream()
              .peek(track -> track.put(Track.RATING, 10))
              .toList();

      // Update the tracks using the edit model
      TrackEditModel trackEditModel = albumModel.detailModel(Track.TYPE).editModel();
      trackEditModel.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().itemAt(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.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.detailModel(Track.TYPE);
    EntityPanel trackPanel = new EntityPanel(trackModel,
            new TrackEditPanel(trackModel.editModel(), trackModel.tableModel()),
            new TrackTablePanel(trackModel.tableModel()));

    addDetailPanel(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.ui.component.value.ComponentValue;
import is.codion.swing.framework.model.SwingEntityEditModel;
import is.codion.swing.framework.ui.EntityEditPanel;

import javax.swing.DefaultListModel;
import javax.swing.JList;
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() {
    initialFocusAttribute().set(Album.ARTIST_FK);

    createForeignKeySearchField(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 JList based value for the album tags, on which
    // we then base the custom AlbumTagPanel component below.
    ComponentValue<List<String>, JList<String>> tagsValue =
            createList(new DefaultListModel<String>())
                    .items(Album.TAGS)
                    .buildValue();
    // We set the Album.COVER component to the custom CoverArtPanel component.
    component(Album.COVER).set(new CoverArtPanel(editModel().value(Album.COVER)));

    JPanel centerPanel = flexibleGridLayoutPanel(2, 2)
            .add(createInputPanel(Album.ARTIST_FK))
            .add(createInputPanel(Album.TITLE))
            .add(createInputPanel(Album.TAGS, new AlbumTagPanel(tagsValue)))
            .add(createInputPanel(Album.COVER))
            .build();

    setLayout(borderLayout());
    add(centerPanel, BorderLayout.CENTER);
  }

  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.control.Controls;
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.JPanel;
import javax.swing.filechooser.FileNameExtensionFilter;
import java.awt.BorderLayout;
import java.awt.Component;
import java.awt.Dimension;
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.borderLayoutPanel;
import static is.codion.swing.common.ui.component.Components.buttonPanel;
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 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.isNotNull());
    this.imagePanel = createImagePanel();
    this.centerPanel = createCenterPanel();
    add(centerPanel, BorderLayout.CENTER);
    bindEvents();
  }

  private JPanel createCenterPanel() {
    return borderLayoutPanel()
            .preferredSize(EMBEDDED_SIZE)
            .centerComponent(imagePanel)
            .southComponent(borderLayoutPanel()
                    .eastComponent(buttonPanel(Controls.builder()
                            .control(Control.builder()
                                    .command(this::selectCover)
                                    .smallIcon(ICONS.icon(Foundation.PLUS)))
                            .control(Control.builder()
                                    .command(this::removeCover)
                                    .smallIcon(ICONS.icon(Foundation.MINUS))
                                    .enabled(imageSelected)))
                            .buttonBuilder(buttonBuilder -> buttonBuilder.transferFocusOnEnter(true))
                            .buttonGap(0)
                            .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 selectCover() 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.state.State;
import is.codion.framework.i18n.FrameworkMessages;
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.DefaultListModel;
import javax.swing.JList;
import javax.swing.JPanel;
import javax.swing.JTextField;
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>, JList<String>> tagsValue;
  private final DefaultListModel<String> tagListModel;
  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.icon(Foundation.PLUS))
          .build();
  private final Control removeTagControl = Control.builder()
          .command(this::removeTag)
          .smallIcon(ICONS.icon(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>, JList<String>> tagsValue) {
    super(borderLayout());
    this.tagsValue = tagsValue;
    this.tagsValue.component().addListSelectionListener(new UpdateSelectionEmptyState());
    this.tagListModel = (DefaultListModel<String>) tagsValue.component().getModel();
    add(createCenterPanel(), BorderLayout.CENTER);
    setupKeyEvents();
  }

  ComponentValue<List<String>, JList<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))
            .buttonBuilder(buttonBuilder -> buttonBuilder.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() {
    ComponentValue<String, JTextField> tagValue = stringField().buildValue();
    State tagNotNull = State.state(false);
    tagValue.observer().addListener(() -> tagNotNull.set(tagValue.isNotNull()));
    tagListModel.addElement(inputDialog(tagValue)
            .owner(this)
            .title(FrameworkMessages.add())
            .valid(tagNotNull)
            .show());
  }

  private void removeTag() {
    tagsValue.component().getSelectedValuesList().forEach(tagListModel::removeElement);
  }

  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] != tagListModel.getSize() - 1) {
        moveTagsDown(selected);
        moveSelectionDown(selected);
      }
    }
    finally {
      movingTags.set(false);
    }
  }

  private void moveTagsUp(int[] selected) {
    for (int i = 0; i < selected.length; i++) {
      tagListModel.add(selected[i] - 1, tagListModel.remove(selected[i]));
    }
  }

  private void moveTagsDown(int[] selected) {
    for (int i = selected.length - 1; i >= 0; i--) {
      tagListModel.add(selected[i] + 1, tagListModel.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.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.EntityComponentFactory;

import javax.imageio.ImageIO;
import javax.swing.DefaultListModel;
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().doubleClickAction().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.isNotNull(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(dialog -> 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 EntityComponentFactory<List<String>, AlbumTagPanel> {

    @Override
    public ComponentValue<List<String>, AlbumTagPanel> componentValue(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(new DefaultListModel<String>())
              // 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
(
    TRACKID LONG GENERATED BY DEFAULT AS IDENTITY,
    NAME VARCHAR(200) NOT NULL,
    ALBUMID INTEGER NOT NULL,
    MEDIATYPEID INTEGER NOT NULL,
    GENREID INTEGER,
    COMPOSER VARCHAR(220),
    MILLISECONDS INTEGER NOT NULL,
    BYTES DOUBLE,
    RATING INTEGER NOT NULL,
    UNITPRICE DOUBLE NOT NULL,
    CONSTRAINT PK_TRACK PRIMARY KEY (TRACKID),
    CONSTRAINT FK_ALBUM_TRACK FOREIGN KEY (ALBUMID) REFERENCES CHINOOK.ALBUM(ALBUMID),
    CONSTRAINT FK_MEDIATYPE_TRACK FOREIGN KEY (MEDIATYPEID) REFERENCES CHINOOK.MEDIATYPE(MEDIATYPEID),
    CONSTRAINT FK_GENRE_TRACK FOREIGN KEY (GENREID) REFERENCES CHINOOK.GENRE(GENREID),
    CONSTRAINT CHK_RATING CHECK (RATING BETWEEN 1 AND 10)
);

Domain

API
  interface Track {
    EntityType TYPE = DOMAIN.entityType("chinook.track", Track.class.getName());

    Column<Long> ID = TYPE.longColumn("trackid");
    Column<String> NAME = TYPE.stringColumn("name");
    Attribute<Entity> ARTIST = TYPE.entityAttribute("artist");
    Column<Long> ALBUM_ID = TYPE.longColumn("albumid");
    Column<Long> MEDIATYPE_ID = TYPE.longColumn("mediatypeid");
    Column<Long> GENRE_ID = TYPE.longColumn("genreid");
    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<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),
                      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));
    }

    record RaisePriceParameters(Collection<Long> trackIds, BigDecimal priceIncrease) implements Serializable {

      public RaisePriceParameters {
        requireNonNull(trackIds);
        requireNonNull(priceIncrease);
      }
    }

    record Dto(Long id, String name, Album.Dto album,
               Genre.Dto genre, MediaType.Dto mediaType,
               Integer milliseconds, Integer rating,
               BigDecimal unitPrice) {

      public Entity entity(Entities entities) {
        return entities.builder(TYPE)
                .with(ID, id)
                .with(NAME, name)
                .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)
                .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();
    }
  }
Implementation
  EntityDefinition track() {
    return Track.TYPE.define(
                    Track.ID.define()
                            .primaryKey(),
                    Track.ALBUM_ID.define()
                            .column()
                            .nullable(false),
                    Track.ALBUM_FK.define()
                            .foreignKey(2)
                            .attributes(Album.ARTIST_FK, Album.TITLE),
                    Track.ARTIST.define()
                            .denormalized(Track.ALBUM_FK, Album.ARTIST_FK),
                    Track.NAME.define()
                            .column()
                            .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.RANDOM.define()
                            .column()
                            .readOnly(true)
                            .selectable(false))
            .keyGenerator(identity())
            .orderBy(ascending(Track.NAME))
            .condition(Track.NOT_IN_PLAYLIST, new NotInPlaylistConditionProvider())
            .stringFactory(Track.NAME)
            .build();
  }
  private static final class NotInPlaylistConditionProvider implements ConditionProvider {

    @Override
    public String toString(List<Column<?>> columns, List<?> values) {
      return new StringBuilder("""
              trackid NOT IN (
                  SELECT trackid
                  FROM chinook.playlisttrack
                  WHERE playlistid IN (""")
              .append(join(", ", nCopies(values.size(), "?"))).append(")\n")
              .append(")")
              .toString();
    }
  }
  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.put(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.observer.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.List;

public final class TrackEditModel extends SwingEntityEditModel {

  private final Event<Collection<Entity.Key>> ratingUpdated = Event.event();

  public TrackEditModel(EntityConnectionProvider connectionProvider) {
    super(Track.TYPE, connectionProvider);
    // Create and populates the combo box models for the given foreign keys,
    // otherwise this would happen when the respective combo boxes are created
    // which happens on the Event Dispatch Thread.
    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) {
    // Collect the album keys of tracks which rating is
    // modified, to propagate to the ratingUpdated event
    List<Entity.Key> albumKeys = entities.stream()
            .filter(entity -> entity.entityType().equals(Track.TYPE))
            .filter(track -> track.modified(Track.RATING))
            .map(track -> track.key(Track.ALBUM_FK))
            .toList();
    Collection<Entity> updated = super.update(entities, connection);
    ratingUpdated.accept(albumKeys);

    return updated;
  }
}
TrackTableModel
package is.codion.demos.chinook.model;

import is.codion.common.model.condition.ConditionModel;
import is.codion.common.value.Value;
import is.codion.demos.chinook.domain.api.Chinook.Track;
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.Attribute;
import is.codion.swing.framework.model.SwingAttributeConditionModelFactory;
import is.codion.swing.framework.model.SwingEntityTableModel;
import is.codion.swing.framework.model.SwingForeignKeyConditionModel;

import java.math.BigDecimal;
import java.util.Collection;
import java.util.Optional;

import static is.codion.framework.model.EntityConditionModel.entityConditionModel;
import static is.codion.framework.model.EntityQueryModel.entityQueryModel;

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(entityConditionModel(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(this::refresh);
    queryModel().limit().addValidator(new LimitValidator());
  }

  private static final class LimitValidator implements Value.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 SwingAttributeConditionModelFactory {

    private TrackColumnConditionFactory(EntityConnectionProvider connectionProvider) {
      super(connectionProvider);
    }

    @Override
    public Optional<ConditionModel<?>> create(Attribute<?> attribute) {
      if (attribute.equals(Track.MEDIATYPE_FK)) {
        return Optional.of(SwingForeignKeyConditionModel.builder()
                .includeEqualOperators(createEqualComboBoxModel(Track.MEDIATYPE_FK))
                .build());
      }

      return super.create(attribute);
    }
  }
}
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
  public void raisePriceOfSelected() {
    try (EntityConnectionProvider connectionProvider = createConnectionProvider()) {
      Entity masterOfPuppets = connectionProvider.connection()
              .selectSingle(Album.TITLE.equalTo("Master Of Puppets"));

      TrackTableModel trackTableModel = new TrackTableModel(connectionProvider);
      trackTableModel.queryModel().conditions()
            .setEqualOperand(Track.ALBUM_FK, masterOfPuppets);

      trackTableModel.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.swing.common.ui.key.KeyEvents;
import is.codion.swing.framework.model.SwingEntityEditModel;
import is.codion.swing.framework.model.SwingEntityTableModel;
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.borderLayoutPanel;
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 SwingEntityTableModel tableModel;

  public TrackEditPanel(SwingEntityEditModel editModel, SwingEntityTableModel tableModel) {
    super(editModel);
    this.tableModel = tableModel;
    addKeyEvents();
  }

  @Override
  protected void initializeUI() {
    initialFocusAttribute().set(Track.ALBUM_FK);

    createForeignKeySearchField(Track.ALBUM_FK);
    createTextField(Track.NAME)
            .columns(12);
    createForeignKeyComboBoxPanel(Track.MEDIATYPE_FK, this::createMediaTypeEditPanel)
            .preferredWidth(160)
            .includeAddButton(true)
            .includeEditButton(true);
    createForeignKeyComboBoxPanel(Track.GENRE_FK, this::createGenreEditPanel)
            .preferredWidth(160)
            .includeAddButton(true)
            .includeEditButton(true);
    createTextFieldPanel(Track.COMPOSER)
            .columns(12);

    DurationComponentValue durationValue = createDurationValue();
    component(Track.MILLISECONDS).set(durationValue.component());

    createIntegerField(Track.BYTES)
            .columns(6);
    createIntegerSpinner(Track.RATING)
            .columns(2);
    createTextField(Track.UNITPRICE)
            .columns(4);

    JPanel genreMediaTypePanel = gridLayoutPanel(1, 2)
            .add(createInputPanel(Track.GENRE_FK))
            .add(createInputPanel(Track.MEDIATYPE_FK))
            .build();

    JPanel durationPanel = gridLayoutPanel(1, 2)
            .add(createInputPanel(Track.BYTES))
            .add(durationValue.component())
            .build();

    JPanel unitPricePanel = borderLayoutPanel()
            .westComponent(createInputPanel(Track.RATING))
            .eastComponent(createInputPanel(Track.UNITPRICE))
            .build();

    setLayout(flexibleGridLayout(4, 2));
    addInputPanel(Track.ALBUM_FK);
    addInputPanel(Track.NAME);
    add(genreMediaTypePanel);
    addInputPanel(Track.COMPOSER);
    add(durationPanel);
    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 DurationComponentValue createDurationValue() {
    DurationComponentValue durationValue = new DurationComponentValue();
    addValidator(Track.MILLISECONDS, durationValue.component().minutesField);
    addValidator(Track.MILLISECONDS, durationValue.component().secondsField);
    addValidator(Track.MILLISECONDS, durationValue.component().millisecondsField);
    durationValue.link(editModel().value(Track.MILLISECONDS));

    return durationValue;
  }

  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 (readyForSelectionChange()) {
      tableModel.selection().indexes().decrement();
    }
  }

  private void incrementSelection() {
    if (readyForSelectionChange()) {
      tableModel.selection().indexes().increment();
    }
  }

  private boolean readyForSelectionChange() {
    // If the selection is empty
    if (tableModel.selection().isSelectionEmpty()) {
      return true;
    }
    // If the entity is not modified
    if (!editModel().entity().modified().get()) {
      return true;
    }
    // If the current item was modified and
    // successfully updated after user confirmation
    return updateWithConfirmation();
  }
}
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.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.text.NumberField;
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.EntityComponentFactory;

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.framework.ui.component.EntityComponents.entityComponents;
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 RatingComponentFactory())
            // Custom component for editing track durations
            .editComponentFactory(Track.MILLISECONDS, new DurationComponentFactory(tableModel))
            // 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())
            .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)
                    .name(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 = tableModel();
    tableModel.raisePriceOfSelected(getAmountFromUser());
  }

  private BigDecimal getAmountFromUser() {
    ComponentValue<BigDecimal, NumberField<BigDecimal>> amountValue =
            bigDecimalField()
                    .nullable(false)
                    .minimumValue(0)
                    .buildValue();

    return Dialogs.inputDialog(amountValue)
            .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 RatingComponentFactory
          implements EntityComponentFactory<Integer, JSpinner> {

    @Override
    public ComponentValue<Integer, JSpinner> componentValue(SwingEntityEditModel editModel,
                                                            Integer value) {
      return ratingSpinner(editModel.entityDefinition())
              .value(value)
              .buildValue();
    }
  }

  private static final class DurationComponentFactory
          implements EntityComponentFactory<Integer, DurationPanel> {

    private final String caption;

    private DurationComponentFactory(TrackTableModel tableModel) {
      this.caption = tableModel.entityDefinition().attributes().definition(Track.MILLISECONDS).caption();
    }

    @Override
    public Optional<String> caption() {
      return Optional.of(caption);
    }

    @Override
    public ComponentValue<Integer, DurationPanel> componentValue(SwingEntityEditModel editModel, Integer value) {
      DurationComponentValue durationValue = new DurationComponentValue();
      durationValue.set(value);

      return durationValue;
    }
  }
}
DurationComponentValue
package is.codion.demos.chinook.ui;

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() {
    this(false);
  }

  DurationComponentValue(boolean cellEditor) {
    super(new DurationPanel(cellEditor));
    component().minutesField.number().addListener(this::notifyListeners);
    component().secondsField.number().addListener(this::notifyListeners);
    component().millisecondsField.number().addListener(this::notifyListeners);
  }

  @Override
  protected Integer getComponentValue() {
    return (int) ofMinutes(component().minutesField.number().optional().orElse(0))
            .plusSeconds(component().secondsField.number().optional().orElse(0))
            .plusMillis(component().millisecondsField.number().optional().orElse(0))
            .toMillis();
  }

  @Override
  protected void setComponentValue(Integer milliseconds) {
    component().minutesField.number().set(minutes(milliseconds));
    component().secondsField.number().set(seconds(milliseconds));
    component().millisecondsField.number().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());

    final NumberField<Integer> minutesField;
    final NumberField<Integer> secondsField;
    final NumberField<Integer> millisecondsField;

    private DurationPanel(boolean cellEditor) {
      super(borderLayout());
      minutesField = integerField()
              .transferFocusOnEnter(true)
              .selectAllOnFocusGained(true)
              .columns(2)
              .build();
      secondsField = integerField()
              .valueRange(0, 59)
              .transferFocusOnEnter(true)
              .selectAllOnFocusGained(true)
              .silentValidation(true)
              .columns(2)
              .build();
      millisecondsField = integerField()
              .valueRange(0, 999)
              .transferFocusOnEnter(!cellEditor)
              .selectAllOnFocusGained(true)
              .silentValidation(true)
              .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, 3)
              .add(minutesField)
              .add(secondsField)
              .add(millisecondsField)
              .build(), BorderLayout.CENTER);
    }

    private void initializeInputPanel() {
      add(borderLayoutPanel()
              .northComponent(gridLayoutPanel(1, 3)
                      .add(new JLabel(BUNDLE.getString("min")))
                      .add(new JLabel(BUNDLE.getString("sec")))
                      .add(new JLabel(BUNDLE.getString("ms")))
                      .build())
              .centerComponent(gridLayoutPanel(1, 3)
                      .add(minutesField)
                      .add(secondsField)
                      .add(millisecondsField)
                      .build())
              .build());
    }
  }
}

Playlists

playlists

Playlist

SQL

CREATE TABLE CHINOOK.PLAYLIST
(
    PLAYLISTID LONG GENERATED BY DEFAULT AS IDENTITY,
    NAME VARCHAR(120) NOT NULL,
    CONSTRAINT PK_PLAYLIST PRIMARY KEY (PLAYLISTID),
    CONSTRAINT UK_PLAYLIST UNIQUE (NAME)
);

Domain

API
  interface Playlist {
    EntityType TYPE = DOMAIN.entityType("chinook.playlist", Playlist.class.getName());

    Column<Long> ID = TYPE.longColumn("playlistid");
    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().get(), 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().addItemAt(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.detailModel(PlaylistTrack.TYPE);
    EntityPanel playlistTrackPanel =
            new EntityPanel(playlistTrackModel,
                    new PlaylistTrackTablePanel(playlistTrackModel.tableModel()));

    // We still add the detail panel, for keyboard navigation
    addDetailPanel(playlistTrackPanel);
  }

  @Override
  protected void initializeUI() {
    setLayout(borderLayout());
    add(splitPane()
            .leftComponent(mainPanel())
            .rightComponent(detailPanel(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() {
    initialFocusAttribute().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.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 java.util.ResourceBundle;

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)
                    .name(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 = Dialogs.inputDialog(playlistParametersValue)
            .owner(this)
            .title(BUNDLE.getString("random_playlist"))
            .valid(playlistParametersValue.component().parametersValid())
            .show();

    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.State;
import is.codion.common.state.StateObserver;
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.EntityConnection.Select;
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.text.NumberField;

import javax.swing.DefaultListModel;
import javax.swing.JLabel;
import javax.swing.JList;
import javax.swing.JPanel;
import javax.swing.JScrollPane;
import javax.swing.JTextField;
import java.awt.BorderLayout;
import java.util.ResourceBundle;

import static is.codion.common.Text.nullOrEmpty;
import static is.codion.framework.domain.entity.OrderBy.ascending;
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 JList<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);
  }

  StateObserver parametersValid() {
    return model.parametersValid.observer();
  }

  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 JList<Entity> createGenreList(EntityConnectionProvider connectionProvider) {
    return Components.list(createGenreListModel(connectionProvider))
            .selectedItems(model.genres)
            .visibleRowCount(5)
            .build();
  }

  private static DefaultListModel<Entity> createGenreListModel(EntityConnectionProvider connectionProvider) {
    DefaultListModel<Entity> listModel = new DefaultListModel<>();
    connectionProvider.connection().select(Select.all(Genre.TYPE)
                    .orderBy(ascending(Genre.NAME))
                    .build())
            .forEach(listModel::addElement);

    return listModel;
  }

  private static final class RandomPlaylistParametersModel {

    private final Value<String> playlistName = Value.value();
    private final Value<Integer> noOfTracks = Value.value();
    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(isValid());
    }

    private boolean isValid() {
      if (nullOrEmpty(playlistName.get())) {
        return false;
      }
      if (noOfTracks.isNull()) {
        return false;
      }
      if (genres.empty()) {
        return false;
      }

      return true;
    }
  }
}

Playlist Track

SQL

CREATE TABLE CHINOOK.PLAYLISTTRACK
(
    PLAYLISTTRACKID LONG GENERATED BY DEFAULT AS IDENTITY,
    PLAYLISTID INTEGER NOT NULL,
    TRACKID INTEGER NOT NULL,
    CONSTRAINT PK_PLAYLISTTRACK PRIMARY KEY (PLAYLISTTRACKID),
    CONSTRAINT UK_PLAYLISTTRACK UNIQUE (PLAYLISTID, TRACKID),
    CONSTRAINT FK_TRACK_PLAYLISTTRACK FOREIGN KEY (TRACKID) REFERENCES CHINOOK.TRACK(TRACKID),
    CONSTRAINT FK_PLAYLIST_PLAYLISTTRACK FOREIGN KEY (PLAYLISTID) REFERENCES CHINOOK.PLAYLIST(PLAYLISTID)
);

Domain

API
  interface PlaylistTrack {
    EntityType TYPE = DOMAIN.entityType("chinook.playlisttrack", PlaylistTrack.class.getName());

    Column<Long> ID = TYPE.longColumn("playlisttrackid");
    Column<Long> PLAYLIST_ID = TYPE.longColumn("playlistid");
    Column<Long> TRACK_ID = TYPE.longColumn("trackid");
    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(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;

import java.util.List;

public final class PlaylistTrackEditModel extends SwingEntityEditModel {

  public PlaylistTrackEditModel(EntityConnectionProvider connectionProvider) {
    super(PlaylistTrack.TYPE, connectionProvider);
    value(PlaylistTrack.TRACK_FK).persist().set(false);
    // Set the search model condition, so the search results
    // won't contain tracks already in the currently selected playlist
    value(PlaylistTrack.PLAYLIST_FK).addConsumer(this::excludePlaylistTracks);
  }

  private void excludePlaylistTracks(Entity playlist) {
    foreignKeySearchModel(PlaylistTrack.TRACK_FK).condition().set(() -> playlist == null ? null :
            Condition.custom(Track.NOT_IN_PLAYLIST,
                    List.of(Playlist.ID),
                    List.of(playlist.get(Playlist.ID))));
  }
}

UI

PlaylistTrackEditPanel
package is.codion.demos.chinook.ui;

import is.codion.demos.chinook.domain.api.Chinook.PlaylistTrack;
import is.codion.demos.chinook.model.PlaylistTrackEditModel;
import is.codion.swing.common.ui.layout.Layouts;
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(PlaylistTrackEditModel editModel) {
    super(editModel, config -> config
            // Skip confirmation when deleting
            .deleteConfirmer(Confirmer.NONE));
  }

  @Override
  protected void initializeUI() {
    initialFocusAttribute().set(PlaylistTrack.TRACK_FK);
    createForeignKeySearchField(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.demos.chinook.domain.api.Chinook.PlaylistTrack;
import is.codion.framework.domain.entity.Entity;
import is.codion.swing.common.ui.component.table.ColumnConditionPanel;
import is.codion.swing.framework.model.SwingEntityTableModel;
import is.codion.swing.framework.ui.EntityEditPanel.Confirmer;
import is.codion.swing.framework.ui.EntityTablePanel;
import is.codion.swing.framework.ui.component.EntitySearchField;

import java.util.Optional;
import java.util.stream.Stream;

public final class PlaylistTrackTablePanel extends EntityTablePanel {

  public PlaylistTrackTablePanel(SwingEntityTableModel tableModel) {
    // We provide an edit panel, which becomes available via
    // double click and keyboard shortcuts, instead of embedding it
    super(tableModel, new PlaylistTrackEditPanel(tableModel.editModel()), config -> config
            // Custom component for editing tracks
            .editComponentFactory(PlaylistTrack.TRACK_FK, new TrackComponentFactory(PlaylistTrack.TRACK_FK))
            // Skip confirmation when deleting
            .deleteConfirmer(Confirmer.NONE)
            // No need for the edit toolbar control
            .includeEditControl(false));
    table().columnModel()
            .visible().set(PlaylistTrack.TRACK_FK, PlaylistTrack.ARTIST, PlaylistTrack.ALBUM);
    configureTrackConditionPanel();
  }

  private void configureTrackConditionPanel() {
    ColumnConditionPanel<Entity> conditionPanel = conditions().panel(PlaylistTrack.TRACK_FK);
    Stream.of(conditionPanel.fields().equal(), conditionPanel.fields().in())
            .flatMap(Optional::stream)
            .map(EntitySearchField.class::cast)
            .forEach(field -> field.selectorFactory().set(new TrackSelectorFactory()));
  }
}
TrackComponentFactory
package is.codion.demos.chinook.ui;

import is.codion.framework.domain.entity.Entity;
import is.codion.framework.domain.entity.attribute.ForeignKey;
import is.codion.swing.common.ui.component.value.ComponentValue;
import is.codion.swing.framework.model.SwingEntityEditModel;
import is.codion.swing.framework.ui.component.DefaultEntityComponentFactory;
import is.codion.swing.framework.ui.component.EntitySearchField;

/**
 * Provides a {@link EntitySearchField} using the {@link TrackSelectorFactory}.
 */
final class TrackComponentFactory extends DefaultEntityComponentFactory<Entity, EntitySearchField> {

  TrackComponentFactory(ForeignKey trackForeignKey) {
    super(trackForeignKey);
  }

  @Override
  public ComponentValue<Entity, EntitySearchField> componentValue(SwingEntityEditModel editModel,
                                                                  Entity value) {
    ComponentValue<Entity, EntitySearchField> componentValue = super.componentValue(editModel, value);
    EntitySearchField trackSearchField = componentValue.component();
    trackSearchField.selectorFactory().set(new TrackSelectorFactory());

    return componentValue;
  }
}
TrackSelectorFactory
package is.codion.demos.chinook.ui;

import is.codion.demos.chinook.domain.api.Chinook.Track;
import is.codion.framework.model.EntitySearchModel;
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;
import static javax.swing.SortOrder.ASCENDING;

/**
 * Provides a {@link TableSelector} for selecting tracks,
 * displaying columns for the artist, album and track name.
 */
final class TrackSelectorFactory implements Function<EntitySearchModel, Selector> {

  @Override
  public TableSelector apply(EntitySearchModel searchModel) {
    TableSelector selector = tableSelector(searchModel);
    selector.table().columnModel().visible().set(Track.ARTIST, Track.ALBUM_FK, Track.NAME);
    selector.table().model().sorter().setSortOrder(Track.ARTIST, ASCENDING);
    selector.table().model().sorter().addSortOrder(Track.ALBUM_FK, ASCENDING);
    selector.table().model().sorter().addSortOrder(Track.NAME, ASCENDING);
    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.framework.model.ForeignKeyDetailModelLink;
import is.codion.swing.framework.model.SwingEntityApplicationModel;
import is.codion.swing.framework.model.SwingEntityModel;

public final class ChinookAppModel extends SwingEntityApplicationModel {

  public static final Version VERSION = Version.parse(ChinookAppModel.class, "/version.properties");

  public ChinookAppModel(EntityConnectionProvider connectionProvider) {
    super(connectionProvider, VERSION);
    addEntityModel(createAlbumModel(connectionProvider));
    addEntityModel(createPlaylistModel(connectionProvider));
    addEntityModel(createCustomerModel(connectionProvider));
  }

  private static SwingEntityModel createAlbumModel(EntityConnectionProvider connectionProvider) {
    AlbumModel albumModel = new AlbumModel(connectionProvider);
    albumModel.tableModel().refresh();

    return albumModel;
  }

  private static SwingEntityModel createPlaylistModel(EntityConnectionProvider connectionProvider) {
    SwingEntityModel playlistModel = new SwingEntityModel(new PlaylistTableModel(connectionProvider));
    SwingEntityModel playlistTrackModel = new SwingEntityModel(new PlaylistTrackEditModel(connectionProvider));

    ForeignKeyDetailModelLink<?, ?, ?> playlistTrackLink =
            playlistModel.addDetailModel(playlistTrackModel);
    playlistTrackLink.clearForeignKeyValueOnEmptySelection().set(true);
    playlistTrackLink.active().set(true);

    playlistModel.tableModel().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.addDetailModel(invoiceModel);

    customerModel.tableModel().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.model.ChinookAppModel;
import is.codion.demos.chinook.model.TrackTableModel;
import is.codion.swing.common.ui.component.combobox.Completion;
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.common.ui.laf.LookAndFeelProvider;
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 com.formdev.flatlaf.intellijthemes.FlatAllIJThemes;
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.Arrays;
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 DEFAULT_FLAT_LOOK_AND_FEEL = "com.formdev.flatlaf.intellijthemes.materialthemeuilite.FlatMaterialDarkerIJTheme";
  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 = new Locale(LANGUAGE_IS, "IS");
  private static final Locale LOCALE_EN = new Locale(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 appModel) {
    super(appModel);
  }

  @Override
  protected List<EntityPanel> createEntityPanels() {
    return List.of(
            new CustomerPanel(applicationModel().entityModel(Customer.TYPE)),
            new AlbumPanel(applicationModel().entityModel(Album.TYPE)),
            new PlaylistPanel(applicationModel().entityModel(Playlist.TYPE))
    );
  }

  @Override
  protected List<EntityPanel.Builder> createSupportEntityPanelBuilders() {
    EntityPanel.Builder trackPanelBuilder =
            EntityPanel.builder(Track.TYPE)
                    .tablePanel(TrackTablePanel.class);

    SwingEntityModel.Builder genreModelBuilder =
            SwingEntityModel.builder(Genre.TYPE)
                    .detailModel(SwingEntityModel.builder(Track.TYPE)
                            .tableModel(TrackTableModel.class));

    EntityPanel.Builder genrePanelBuilder =
            EntityPanel.builder(genreModelBuilder)
                    .editPanel(GenreEditPanel.class)
                    .detailPanel(trackPanelBuilder)
                    .detailLayout(entityPanel -> TabbedDetailLayout.builder(entityPanel)
                            .initialDetailState(HIDDEN)
                            .build());

    EntityPanel.Builder mediaTypePanelBuilder =
            EntityPanel.builder(MediaType.TYPE)
                    .editPanel(MediaTypeEditPanel.class);

    EntityPanel.Builder artistPanelBuilder =
            EntityPanel.builder(Artist.TYPE)
                    .editPanel(ArtistEditPanel.class);

    EntityPanel.Builder customerPanelBuilder =
            EntityPanel.builder(Customer.TYPE)
                    .tablePanel(CustomerTablePanel.class);

    SwingEntityModel.Builder employeeModelBuilder =
            SwingEntityModel.builder(Employee.TYPE)
                    .detailModel(SwingEntityModel.builder(Customer.TYPE));

    EntityPanel.Builder employeePanelBuilder =
            EntityPanel.builder(employeeModelBuilder)
                    .tablePanel(EmployeeTablePanel.class)
                    .detailPanel(customerPanelBuilder)
                    .detailLayout(entityPanel -> TabbedDetailLayout.builder(entityPanel)
                            .initialDetailState(HIDDEN)
                            .build())
                    .preferredSize(new Dimension(1000, 500));

    return List.of(artistPanelBuilder, genrePanelBuilder, mediaTypePanelBuilder, employeePanelBuilder);
  }

  @Override
  protected Optional<Controls> createViewMenuControls() {
    return super.createViewMenuControls()
            .map(controls -> controls.copy()
                    .controlAt(2, Control.builder()
                            .command(this::selectLanguage)
                            .name(bundle.getString(SELECT_LANGUAGE))
                            .build())
                    .build());
  }

  private void selectLanguage() {
    String currentLanguage = UserPreferences.getUserPreference(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.setUserPreference(LANGUAGE_PREFERENCES_KEY, selectedLanguage);
      showMessageDialog(this, bundle.getString("language_has_been_changed"));
    }
  }

  public static void main(String[] args) throws CancelException {
    String language = UserPreferences.getUserPreference(LANGUAGE_PREFERENCES_KEY, Locale.getDefault().getLanguage());
    Locale.setDefault(LANGUAGE_IS.equals(language) ? LOCALE_IS : LOCALE_EN);
    Arrays.stream(FlatAllIJThemes.INFOS).forEach(LookAndFeelProvider::addLookAndFeel);
    FrameworkIcons.instance().add(Foundation.PLUS, Foundation.MINUS);
    Completion.COMPLETION_MODE.set(Completion.Mode.AUTOCOMPLETE);
    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.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);
    ReferentialIntegrityErrorHandling.REFERENTIAL_INTEGRITY_ERROR_HANDLING.set(ReferentialIntegrityErrorHandling.DISPLAY_DEPENDENCIES);
    EntityApplicationPanel.builder(ChinookAppModel.class, ChinookAppPanel.class)
            .applicationName("Chinook")
            .domainType(DOMAIN)
            .applicationVersion(ChinookAppModel.VERSION)
            .defaultLookAndFeelClassName(DEFAULT_FLAT_LOOK_AND_FEEL)
            .defaultLoginUser(User.parse("scott:tiger"))
            .displayStartupDialog(false)
            .start();
  }
}

Messages

ChinookResources

package is.codion.demos.chinook.domain;

import is.codion.common.resource.Resources;
import is.codion.framework.i18n.FrameworkMessages;

import java.util.Locale;

/**
 * Replace the english insert caption/mnemonic Add/A with Insert/I.
 */
public final class ChinookResources implements Resources {

  private static final String FRAMEWORK_MESSAGES =
          FrameworkMessages.class.getName();

  private final boolean english = Locale.getDefault()
          .equals(new Locale("en", "EN"));

  @Override
  public String getString(String baseBundleName, String key, String defaultString) {
    if (english && baseBundleName.equals(FRAMEWORK_MESSAGES)) {
      return switch (key) {
        case "insert" -> "Insert";
        case "insert_mnemonic" -> "I";
        default -> defaultString;
      };
    }

    return defaultString;
  }
}

Authenticator

SQL

CREATE TABLE CHINOOK.USERS
(
  USERID LONG GENERATED BY DEFAULT AS IDENTITY,
  USERNAME VARCHAR(20) NOT NULL,
  PASSWORDHASH INTEGER NOT NULL,
  CONSTRAINT PK_USER PRIMARY KEY (USERID),
  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> clientType() {
    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("userid");
      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 java.util.Arrays.asList;
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.entityType().equals(Album.TYPE)) {
        entity.put(Album.TAGS, asList("tag_one", "tag_two", "tag_three"));
      }
    }

    @Override
    protected <T> T value(Attribute<T> attribute) {
      if (attribute.equals(Album.TAGS)) {
        return (T) asList("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.function.Function;

import static is.codion.tools.loadtest.LoadTest.Scenario.scenario;
import static is.codion.tools.loadtest.ui.LoadTestPanel.loadTestPanel;
import static java.util.Arrays.asList;

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 = asList(
          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;
import static java.util.Arrays.asList;

public final class RandomPlaylist implements Performer<EntityConnectionProvider> {

  private static final String PLAYLIST_NAME = "Random playlist";
  private static final Collection<String> GENRES =
          asList("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 java.util.stream.Collectors;

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;

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()))));
      Collection<Entity> invoiceLines = connection.select(InvoiceLine.INVOICE_FK.equalTo(invoice));
      invoiceLines.forEach(invoiceLine ->
              invoiceLine.put(InvoiceLine.QUANTITY, RANDOM.nextInt(4) + 1));
      updateInvoiceLines(invoiceLines.stream()
              .filter(Entity::modified)
              .collect(Collectors.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));
    }
  }
}

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();
  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.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.averageGetTime() + " 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 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));
    }
  }
}

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.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"), 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", album, genre,
              mediaType, 10_000_000, 7, BigDecimal.ONE));
      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.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.i18n;
  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;

  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;
  provides is.codion.common.resource.Resources
          with is.codion.demos.chinook.domain.ChinookResources;
}

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.demos.chinook.domain.api;
  requires com.formdev.flatlaf.intellijthemes;
  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;
}

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;
}