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

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 DefaultDomain {

  public ChinookImpl() {
    super(DOMAIN);
    artist();
    album();
    employee();
    customer();
    genre();
    mediaType();
    track();
    invoice();
    invoiceLine();
    playlist();
    playlistTrack();
  }

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("customer@chinook", 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
  void customer() {
    add(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))
            .tableName("chinook.customer")
            .keyGenerator(identity())
            .validator(new EmailValidator(Customer.EMAIL))
            .orderBy(ascending(Customer.LASTNAME, Customer.FIRSTNAME))
            .stringFactory(new CustomerStringFactory()));

    add(Customer.REPORT, classPathReport(ChinookImpl.class, "customer_report.jasper"));
  }

UI

CustomerPanel
package is.codion.framework.demos.chinook.ui;

import is.codion.framework.demos.chinook.domain.api.Chinook.Invoice;
import is.codion.framework.demos.chinook.domain.api.Chinook.InvoiceLine;
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()));

    SwingEntityModel invoiceModel = customerModel.detailModel(Invoice.TYPE);
    SwingEntityModel invoiceLineModel = invoiceModel.detailModel(InvoiceLine.TYPE);

    InvoiceLineTablePanel invoiceLineTablePanel = new InvoiceLineTablePanel(invoiceLineModel.tableModel());
    InvoiceLineEditPanel invoiceLineEditPanel = new InvoiceLineEditPanel(invoiceLineModel.editModel(),
            invoiceLineTablePanel.table().searchField());

    EntityPanel invoiceLinePanel = new EntityPanel(invoiceLineModel, invoiceLineEditPanel,
            invoiceLineTablePanel, config -> config.includeControls(false));

    addDetailPanel(new InvoicePanel(invoiceModel, invoiceLinePanel));
  }
}
CustomerEditPanel
package is.codion.framework.demos.chinook.ui;

import is.codion.common.db.exception.DatabaseException;
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.framework.demos.chinook.domain.api.Chinook.Customer;
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)
            .selector(Dialogs.singleSelector(new StateValueSupplier()));
    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 StateValueSupplier implements Supplier<Collection<String>> {

    @Override
    public Collection<String> get() {
      try {
        return editModel().connection().select(Customer.STATE);
      }
      catch (DatabaseException e) {
        throw new RuntimeException(e);
      }
    }
  }
}
CustomerTablePanel
package is.codion.framework.demos.chinook.ui;

import is.codion.common.db.exception.DatabaseException;
import is.codion.common.db.report.ReportException;
import is.codion.framework.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;

public final class CustomerTablePanel extends EntityTablePanel {

  private static final ResourceBundle BUNDLE = ResourceBundle.getBundle(CustomerTablePanel.class.getName());

  public CustomerTablePanel(SwingEntityTableModel tableModel) {
    super(tableModel, config -> config
            .refreshButtonVisible(RefreshButtonVisible.ALWAYS));
  }

  @Override
  protected void setupControls() {
    control(TableControl.PRINT).set(Control.builder(this::viewCustomerReport)
            .name(BUNDLE.getString("customer_report"))
            .smallIcon(FrameworkIcons.instance().print())
            .enabled(tableModel().selectionModel().selectionNotEmpty())
            .build());
  }

  private void viewCustomerReport() {
    Dialogs.progressWorkerDialog(this::fillCustomerReport)
            .owner(this)
            .title(BUNDLE.getString("customer_report"))
            .onResult(this::viewReport)
            .execute();
  }

  private JasperPrint fillCustomerReport() throws DatabaseException, ReportException {
    Collection<Long> customerIDs = Entity.values(Customer.ID,
            tableModel().selectionModel().getSelectedItems());
    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("invoice@chinook", 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
  void invoice() {
    add(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))
            .tableName("chinook.invoice")
            .keyGenerator(identity())
            .orderBy(OrderBy.builder()
                    .ascending(Invoice.CUSTOMER_ID)
                    .descending(Invoice.DATE)
                    .build())
            .stringFactory(Invoice.ID));

    add(Invoice.UPDATE_TOTALS, new UpdateTotalsFunction());
  }
  private static final class UpdateTotalsFunction implements DatabaseFunction<EntityConnection, Collection<Long>, Collection<Entity>> {

    @Override
    public Collection<Entity> execute(EntityConnection connection,
                                      Collection<Long> invoiceIds) throws DatabaseException {
      Collection<Entity> invoices =
              connection.select(where(Invoice.ID.in(invoiceIds))
                      .forUpdate()
                      .build());

      return connection.updateSelect(invoices.stream()
              .peek(UpdateTotalsFunction::updateTotal)
              .filter(Entity::modified)
              .collect(toList()));
    }

    private static void updateTotal(Entity invoice) {
      invoice.put(Invoice.TOTAL, invoice.optional(Invoice.CALCULATED_TOTAL).orElse(BigDecimal.ZERO));
    }
  }

Model

InvoiceModel
package is.codion.framework.demos.chinook.model;

import is.codion.framework.db.EntityConnectionProvider;
import is.codion.framework.model.ForeignKeyDetailModelLink;
import is.codion.swing.framework.model.SwingEntityModel;

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);
    detailModelLink.clearForeignKeyOnEmptySelection().set(true);
    detailModelLink.active().set(true);

    invoiceLineEditModel.addTotalsUpdatedListener(tableModel()::replace);
  }
}
InvoiceEditModel
package is.codion.framework.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.framework.demos.chinook.domain.api.Chinook.Customer;
import static is.codion.framework.demos.chinook.domain.api.Chinook.Invoice;

public final class InvoiceEditModel extends SwingEntityEditModel {

  public InvoiceEditModel(EntityConnectionProvider connectionProvider) {
    super(Invoice.TYPE, connectionProvider);
    persist(Invoice.CUSTOMER_FK).set(false);
    editEvent(Invoice.CUSTOMER_FK).addDataListener(this::setAddress);
  }

  private void setAddress(Entity customer) {
    if (customer == null) {
      put(Invoice.BILLINGADDRESS, null);
      put(Invoice.BILLINGCITY, null);
      put(Invoice.BILLINGPOSTALCODE, null);
      put(Invoice.BILLINGSTATE, null);
      put(Invoice.BILLINGCOUNTRY, null);
    }
    else {
      put(Invoice.BILLINGADDRESS, customer.get(Customer.ADDRESS));
      put(Invoice.BILLINGCITY, customer.get(Customer.CITY));
      put(Invoice.BILLINGPOSTALCODE, customer.get(Customer.POSTALCODE));
      put(Invoice.BILLINGSTATE, customer.get(Customer.STATE));
      put(Invoice.BILLINGCOUNTRY, customer.get(Customer.COUNTRY));
    }
  }
}

UI

InvoicePanel
package is.codion.framework.demos.chinook.ui;

import is.codion.framework.demos.chinook.domain.api.Chinook.Invoice;
import is.codion.swing.framework.model.SwingEntityModel;
import is.codion.swing.framework.ui.EntityPanel;
import is.codion.swing.framework.ui.EntityTablePanel;

public final class InvoicePanel extends EntityPanel {

  public InvoicePanel(SwingEntityModel invoiceModel, EntityPanel invoiceLinePanel) {
    super(invoiceModel, new InvoiceEditPanel(invoiceModel.editModel(), invoiceLinePanel),
            new EntityTablePanel(invoiceModel.tableModel(), config -> config
                    .editable(attributes -> attributes.remove(Invoice.TOTAL))),
            config -> config.detailLayout(DetailLayout.NONE));
    addDetailPanel(invoiceLinePanel);
  }
}
InvoiceEditPanel
package is.codion.framework.demos.chinook.ui;

import is.codion.framework.demos.chinook.domain.api.Chinook.InvoiceLine;
import is.codion.framework.domain.entity.Entity;
import is.codion.framework.domain.entity.attribute.Attribute;
import is.codion.framework.model.EntitySearchModel;
import is.codion.swing.common.model.component.table.FilteredTableModel;
import is.codion.swing.framework.model.SwingEntityEditModel;
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.framework.demos.chinook.domain.api.Chinook.Customer;
import static is.codion.framework.demos.chinook.domain.api.Chinook.Invoice;
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, EntityPanel invoiceLinePanel) {
    super(editModel, config -> config.clearAfterInsert(false));
    this.invoiceLinePanel = invoiceLinePanel;
  }

  @Override
  protected void initializeUI() {
    initialFocusAttribute().set(Invoice.CUSTOMER_FK);

    createForeignKeySearchField(Invoice.CUSTOMER_FK)
            .columns(14)
            .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);
  }

  private static final class CustomerSelectorFactory implements Function<EntitySearchModel, Selector> {

    @Override
    public Selector apply(EntitySearchModel searchModel) {
      TableSelector selector = EntitySearchField.tableSelector(searchModel);
      FilteredTableModel<Entity, Attribute<?>> tableModel = selector.table().getModel();
      tableModel.columnModel().setVisibleColumns(Customer.LASTNAME, Customer.FIRSTNAME, Customer.EMAIL);
      tableModel.sortModel().setSortOrder(Customer.LASTNAME, ASCENDING);
      tableModel.sortModel().addSortOrder(Customer.FIRSTNAME, ASCENDING);
      selector.preferredSize(new Dimension(500, 300));

      return selector;
    }
  }
}

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("invoiceline@chinook", 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 sourceValues) {
      Integer quantity = sourceValues.get(InvoiceLine.QUANTITY);
      BigDecimal unitPrice = sourceValues.get(InvoiceLine.UNITPRICE);
      if (unitPrice == null || quantity == null) {
        return null;
      }

      return unitPrice.multiply(BigDecimal.valueOf(quantity));
    }
  }
Implementation
  void invoiceLine() {
    add(InvoiceLine.TYPE.define(
                    InvoiceLine.ID.define()
                            .primaryKey(),
                    InvoiceLine.INVOICE_ID.define()
                            .column()
                            .nullable(false),
                    InvoiceLine.INVOICE_FK.define()
                            .foreignKey(0),
                    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))
            .tableName("chinook.invoiceline")
            .keyGenerator(identity()));
  }

Model

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

import is.codion.common.db.exception.DatabaseException;
import is.codion.common.event.Event;
import is.codion.framework.db.EntityConnection;
import is.codion.framework.db.EntityConnectionProvider;
import is.codion.framework.demos.chinook.domain.api.Chinook.Invoice;
import is.codion.framework.demos.chinook.domain.api.Chinook.InvoiceLine;
import is.codion.framework.demos.chinook.domain.api.Chinook.Track;
import is.codion.framework.domain.entity.Entity;
import is.codion.swing.framework.model.SwingEntityEditModel;

import java.util.Collection;
import java.util.function.Consumer;

public final class InvoiceLineEditModel extends SwingEntityEditModel {

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

  public InvoiceLineEditModel(EntityConnectionProvider connectionProvider) {
    super(InvoiceLine.TYPE, connectionProvider);
    editEvent(InvoiceLine.TRACK_FK).addDataListener(this::setUnitPrice);
  }

  void addTotalsUpdatedListener(Consumer<Collection<Entity>> listener) {
    totalsUpdatedEvent.addDataListener(listener);
  }

  @Override
  protected Collection<Entity> insert(Collection<Entity> entities, EntityConnection connection) throws DatabaseException {
    connection.beginTransaction();
    try {
      Collection<Entity> inserted = connection.insertSelect(entities);
      updateTotals(entities, connection);
      connection.commitTransaction();

      return inserted;
    }
    catch (DatabaseException e) {
      connection.rollbackTransaction();
      throw e;
    }
    catch (Exception e) {
      connection.rollbackTransaction();
      throw new RuntimeException(e);
    }
  }

  @Override
  protected Collection<Entity> update(Collection<Entity> entities, EntityConnection connection) throws DatabaseException {
    connection.beginTransaction();
    try {
      Collection<Entity> updated = connection.updateSelect(entities);
      updateTotals(entities, connection);
      connection.commitTransaction();

      return updated;
    }
    catch (DatabaseException e) {
      connection.rollbackTransaction();
      throw e;
    }
    catch (Exception e) {
      connection.rollbackTransaction();
      throw new RuntimeException(e);
    }
  }

  @Override
  protected void delete(Collection<Entity> entities, EntityConnection connection) throws DatabaseException {
    connection.beginTransaction();
    try {
      connection.delete(Entity.primaryKeys(entities));
      updateTotals(entities, connection);
      connection.commitTransaction();
    }
    catch (DatabaseException e) {
      connection.rollbackTransaction();
      throw e;
    }
    catch (Exception e) {
      connection.rollbackTransaction();
      throw new RuntimeException(e);
    }
  }

  private void setUnitPrice(Entity track) {
    put(InvoiceLine.UNITPRICE, track == null ? null : track.get(Track.UNITPRICE));
  }

  private void updateTotals(Collection<Entity> invoices, EntityConnection connection) throws DatabaseException {
    totalsUpdatedEvent.accept(connection.execute(Invoice.UPDATE_TOTALS, Entity.distinct(InvoiceLine.INVOICE_ID, invoices)));
  }
}
InvoiceLineEditModelTest
package is.codion.framework.demos.chinook.model;

import is.codion.common.db.exception.DatabaseException;
import is.codion.common.user.User;
import is.codion.framework.db.EntityConnection;
import is.codion.framework.db.EntityConnectionProvider;
import is.codion.framework.db.local.LocalEntityConnectionProvider;
import is.codion.framework.demos.chinook.domain.ChinookImpl;
import is.codion.framework.demos.chinook.domain.api.Chinook.Customer;
import is.codion.framework.demos.chinook.domain.api.Chinook.Invoice;
import is.codion.framework.demos.chinook.domain.api.Chinook.InvoiceLine;
import is.codion.framework.demos.chinook.domain.api.Chinook.Track;
import is.codion.framework.domain.entity.Entities;
import is.codion.framework.domain.entity.Entity;
import is.codion.framework.domain.entity.exception.ValidationException;

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() throws DatabaseException, ValidationException {
    try (EntityConnectionProvider connectionProvider = createConnectionProvider()) {
      EntityConnection connection = connectionProvider.connection();

      Entity invoice = createInvoice(connectionProvider);
      assertNull(invoice.get(Invoice.TOTAL));

      Entity battery = connection.selectSingle(Track.NAME.equalToIgnoreCase("battery"));

      InvoiceLineEditModel editModel = new InvoiceLineEditModel(connectionProvider);
      editModel.put(InvoiceLine.INVOICE_FK, invoice);
      editModel.put(InvoiceLine.TRACK_FK, 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.defaults();
      editModel.put(InvoiceLine.INVOICE_FK, invoice);
      editModel.put(InvoiceLine.TRACK_FK, 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.set(invoiceLineBattery);
      editModel.put(InvoiceLine.TRACK_FK, 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(EntityConnectionProvider connectionProvider) throws DatabaseException {
    Entities entities = connectionProvider.entities();
    EntityConnection connection = connectionProvider.connection();

    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.framework.demos.chinook.ui;

import is.codion.framework.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;

public final class InvoiceLineEditPanel extends EntityEditPanel {

  private final JTextField tableSearchField;

  public InvoiceLineEditPanel(SwingEntityEditModel editModel, JTextField tableSearchField) {
    super(editModel);
    this.tableSearchField = tableSearchField;
    editModel.persist(InvoiceLine.TRACK_FK).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)
            .action(control(EditControl.INSERT).get());

    JToolBar updateToolBar = toolBar()
            .floatable(false)
            .action(control(EditControl.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.framework.demos.chinook.ui;

import is.codion.framework.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)
            .includeConditionPanel(false)
            .editable(attributes -> attributes.remove(InvoiceLine.INVOICE_FK))
            .editComponentFactory(InvoiceLine.TRACK_FK, new TrackComponentFactory()));
    table().setAutoResizeMode(JTable.AUTO_RESIZE_ALL_COLUMNS);
    table().getModel().columnModel().visible(InvoiceLine.INVOICE_FK).set(false);
    setPreferredSize(new Dimension(360, 40));
  }
}

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("employee@chinook", 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 = ResourceBundle.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) throws ValidationException {
      super.validate(entity, attribute);
      if (attribute.equals(emailColumn)) {
        validateEmail(entity.get(emailColumn));
      }
    }

    private void validateEmail(String email) throws ValidationException {
      if (!EMAIL_PATTERN.matcher(email).matches()) {
        throw new ValidationException(emailColumn, email, BUNDLE.getString("invalid_email"));
      }
    }
  }
Implementation
  void employee() {
    add(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))
            .tableName("chinook.employee")
            .keyGenerator(identity())
            .validator(new EmailValidator(Employee.EMAIL))
            .orderBy(ascending(Employee.LASTNAME, Employee.FIRSTNAME))
            .stringFactory(StringFactory.builder()
                    .value(Employee.LASTNAME)
                    .text(", ")
                    .value(Employee.FIRSTNAME)
                    .build()));
  }

UI

EmployeeEditPanel
package is.codion.framework.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.framework.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;

final class EmployeeEditPanel extends EntityEditPanel {

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

    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.framework.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) {
    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("artist@chinook", 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");
  }
Implementation
  void artist() {
    add(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"""))
            .tableName("chinook.artist")
            .keyGenerator(identity())
            .orderBy(ascending(Artist.NAME))
            .stringFactory(Artist.NAME));
  }

UI

ArtistEditPanel
package is.codion.framework.demos.chinook.ui;

import is.codion.swing.framework.model.SwingEntityEditModel;
import is.codion.swing.framework.ui.EntityEditPanel;

import static is.codion.framework.demos.chinook.domain.api.Chinook.Artist;
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("genre@chinook", Genre.class.getName());

    Column<Long> ID = TYPE.longColumn("genreid");
    Column<String> NAME = TYPE.stringColumn("name");
  }
Implementation
  void genre() {
    add(Genre.TYPE.define(
                    Genre.ID.define()
                            .primaryKey(),
                    Genre.NAME.define()
                            .column()
                            .searchable(true)
                            .nullable(false)
                            .maximumLength(120))
            .tableName("chinook.genre")
            .keyGenerator(identity())
            .orderBy(ascending(Genre.NAME))
            .stringFactory(Genre.NAME)
            .smallDataset(true));
  }

UI

GenreEditPanel
package is.codion.framework.demos.chinook.ui;

import is.codion.framework.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("mediatype@chinook", MediaType.class.getName());

    Column<Long> ID = TYPE.longColumn("mediatypeid");
    Column<String> NAME = TYPE.stringColumn("name");
  }
Implementation
  void mediaType() {
    add(MediaType.TYPE.define(
                    MediaType.ID.define()
                            .primaryKey(),
                    MediaType.NAME.define()
                            .column()
                            .nullable(false)
                            .maximumLength(120))
            .tableName("chinook.mediatype")
            .keyGenerator(identity())
            .stringFactory(MediaType.NAME)
            .smallDataset(true));
  }

UI

MediaTypeEditPanel
package is.codion.framework.demos.chinook.ui;

import is.codion.framework.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("artist@chinook", 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");
  }
Implementation
  void artist() {
    add(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"""))
            .tableName("chinook.artist")
            .keyGenerator(identity())
            .orderBy(ascending(Artist.NAME))
            .stringFactory(Artist.NAME));
  }

UI

Album

SQL

CREATE TABLE CHINOOK.ALBUM
(
    ALBUMID LONG GENERATED BY DEFAULT AS IDENTITY,
    TITLE VARCHAR(160) NOT NULL,
    ARTISTID INTEGER NOT NULL,
    COVER BLOB,
    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("album@chinook", 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");
    Attribute<Image> COVERIMAGE = TYPE.attribute("coverimage", Image.class);
    Column<Integer> NUMBER_OF_TRACKS = TYPE.integerColumn("number_of_tracks");

    ForeignKey ARTIST_FK = TYPE.foreignKey("artist_fk", ARTIST_ID, Artist.ID);
  }
Implementation
  void album() {
    add(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.COVERIMAGE.define()
                            .derived(new CoverArtImageProvider(), Album.COVER),
                    Album.NUMBER_OF_TRACKS.define()
                            .subquery("""
                                    select count(*)
                                    from chinook.track
                                    where track.albumid = album.albumid"""))
            .tableName("chinook.album")
            .keyGenerator(identity())
            .orderBy(ascending(Album.ARTIST_ID, Album.TITLE))
            .stringFactory(Album.TITLE));
  }

UI

AlbumPanel
package is.codion.framework.demos.chinook.ui;

import is.codion.framework.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.framework.demos.chinook.ui;

import is.codion.framework.demos.chinook.domain.api.Chinook.Artist;
import is.codion.swing.framework.model.SwingEntityEditModel;
import is.codion.swing.framework.ui.EntityEditPanel;

import javax.swing.JPanel;
import java.awt.BorderLayout;

import static is.codion.framework.demos.chinook.domain.api.Chinook.Album;
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.layout.Layouts.borderLayout;

public final class AlbumEditPanel extends EntityEditPanel {

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

  @Override
  protected void initializeUI() {
    initialFocusAttribute().set(Album.ARTIST_FK);

    createForeignKeySearchFieldPanel(Album.ARTIST_FK, this::createArtistEditPanel)
            .columns(15)
            .add(true)
            .edit(true);
    createTextField(Album.TITLE)
            .columns(15);
    component(Album.COVER).set(new CoverArtPanel(editModel().value(Album.COVER)));

    JPanel centerPanel = borderLayoutPanel()
            .westComponent(borderLayoutPanel()
                    .northComponent(gridLayoutPanel(2, 1)
                            .add(createInputPanel(Album.ARTIST_FK))
                            .add(createInputPanel(Album.TITLE))
                            .build())
                    .build())
            .centerComponent(createInputPanel(Album.COVER))
            .build();

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

  private EntityEditPanel createArtistEditPanel() {
    return new ArtistEditPanel(new SwingEntityEditModel(Artist.TYPE, editModel().connectionProvider()));
  }
}
package is.codion.framework.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.common.ui.layout.Layouts;
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 javax.swing.BorderFactory.createEmptyBorder;
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 = ResourceBundle.getBundle(CoverArtPanel.class.getName());

  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 basePanel;
  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.basePanel = createPanel();
    add(basePanel, BorderLayout.CENTER);
    bindEvents();
  }

  private JPanel createPanel() {
    return borderLayoutPanel()
            .preferredSize(EMBEDDED_SIZE)
            .centerComponent(imagePanel)
            .southComponent(borderLayoutPanel()
                    .eastComponent(buttonPanel(Controls.builder()
                            .control(Control.builder(this::selectCover)
                                    .smallIcon(FrameworkIcons.instance().icon(Foundation.PLUS)))
                            .control(Control.builder(this::removeCover)
                                    .smallIcon(FrameworkIcons.instance().icon(Foundation.MINUS))
                                    .enabled(imageSelected)))
                            .buttonGap(0)
                            .border(createEmptyBorder(0, 0, Layouts.GAP.get(), 0))
                            .build())
                    .build())
            .build();
  }

  private void bindEvents() {
    imageBytes.addDataListener(imageBytes -> imagePanel.setImage(readImage(imageBytes)));
    imageBytes.addDataListener(imageBytes -> imageSelected.set(imageBytes != null));
    embedded.addDataListener(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.set(null);
  }

  private void setEmbedded(boolean embedded) {
    configureImagePanel(embedded);
    if (embedded) {
      embed();
    }
    else {
      displayInDialog();
    }
  }

  private void embed() {
    Utilities.disposeParentWindow(basePanel);
    basePanel.setSize(EMBEDDED_SIZE);
    imagePanel.resetView();
    add(basePanel, BorderLayout.CENTER);
    revalidate();
    repaint();
  }

  private void displayInDialog() {
    remove(basePanel);
    revalidate();
    repaint();
    Dialogs.componentDialog(basePanel)
            .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());
    }
  }
}
AlbumTablePanel
package is.codion.framework.demos.chinook.ui;

import is.codion.framework.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.control.Control;
import is.codion.swing.common.ui.dialog.Dialogs;
import is.codion.swing.framework.model.SwingEntityTableModel;
import is.codion.swing.framework.ui.EntityTablePanel;

import javax.imageio.ImageIO;
import java.awt.image.BufferedImage;
import java.io.ByteArrayInputStream;
import java.io.IOException;

public final class AlbumTablePanel extends EntityTablePanel {

  private final NavigableImagePanel imagePanel;

  public AlbumTablePanel(SwingEntityTableModel tableModel) {
    super(tableModel);
    imagePanel = new NavigableImagePanel();
    imagePanel.setPreferredSize(Windows.screenSizeRatio(0.5));
    table().doubleClickAction().set(viewCoverControl());
  }

  private Control viewCoverControl() {
    return Control.builder(this::viewSelectedCover)
            .enabled(tableModel().selectionModel().singleSelection())
            .build();
  }

  private void viewSelectedCover() {
    tableModel().selectionModel().selectedItem()
            .filter(album -> album.isNotNull(Album.COVER))
            .ifPresent(album -> displayImage(album.get(Album.TITLE), album.get(Album.COVER)));
  }

  private void displayImage(String title, byte[] imageBytes) {
    imagePanel.setImage(readImage(imageBytes));
    if (imagePanel.isShowing()) {
      Utilities.parentDialog(imagePanel).toFront();
    }
    else {
      Dialogs.componentDialog(imagePanel)
              .owner(Utilities.parentWindow(this))
              .title(title)
              .modal(false)
              .onClosed(dialog -> imagePanel.setImage(null))
              .show();
    }
  }

  private static BufferedImage readImage(byte[] bytes) {
    try {
      return ImageIO.read(new ByteArrayInputStream(bytes));
    }
    catch (IOException e) {
      throw new RuntimeException(e);
    }
  }
}

Track

SQL

CREATE TABLE CHINOOK.TRACK
(
    TRACKID LONG GENERATED BY DEFAULT AS IDENTITY,
    NAME VARCHAR(200) NOT NULL,
    ALBUMID INTEGER,
    MEDIATYPEID INTEGER NOT NULL,
    GENREID INTEGER,
    COMPOSER VARCHAR(220),
    MILLISECONDS INTEGER NOT NULL,
    BYTES DOUBLE,
    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)
);

Domain

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

    Column<Long> ID = TYPE.longColumn("trackid");
    Column<String> NAME = TYPE.stringColumn("name");
    Attribute<Entity> ARTIST_DENORM = TYPE.entityAttribute("artist_denorm");
    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<String> MINUTES_SECONDS_DERIVED = TYPE.stringColumn("minutes_seconds_derived");
    Column<Integer> BYTES = TYPE.integerColumn("bytes");
    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");

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

      public RaisePriceParameters {
        requireNonNull(trackIds);
        requireNonNull(priceIncrease);
      }
    }
  }
  final class TrackMinSecProvider
          implements DerivedAttribute.Provider<String> {

    @Serial
    private static final long serialVersionUID = 1;

    @Override
    public String get(SourceValues sourceValues) {
      return sourceValues.optional(Track.MILLISECONDS)
              .map(TrackMinSecProvider::toMinutesSecondsString)
              .orElse(null);
    }

    private static String toMinutesSecondsString(Integer milliseconds) {
      return minutes(milliseconds) + " min " +
              seconds(milliseconds) + " sec";
    }
  }

  static Integer minutes(Integer milliseconds) {
    if (milliseconds == null) {
      return null;
    }

    return milliseconds / 1000 / 60;
  }

  static Integer seconds(Integer milliseconds) {
    if (milliseconds == null) {
      return null;
    }

    return milliseconds / 1000 % 60;
  }

  static Integer milliseconds(Integer minutes, Integer seconds) {
    int milliseconds = minutes == null ? 0 : minutes * 60 * 1000;
    milliseconds += seconds == null ? 0 : seconds * 1000;

    return milliseconds == 0 ? null : milliseconds;
  }
  final class CoverArtImageProvider
          implements DerivedAttribute.Provider<Image> {

    @Serial
    private static final long serialVersionUID = 1;

    @Override
    public Image get(SourceValues sourceValues) {
      return sourceValues.optional(Album.COVER)
              .map(CoverArtImageProvider::fromBytes)
              .orElse(null);
    }

    private static Image fromBytes(byte[] bytes) {
      try {
        return ImageIO.read(new ByteArrayInputStream(bytes));
      }
      catch (IOException e) {
        throw new RuntimeException(e);
      }
    }
  }
  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
  void track() {
    add(Track.TYPE.define(
                    Track.ID.define()
                            .primaryKey(),
                    Track.ARTIST_DENORM.define()
                            .denormalized(Track.ALBUM_FK, Album.ARTIST_FK),
                    Track.ALBUM_ID.define()
                            .column(),
                    Track.ALBUM_FK.define()
                            .foreignKey(2)
                            .attributes(Album.ARTIST_FK, Album.TITLE),
                    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.MINUTES_SECONDS_DERIVED.define()
                            .derived(new TrackMinSecProvider(), Track.MILLISECONDS),
                    Track.BYTES.define()
                            .column()
                            .format(NumberFormat.getIntegerInstance()),
                    Track.UNITPRICE.define()
                            .column()
                            .nullable(false)
                            .minimumValue(0)
                            .maximumFractionDigits(2),
                    Track.RANDOM.define()
                            .column()
                            .readOnly(true)
                            .selectable(false))
            .tableName("chinook.track")
            .keyGenerator(identity())
            .orderBy(ascending(Track.NAME))
            .condition(Track.NOT_IN_PLAYLIST, new NotInPlaylistConditionProvider())
            .stringFactory(Track.NAME));

    add(Track.RAISE_PRICE, new RaisePriceFunction());
  }
  private static final class RaisePriceFunction implements DatabaseFunction<EntityConnection, RaisePriceParameters, Collection<Entity>> {

    @Override
    public Collection<Entity> execute(EntityConnection entityConnection,
                                      RaisePriceParameters parameters) throws DatabaseException {
      Select select =
              where(Track.ID.in(parameters.trackIds()))
                      .forUpdate()
                      .build();

      return entityConnection.updateSelect(entityConnection.select(select).stream()
              .peek(track -> raisePrice(track, parameters.priceIncrease()))
              .collect(toList()));
    }

    private static void raisePrice(Entity track, BigDecimal priceIncrease) {
      track.put(Track.UNITPRICE, track.get(Track.UNITPRICE).add(priceIncrease));
    }
  }

Model

TrackTableModel
package is.codion.framework.demos.chinook.model;

import is.codion.common.db.exception.DatabaseException;
import is.codion.common.value.Value;
import is.codion.framework.db.EntityConnectionProvider;
import is.codion.framework.demos.chinook.domain.api.Chinook.Track.RaisePriceParameters;
import is.codion.framework.domain.entity.Entity;
import is.codion.swing.framework.model.SwingEntityTableModel;

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

import static is.codion.framework.demos.chinook.domain.api.Chinook.Track;

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(Track.TYPE, connectionProvider);
    editable().set(true);
    configureLimit();
  }

  public void raisePriceOfSelected(BigDecimal increase) throws DatabaseException {
    if (selectionModel().selectionNotEmpty().get()) {
      Collection<Long> trackIds = Entity.values(Track.ID, selectionModel().getSelectedItems());
      Collection<Entity> result = connection()
              .execute(Track.RAISE_PRICE, new RaisePriceParameters(trackIds, increase));
      replace(result);
    }
  }

  private void configureLimit() {
    limit().set(DEFAULT_LIMIT);
    limit().addListener(this::refresh);
    limit().addValidator(new LimitValidator());
  }

  private static final class LimitValidator implements Value.Validator<Integer> {

    @Override
    public void validate(Integer value) {
      if (value != null && value > MAXIMUM_LIMIT) {
        // The error message is never displayed, so not required
        throw new IllegalArgumentException();
      }
    }
  }
}
package is.codion.framework.demos.chinook.model;

import is.codion.common.db.exception.DatabaseException;
import is.codion.common.model.table.ColumnConditionModel;
import is.codion.common.user.User;
import is.codion.framework.db.EntityConnectionProvider;
import is.codion.framework.db.local.LocalEntityConnectionProvider;
import is.codion.framework.demos.chinook.domain.ChinookImpl;
import is.codion.framework.demos.chinook.domain.api.Chinook.Album;
import is.codion.framework.demos.chinook.domain.api.Chinook.Track;
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() throws DatabaseException {
    try (EntityConnectionProvider connectionProvider = createConnectionProvider()) {
      Entity masterOfPuppets = connectionProvider.connection()
              .selectSingle(Album.TITLE.equalTo("Master Of Puppets"));

      TrackTableModel trackTableModel = new TrackTableModel(connectionProvider);
      ColumnConditionModel<?, Entity> albumConditionModel =
              trackTableModel.conditionModel().conditionModel(Track.ALBUM_FK);

      albumConditionModel.setEqualValue(masterOfPuppets);

      trackTableModel.refresh();
      assertEquals(8, trackTableModel.getRowCount());

      trackTableModel.selectionModel().selectAll();
      trackTableModel.raisePriceOfSelected(BigDecimal.ONE);

      trackTableModel.items().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.framework.demos.chinook.ui;

import is.codion.framework.demos.chinook.ui.MinutesSecondsPanelValue.MinutesSecondsPanel;
import is.codion.swing.common.ui.component.value.ComponentValue;
import is.codion.swing.common.ui.control.Control;
import is.codion.swing.common.ui.key.KeyEvents;
import is.codion.swing.framework.model.SwingEntityEditModel;
import is.codion.swing.framework.model.SwingEntityTableModel;
import is.codion.swing.framework.ui.EntityEditPanel;

import javax.swing.JPanel;

import static is.codion.framework.demos.chinook.domain.api.Chinook.*;
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.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)
            .add(true)
            .edit(true);
    createForeignKeyComboBoxPanel(Track.GENRE_FK, this::createGenreEditPanel)
            .preferredWidth(160)
            .add(true)
            .edit(true);
    createTextFieldPanel(Track.COMPOSER)
            .columns(12);
    createIntegerField(Track.MILLISECONDS)
            .columns(5);

    ComponentValue<Integer, MinutesSecondsPanel> minutesSecondsValue = new MinutesSecondsPanelValue();
    minutesSecondsValue.link(editModel().value(Track.MILLISECONDS));

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

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

    JPanel durationPanel = flexibleGridLayoutPanel(1, 3)
            .add(createInputPanel(Track.BYTES))
            .add(createInputPanel(Track.MILLISECONDS))
            .add(minutesSecondsValue.component())
            .build();

    JPanel unitPricePanel = borderLayoutPanel()
            .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 void addKeyEvents() {
    KeyEvents.Builder keyEvent = KeyEvents.builder()
            .condition(WHEN_ANCESTOR_OF_FOCUSED_COMPONENT)
            .modifiers(CTRL_DOWN_MASK);
    keyEvent.keyCode(VK_UP)
            .action(Control.control(this::moveSelectionUp))
            .enable(this);
    keyEvent.keyCode(VK_DOWN)
            .action(Control.control(this::moveSelectionDown))
            .enable(this);
  }

  private void moveSelectionUp() {
    if (readyForSelectionChange()) {
      tableModel.selectionModel().moveSelectionUp();
    }
  }

  private void moveSelectionDown() {
    if (readyForSelectionChange()) {
      tableModel.selectionModel().moveSelectionDown();
    }
  }

  private boolean readyForSelectionChange() {
    // If the selection is empty
    if (tableModel.selectionModel().isSelectionEmpty()) {
      return true;
    }
    // If the current item is not modified
    if (!editModel().modified().get()) {
      return true;
    }
    // If the current item was modified and
    // successfully updated after user confirmation
    return updateWithConfirmation();
  }
}
TrackTablePanel
package is.codion.framework.demos.chinook.ui;

import is.codion.common.db.exception.DatabaseException;
import is.codion.framework.demos.chinook.domain.api.Chinook.Track;
import is.codion.framework.demos.chinook.model.TrackTableModel;
import is.codion.framework.demos.chinook.ui.MinutesSecondsPanelValue.MinutesSecondsPanel;
import is.codion.framework.domain.entity.attribute.Attribute;
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.control.Controls;
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.EntityTablePanel;
import is.codion.swing.framework.ui.component.DefaultEntityComponentFactory;

import java.math.BigDecimal;
import java.util.List;
import java.util.Objects;
import java.util.ResourceBundle;

import static is.codion.swing.common.ui.component.Components.bigDecimalField;

public final class TrackTablePanel extends EntityTablePanel {

  private static final ResourceBundle BUNDLE = ResourceBundle.getBundle(TrackTablePanel.class.getName());

  public TrackTablePanel(SwingEntityTableModel tableModel) {
    super(tableModel, config -> config
            .editComponentFactory(Track.MILLISECONDS, new MinutesSecondsComponentFactory(false))
            .tableCellEditorFactory(Track.MILLISECONDS, new MinutesSecondsComponentFactory(true))
            .includeLimitMenu(true));
  }

  @Override
  protected Controls createPopupMenuControls(List<Controls> additionalPopupMenuControls) {
    return super.createPopupMenuControls(additionalPopupMenuControls)
            .addAt(0, Control.builder(this::raisePriceOfSelected)
                    .name(BUNDLE.getString("raise_price") + "...")
                    .enabled(tableModel().selectionModel().selectionNotEmpty())
                    .build())
            .addSeparatorAt(1);
  }

  private void raisePriceOfSelected() throws DatabaseException {
    TrackTableModel tableModel = tableModel();
    tableModel.raisePriceOfSelected(getAmountFromUser());
  }

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

    return Dialogs.inputDialog(amountValue)
            .owner(this)
            .title(BUNDLE.getString("amount"))
            .inputValidator(Objects::nonNull)
            .show();
  }

  private static final class MinutesSecondsComponentFactory
          extends DefaultEntityComponentFactory<Integer, Attribute<Integer>, MinutesSecondsPanel> {

    private final boolean horizontal;

    private MinutesSecondsComponentFactory(boolean horizontal) {
      this.horizontal = horizontal;
    }

    @Override
    public ComponentValue<Integer, MinutesSecondsPanel> componentValue(Attribute<Integer> attribute,
                                                                       SwingEntityEditModel editModel,
                                                                       Integer initialValue) {
      MinutesSecondsPanelValue minutesSecondsPanelValue = new MinutesSecondsPanelValue(horizontal);
      minutesSecondsPanelValue.set(initialValue);

      return minutesSecondsPanelValue;
    }
  }
}
MinutesSecondsPanelValue
package is.codion.framework.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.util.ResourceBundle;

import static is.codion.framework.demos.chinook.domain.api.Chinook.*;
import static is.codion.swing.common.ui.component.Components.gridLayoutPanel;
import static is.codion.swing.common.ui.component.Components.integerField;
import static is.codion.swing.common.ui.layout.Layouts.borderLayout;

final class MinutesSecondsPanelValue extends AbstractComponentValue<Integer, MinutesSecondsPanelValue.MinutesSecondsPanel> {

  MinutesSecondsPanelValue() {
    this(false);
  }

  MinutesSecondsPanelValue(boolean horizontal) {
    super(new MinutesSecondsPanel(horizontal));
    component().minutesField.numberValue().addListener(this::notifyListeners);
    component().secondsField.numberValue().addListener(this::notifyListeners);
  }

  @Override
  protected Integer getComponentValue() {
    return milliseconds(component().minutesField.getNumber(), component().secondsField.getNumber());
  }

  @Override
  protected void setComponentValue(Integer milliseconds) {
    component().minutesField.setNumber(minutes(milliseconds));
    component().secondsField.setNumber(seconds(milliseconds));
  }

  static final class MinutesSecondsPanel extends JPanel {

    private static final ResourceBundle BUNDLE = ResourceBundle.getBundle(MinutesSecondsPanel.class.getName());

    private final NumberField<Integer> minutesField = integerField()
            .transferFocusOnEnter(true)
            .selectAllOnFocusGained(true)
            .columns(2)
            .build();
    private final NumberField<Integer> secondsField = integerField()
            .valueRange(0, 59)
            .transferFocusOnEnter(true)
            .selectAllOnFocusGained(true)
            .columns(2)
            .build();

    private MinutesSecondsPanel(boolean horizontal) {
      super(borderLayout());
      if (horizontal) {
        gridLayoutPanel(1, 4)
                .add(new JLabel(BUNDLE.getString("min")))
                .add(minutesField)
                .add(new JLabel(BUNDLE.getString("sec")))
                .add(secondsField)
                .build(panel -> add(panel, BorderLayout.CENTER));
      }
      else {
        gridLayoutPanel(1, 2)
                .add(new JLabel(BUNDLE.getString("min")))
                .add(new JLabel(BUNDLE.getString("sec")))
                .build(panel -> add(panel, BorderLayout.NORTH));
        gridLayoutPanel(1, 2)
                .add(minutesField)
                .add(secondsField)
                .build(panel -> add(panel, BorderLayout.CENTER));
      }
    }
  }
}

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("playlist@chinook", 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
  void playlist() {
    add(Playlist.TYPE.define(
                    Playlist.ID.define()
                            .primaryKey(),
                    Playlist.NAME.define()
                            .column()
                            .searchable(true)
                            .nullable(false)
                            .maximumLength(120))
            .tableName("chinook.playlist")
            .keyGenerator(identity())
            .orderBy(ascending(Playlist.NAME))
            .stringFactory(Playlist.NAME));

    add(Playlist.RANDOM_PLAYLIST, new CreateRandomPlaylistFunction(entities()));
  }
  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) throws DatabaseException {
      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) throws DatabaseException {
      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) throws DatabaseException {
      return connection.select(Track.ID,
              where(Track.GENRE_FK.in(genres))
                      .orderBy(ascending(Track.RANDOM))
                      .limit(noOfTracks)
                      .build());
    }
  }

Model

PlaylistTableModel
package is.codion.framework.demos.chinook.model;

import is.codion.common.db.exception.DatabaseException;
import is.codion.framework.db.EntityConnection;
import is.codion.framework.db.EntityConnectionProvider;
import is.codion.framework.demos.chinook.domain.api.Chinook.Playlist;
import is.codion.framework.demos.chinook.domain.api.Chinook.Playlist.RandomPlaylistParameters;
import is.codion.framework.domain.entity.Entity;
import is.codion.swing.framework.model.SwingEntityTableModel;

import java.util.Collections;

public final class PlaylistTableModel extends SwingEntityTableModel {

  public PlaylistTableModel(EntityConnectionProvider connectionProvider) {
    super(Playlist.TYPE, connectionProvider);
  }

  public void createRandomPlaylist(RandomPlaylistParameters parameters) throws DatabaseException {
    Entity randomPlaylist = createPlaylist(parameters);
    addItemsAt(0, Collections.singletonList(randomPlaylist));
    selectionModel().setSelectedItem(randomPlaylist);
  }

  private Entity createPlaylist(RandomPlaylistParameters parameters) throws DatabaseException {
    EntityConnection connection = connection();
    connection.beginTransaction();
    try {
      Entity randomPlaylist = connection.execute(Playlist.RANDOM_PLAYLIST, parameters);
      connection.commitTransaction();

      return randomPlaylist;
    }
    catch (DatabaseException e) {
      connection.rollbackTransaction();
      throw e;
    }
    catch (Exception e) {
      connection.rollbackTransaction();
      throw new RuntimeException(e);
    }
  }
}

UI

PlaylistPanel
package is.codion.framework.demos.chinook.ui;

import is.codion.framework.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()),
            config -> config.detailLayout(DetailLayout.NONE));

    SwingEntityModel playlistTrackModel = playlistModel.detailModel(PlaylistTrack.TYPE);
    EntityPanel playlistTrackPanel = new EntityPanel(playlistTrackModel,
            new PlaylistTrackTablePanel(playlistTrackModel.tableModel()));

    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.framework.demos.chinook.ui;

import is.codion.framework.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.updateConfirmer(entity -> true));
  }

  @Override
  protected void initializeUI() {
    initialFocusAttribute().set(Playlist.NAME);
    createTextField(Playlist.NAME)
            .transferFocusOnEnter(false)
            .columns(20);

    setLayout(borderLayout());
    add(borderLayoutPanel()
            .westComponent(createLabel(Playlist.NAME).build())
            .centerComponent(component(Playlist.NAME).get())
            .border(new EmptyBorder(Layouts.GAP.get(), Layouts.GAP.get(), 0, Layouts.GAP.get()))
            .build(), BorderLayout.CENTER);
  }
}
PlaylistTablePanel
package is.codion.framework.demos.chinook.ui;

import is.codion.common.db.exception.DatabaseException;
import is.codion.framework.db.EntityConnectionProvider;
import is.codion.framework.demos.chinook.domain.api.Chinook.Playlist.RandomPlaylistParameters;
import is.codion.framework.demos.chinook.model.PlaylistTableModel;
import is.codion.swing.common.ui.component.value.AbstractComponentValue;
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.model.SwingEntityTableModel;
import is.codion.swing.framework.ui.EntityTablePanel;
import is.codion.swing.framework.ui.icon.FrameworkIcons;

import java.util.List;
import java.util.ResourceBundle;

public final class PlaylistTablePanel extends EntityTablePanel {

  private static final ResourceBundle BUNDLE = ResourceBundle.getBundle(PlaylistTablePanel.class.getName());

  public PlaylistTablePanel(SwingEntityTableModel tableModel) {
    super(tableModel, new PlaylistEditPanel(tableModel.editModel()));
  }

  @Override
  protected Controls createPopupMenuControls(List<Controls> additionalPopupMenuControls) {
    return super.createPopupMenuControls(additionalPopupMenuControls)
            .addAt(6, Control.builder(this::randomPlaylist)
                    .name(BUNDLE.getString("random_playlist"))
                    .smallIcon(FrameworkIcons.instance().add())
                    .build())
            .addSeparatorAt(7);
  }

  private void randomPlaylist() throws DatabaseException {
    RandomPlaylistParametersValue playlistParametersValue = new RandomPlaylistParametersValue(tableModel().connectionProvider());
    RandomPlaylistParameters randomPlaylistParameters = Dialogs.inputDialog(playlistParametersValue)
            .owner(this)
            .title(BUNDLE.getString("random_playlist"))
            .inputValid(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.framework.demos.chinook.ui;

import is.codion.common.db.exception.DatabaseException;
import is.codion.common.state.State;
import is.codion.common.state.StateObserver;
import is.codion.common.value.Value;
import is.codion.common.value.ValueSet;
import is.codion.framework.db.EntityConnection.Select;
import is.codion.framework.db.EntityConnectionProvider;
import is.codion.framework.demos.chinook.domain.api.Chinook.Genre;
import is.codion.framework.demos.chinook.domain.api.Chinook.Playlist.RandomPlaylistParameters;
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 javax.swing.ListSelectionModel;
import java.awt.BorderLayout;
import java.util.ResourceBundle;

import static is.codion.common.NullOrEmpty.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;

final class RandomPlaylistParametersPanel extends JPanel {

  private static final ResourceBundle BUNDLE = ResourceBundle.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());
    this.playlistNameField = createPlaylistNameField();
    this.noOfTracksField = createNoOfTracksField();
    this.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), model.genres)
            .selectionMode(ListSelectionModel.MULTIPLE_INTERVAL_SELECTION)
            .visibleRowCount(5)
            .build();
  }

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

      return listModel;
    }
    catch (DatabaseException e) {
      throw new RuntimeException(e);
    }
  }

  private static final class RandomPlaylistParametersModel {

    private final Value<String> playlistName = Value.value();
    private final Value<Integer> noOfTracks = Value.value();
    private final ValueSet<Entity> genres = ValueSet.valueSet();
    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("playlisttrack@chinook", 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_DENORM = TYPE.entityAttribute("album_denorm");
    Attribute<Entity> ARTIST_DENORM = TYPE.entityAttribute("artist_denorm");

    ForeignKey PLAYLIST_FK = TYPE.foreignKey("playlist_fk", PLAYLIST_ID, Playlist.ID);
    ForeignKey TRACK_FK = TYPE.foreignKey("track_fk", TRACK_ID, Track.ID);
  }
Implementation
  void playlistTrack() {
    add(PlaylistTrack.TYPE.define(
                    PlaylistTrack.ID.define()
                            .primaryKey(),
                    PlaylistTrack.PLAYLIST_ID.define()
                            .column()
                            .nullable(false),
                    PlaylistTrack.PLAYLIST_FK.define()
                            .foreignKey(),
                    PlaylistTrack.ARTIST_DENORM.define()
                            .denormalized(PlaylistTrack.ALBUM_DENORM, Album.ARTIST_FK),
                    PlaylistTrack.TRACK_ID.define()
                            .column()
                            .nullable(false),
                    PlaylistTrack.TRACK_FK.define()
                            .foreignKey(3),
                    PlaylistTrack.ALBUM_DENORM.define()
                            .denormalized(PlaylistTrack.TRACK_FK, Track.ALBUM_FK))
            .tableName("chinook.playlisttrack")
            .keyGenerator(identity())
            .stringFactory(StringFactory.builder()
                    .value(PlaylistTrack.PLAYLIST_FK)
                    .text(" - ")
                    .value(PlaylistTrack.TRACK_FK)
                    .build()));
  }

Model

PlaylistTrackEditModel
package is.codion.framework.demos.chinook.model;

import is.codion.framework.db.EntityConnectionProvider;
import is.codion.framework.demos.chinook.domain.api.Chinook.Playlist;
import is.codion.framework.demos.chinook.domain.api.Chinook.PlaylistTrack;
import is.codion.framework.demos.chinook.domain.api.Chinook.Track;
import is.codion.framework.domain.entity.Entity;
import is.codion.framework.domain.entity.condition.Condition;
import is.codion.swing.framework.model.SwingEntityEditModel;

import static java.util.Collections.singletonList;

public final class PlaylistTrackEditModel extends SwingEntityEditModel {

  public PlaylistTrackEditModel(EntityConnectionProvider connectionProvider) {
    super(PlaylistTrack.TYPE, connectionProvider);
    // Filter out tracks already in the current playlist
    valueEvent(PlaylistTrack.PLAYLIST_FK).addDataListener(this::filterPlaylistTracks);
  }

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

UI

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

import is.codion.framework.demos.chinook.domain.api.Chinook.PlaylistTrack;
import is.codion.framework.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
            // No confirmation needed when deleting
            .deleteConfirmer(dialogOwner -> true));
    editModel.persist(PlaylistTrack.TRACK_FK).set(false);
  }

  @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.framework.demos.chinook.ui;

import is.codion.framework.demos.chinook.domain.api.Chinook.PlaylistTrack;
import is.codion.swing.framework.model.SwingEntityTableModel;
import is.codion.swing.framework.ui.EntityTablePanel;
import is.codion.swing.framework.ui.component.EntitySearchField;

public final class PlaylistTrackTablePanel extends EntityTablePanel {

  public PlaylistTrackTablePanel(SwingEntityTableModel tableModel) {
    super(tableModel, new PlaylistTrackEditPanel(tableModel.editModel()),
            config -> config
                    .editComponentFactory(PlaylistTrack.TRACK_FK, new TrackComponentFactory())
                    // No confirmation needed when deleting
                    .deleteConfirmer(dialogOwner -> true)
                    .includeEditControl(false));
    configureTrackConditionPanel();
  }

  private void configureTrackConditionPanel() {
    conditionPanel().conditionPanel(PlaylistTrack.TRACK_FK)
            .map(conditionPanel -> (EntitySearchField) conditionPanel.equalField())
            .ifPresent(equalField -> equalField.selectorFactory().set(new TrackSelectorFactory()));
  }
}
TrackComponentFactory
package is.codion.framework.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;

public final class TrackComponentFactory extends DefaultEntityComponentFactory<Entity, ForeignKey, EntitySearchField> {

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

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

import is.codion.framework.domain.entity.Entity;
import is.codion.framework.domain.entity.attribute.Attribute;
import is.codion.framework.model.EntitySearchModel;
import is.codion.swing.common.model.component.table.FilteredTableModel;
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.framework.demos.chinook.domain.api.Chinook.Track;
import static is.codion.swing.framework.ui.component.EntitySearchField.tableSelector;
import static javax.swing.SortOrder.ASCENDING;

final class TrackSelectorFactory implements Function<EntitySearchModel, Selector> {

  @Override
  public TableSelector apply(EntitySearchModel searchModel) {
    TableSelector selector = tableSelector(searchModel);
    FilteredTableModel<Entity, Attribute<?>> tableModel = selector.table().getModel();
    tableModel.columnModel().setVisibleColumns(Track.ARTIST_DENORM, Track.ALBUM_FK, Track.NAME);
    tableModel.sortModel().setSortOrder(Track.ARTIST_DENORM, ASCENDING);
    tableModel.sortModel().addSortOrder(Track.ALBUM_FK, ASCENDING);
    tableModel.sortModel().addSortOrder(Track.NAME, ASCENDING);
    selector.preferredSize(new Dimension(500, 300));

    return selector;
  }
}

Application

ChinookAppModel

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

import is.codion.common.version.Version;
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;

import static is.codion.framework.demos.chinook.domain.api.Chinook.*;

public final class ChinookAppModel extends SwingEntityApplicationModel {

  public static final Version VERSION = Version.parsePropertiesFile(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) {
    SwingEntityModel albumModel = new SwingEntityModel(Album.TYPE, connectionProvider);
    SwingEntityModel trackModel = new SwingEntityModel(new TrackTableModel(connectionProvider));
    trackModel.editModel().initializeComboBoxModels(Track.MEDIATYPE_FK, Track.GENRE_FK);

    albumModel.addDetailModel(trackModel);

    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));
    playlistTrackModel.tableModel().columnModel()
            .setVisibleColumns(PlaylistTrack.TRACK_FK, PlaylistTrack.ARTIST_DENORM, PlaylistTrack.ALBUM_DENORM);

    ForeignKeyDetailModelLink<?, ?, ?> detailModelLink = playlistModel.addDetailModel(playlistTrackModel);
    detailModelLink.clearForeignKeyOnEmptySelection().set(true);
    detailModelLink.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.framework.demos.chinook.ui;

import is.codion.common.model.CancelException;
import is.codion.common.model.UserPreferences;
import is.codion.common.user.User;
import is.codion.framework.demos.chinook.model.ChinookAppModel;
import is.codion.framework.model.EntityEditModel;
import is.codion.swing.common.ui.component.combobox.Completion;
import is.codion.swing.common.ui.component.table.FilteredTable;
import is.codion.swing.common.ui.component.table.FilteredTableCellRenderer;
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.EntityPanel;
import is.codion.swing.framework.ui.EntityPanel.PanelState;
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.ResourceBundle;

import static is.codion.framework.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.KeyboardShortcuts.keyStroke;
import static java.awt.event.InputEvent.CTRL_DOWN_MASK;
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 Locale LOCALE_IS = new Locale("is", "IS");
  private static final Locale LOCALE_EN = new Locale("en", "EN");
  private static final String LANGUAGE_IS = "is";
  private static final String 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 = ResourceBundle.getBundle(ChinookAppPanel.class.getName());

  public ChinookAppPanel(ChinookAppModel appModel) {
    super(appModel);
    FrameworkIcons.instance().add(Foundation.PLUS, Foundation.MINUS);
  }

  @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));

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

    SwingEntityModel.Builder mediaTypeModelBuilder =
            SwingEntityModel.builder(MediaType.TYPE)
                    .detailModel(SwingEntityModel.builder(Track.TYPE));

    EntityPanel.Builder mediaTypePanelBuilder =
            EntityPanel.builder(mediaTypeModelBuilder)
                    .editPanel(MediaTypeEditPanel.class)
                    .detailPanel(trackPanelBuilder)
                    .detailLayout(entityPanel -> TabbedDetailLayout.builder(entityPanel)
                            .panelState(PanelState.HIDDEN)
                            .build());

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

    EntityPanel.Builder customerPanelBuilder =
            EntityPanel.builder(Customer.TYPE)
                    .editPanel(CustomerEditPanel.class)
                    .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)
                            .panelState(PanelState.HIDDEN)
                            .build())
                    .preferredSize(new Dimension(1000, 500));

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

  @Override
  protected Controls createViewMenuControls() {
    return super.createViewMenuControls()
            .addAt(2, Control.builder(this::selectLanguage)
                    .name(bundle.getString(SELECT_LANGUAGE))
                    .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::addLookAndFeelProvider);
    Completion.COMBO_BOX_COMPLETION_MODE.set(Completion.Mode.AUTOCOMPLETE);
    EntityEditModel.EDIT_EVENTS.set(true);
    EntityPanel.Config.TOOLBAR_CONTROLS.set(true);
    // Add a CTRL modifier to the DELETE key shortcut for table panels
    EntityTablePanel.Config.KEYBOARD_SHORTCUTS.keyStroke(EntityTablePanel.KeyboardShortcut.DELETE_SELECTED)
            .map(keyStroke -> keyStroke(keyStroke.getKeyCode(), CTRL_DOWN_MASK));
    EntityTablePanel.Config.COLUMN_SELECTION.set(EntityTablePanel.ColumnSelection.MENU);
    FilteredTable.AUTO_RESIZE_MODE.set(JTable.AUTO_RESIZE_ALL_COLUMNS);
    FilteredTableCellRenderer.NUMERICAL_HORIZONTAL_ALIGNMENT.set(SwingConstants.CENTER);
    FilteredTableCellRenderer.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();
  }
}

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.framework.demos.chinook.server;

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.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.DefaultDomain;
import is.codion.framework.domain.Domain;
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() throws DatabaseException {
    connectionPool = ConnectionPoolFactory.instance().createConnectionPool(database, authenticationUser);
  }

  /**
   * Handles logins from clients with this id
   */
  @Override
  public Optional<String> clientTypeId() {
    return Optional.of("is.codion.framework.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");
      }
    }
    catch (DatabaseException e) {
      throw new RuntimeException(e);
    }
  }

  private EntityConnection fetchConnectionFromPool() throws DatabaseException {
    return localEntityConnection(database, domain, connectionPool.connection(authenticationUser));
  }

  private static final class Authentication extends DefaultDomain {

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

Domain unit test

package is.codion.framework.demos.chinook.domain;

import is.codion.framework.db.EntityConnection;
import is.codion.framework.demos.chinook.domain.api.Chinook.Playlist.RandomPlaylistParameters;
import is.codion.framework.domain.entity.Entity;
import is.codion.framework.domain.entity.test.EntityTestUnit;

import org.junit.jupiter.api.Test;

import java.util.List;

import static is.codion.framework.demos.chinook.domain.api.Chinook.*;
import static java.util.Collections.singleton;
import static org.junit.jupiter.api.Assertions.assertEquals;

public class ChinookTest extends EntityTestUnit {

  public ChinookTest() {
    super(new ChinookImpl());
  }

  @Test
  public void album() throws Exception {
    test(Album.TYPE);
  }

  @Test
  public void artist() throws Exception {
    test(Artist.TYPE);
  }

  @Test
  public void customer() throws Exception {
    test(Customer.TYPE);
  }

  @Test
  public void employee() throws Exception {
    test(Employee.TYPE);
  }

  @Test
  public void genre() throws Exception {
    test(Genre.TYPE);
  }

  @Test
  public void invoce() throws Exception {
    test(Invoice.TYPE);
  }

  @Test
  public void invoiceLine() throws Exception {
    test(InvoiceLine.TYPE);
  }

  @Test
  public void mediaType() throws Exception {
    test(MediaType.TYPE);
  }

  @Test
  public void playlist() throws Exception {
    test(Playlist.TYPE);
  }

  @Test
  public void playlistTrack() throws Exception {
    test(PlaylistTrack.TYPE);
  }

  @Test
  public void track() throws Exception {
    test(Track.TYPE);
  }

  @Test
  void randomPlaylist() throws Exception {
    EntityConnection connection = connection();
    connection.beginTransaction();
    try {
      Entity genre = connection.selectSingle(Genre.NAME.equalTo("Metal"));
      int noOfTracks = 10;
      String playlistName = "MetalPlaylistTest";
      RandomPlaylistParameters parameters = new RandomPlaylistParameters(playlistName, noOfTracks, singleton(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();
    }
  }
}

Load test

package is.codion.framework.demos.chinook.client.loadtest;

import is.codion.common.model.loadtest.LoadTest;
import is.codion.common.model.loadtest.LoadTest.Scenario;
import is.codion.common.user.User;
import is.codion.framework.db.EntityConnectionProvider;
import is.codion.framework.demos.chinook.client.loadtest.scenarios.InsertDeleteAlbum;
import is.codion.framework.demos.chinook.client.loadtest.scenarios.InsertDeleteInvoice;
import is.codion.framework.demos.chinook.client.loadtest.scenarios.LogoutLogin;
import is.codion.framework.demos.chinook.client.loadtest.scenarios.RaisePrices;
import is.codion.framework.demos.chinook.client.loadtest.scenarios.RandomPlaylist;
import is.codion.framework.demos.chinook.client.loadtest.scenarios.UpdateTotals;
import is.codion.framework.demos.chinook.client.loadtest.scenarios.ViewAlbum;
import is.codion.framework.demos.chinook.client.loadtest.scenarios.ViewCustomerReport;
import is.codion.framework.demos.chinook.client.loadtest.scenarios.ViewGenre;
import is.codion.framework.demos.chinook.client.loadtest.scenarios.ViewInvoice;
import is.codion.framework.demos.chinook.domain.api.Chinook;
import is.codion.framework.demos.chinook.model.ChinookAppModel;
import is.codion.framework.demos.chinook.ui.ChinookAppPanel;
import is.codion.swing.common.model.tools.loadtest.LoadTestModel;

import java.util.Collection;
import java.util.function.Function;

import static is.codion.common.model.loadtest.LoadTest.Scenario.scenario;
import static is.codion.swing.common.ui.tools.loadtest.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)
              .clientTypeId(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.framework.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.framework.demos.chinook.client.loadtest.scenarios;

import is.codion.common.model.loadtest.LoadTest.Scenario.Performer;
import is.codion.framework.db.EntityConnection;
import is.codion.framework.db.EntityConnectionProvider;
import is.codion.framework.demos.chinook.domain.api.Chinook.Album;
import is.codion.framework.demos.chinook.domain.api.Chinook.Artist;
import is.codion.framework.demos.chinook.domain.api.Chinook.Genre;
import is.codion.framework.demos.chinook.domain.api.Chinook.MediaType;
import is.codion.framework.demos.chinook.domain.api.Chinook.Track;
import is.codion.framework.domain.entity.Entity;

import java.math.BigDecimal;
import java.util.ArrayList;
import java.util.Collection;
import java.util.List;

import static is.codion.framework.demos.chinook.client.loadtest.scenarios.LoadTestUtil.RANDOM;
import static is.codion.framework.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())))
              .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.framework.demos.chinook.client.loadtest.scenarios;

import is.codion.common.model.loadtest.LoadTest.Scenario.Performer;
import is.codion.framework.db.EntityConnectionProvider;

import static is.codion.framework.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.framework.demos.chinook.client.loadtest.scenarios;

import is.codion.common.db.exception.DatabaseException;
import is.codion.common.model.loadtest.LoadTest.Scenario.Performer;
import is.codion.framework.db.EntityConnection;
import is.codion.framework.db.EntityConnectionProvider;
import is.codion.framework.demos.chinook.domain.api.Chinook.Genre;
import is.codion.framework.demos.chinook.domain.api.Chinook.Playlist;
import is.codion.framework.demos.chinook.domain.api.Chinook.Playlist.RandomPlaylistParameters;
import is.codion.framework.demos.chinook.domain.api.Chinook.PlaylistTrack;
import is.codion.framework.domain.entity.Entity;

import java.util.Collection;
import java.util.List;
import java.util.UUID;

import static is.codion.framework.demos.chinook.client.loadtest.scenarios.LoadTestUtil.RANDOM;
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) throws Exception {
    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 = createPlaylist(connection, 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);
  }

  private static Entity createPlaylist(EntityConnection connection,
                                       RandomPlaylistParameters parameters) throws DatabaseException {
    connection.beginTransaction();
    try {
      Entity randomPlaylist = connection.execute(Playlist.RANDOM_PLAYLIST, parameters);
      connection.commitTransaction();

      return randomPlaylist;
    }
    catch (DatabaseException e) {
      connection.rollbackTransaction();
      throw e;
    }
    catch (Exception e) {
      connection.rollbackTransaction();
      throw new RuntimeException(e);
    }
  }
}
package is.codion.framework.demos.chinook.client.loadtest.scenarios;

import is.codion.common.model.loadtest.LoadTest.Scenario.Performer;
import is.codion.framework.db.EntityConnection;
import is.codion.framework.db.EntityConnectionProvider;
import is.codion.framework.demos.chinook.domain.api.Chinook.Album;
import is.codion.framework.demos.chinook.domain.api.Chinook.Artist;
import is.codion.framework.demos.chinook.domain.api.Chinook.Track;
import is.codion.framework.demos.chinook.domain.api.Chinook.Track.RaisePriceParameters;
import is.codion.framework.domain.entity.Entity;

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

import static is.codion.framework.db.EntityConnection.Select.where;
import static is.codion.framework.demos.chinook.client.loadtest.scenarios.LoadTestUtil.randomArtistId;

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.get(0)));
      Collection<Long> trackIds = Entity.values(Track.ID, tracks);
      connection.execute(Track.RAISE_PRICE, new RaisePriceParameters(trackIds, PRICE_INCREASE));
    }
  }
}
package is.codion.framework.demos.chinook.client.loadtest.scenarios;

import is.codion.common.db.exception.DatabaseException;
import is.codion.common.model.loadtest.LoadTest.Scenario.Performer;
import is.codion.framework.db.EntityConnection;
import is.codion.framework.db.EntityConnectionProvider;
import is.codion.framework.demos.chinook.domain.api.Chinook.Customer;
import is.codion.framework.demos.chinook.domain.api.Chinook.Invoice;
import is.codion.framework.demos.chinook.domain.api.Chinook.InvoiceLine;
import is.codion.framework.domain.entity.Entity;

import java.util.Collection;
import java.util.List;
import java.util.stream.Collectors;

import static is.codion.framework.demos.chinook.client.loadtest.scenarios.LoadTestUtil.RANDOM;
import static is.codion.framework.demos.chinook.client.loadtest.scenarios.LoadTestUtil.randomCustomerId;

public final class UpdateTotals 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()))));
      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) throws DatabaseException {
    connection.beginTransaction();
    try {
      Collection<Entity> updated = connection.updateSelect(invoiceLines);
      connection.execute(Invoice.UPDATE_TOTALS, Entity.distinct(InvoiceLine.INVOICE_ID, updated));
      connection.commitTransaction();
    }
    catch (DatabaseException e) {
      connection.rollbackTransaction();
      throw e;
    }
    catch (Exception e) {
      connection.rollbackTransaction();
      throw new RuntimeException(e);
    }
  }
}
package is.codion.framework.demos.chinook.client.loadtest.scenarios;

import is.codion.common.model.loadtest.LoadTest.Scenario.Performer;
import is.codion.framework.db.EntityConnection;
import is.codion.framework.db.EntityConnectionProvider;
import is.codion.framework.demos.chinook.domain.api.Chinook;
import is.codion.framework.demos.chinook.domain.api.Chinook.Album;
import is.codion.framework.demos.chinook.domain.api.Chinook.Artist;
import is.codion.framework.domain.entity.Entity;

import java.util.List;

import static is.codion.framework.db.EntityConnection.Select.where;
import static is.codion.framework.demos.chinook.client.loadtest.scenarios.LoadTestUtil.randomArtistId;

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.get(0)));
    }
  }
}
package is.codion.framework.demos.chinook.client.loadtest.scenarios;

import is.codion.common.model.loadtest.LoadTest.Scenario.Performer;
import is.codion.framework.db.EntityConnection;
import is.codion.framework.db.EntityConnectionProvider;
import is.codion.framework.demos.chinook.domain.api.Chinook.Customer;
import is.codion.framework.domain.entity.Entity;

import java.util.Collection;
import java.util.Collections;
import java.util.HashMap;
import java.util.Map;

import static is.codion.framework.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();
    Entity customer = connection.selectSingle(Customer.ID.equalTo(randomCustomerId()));
    Collection<Long> customerIDs = Collections.singletonList(customer.primaryKey().get());
    Map<String, Object> reportParameters = new HashMap<>();
    reportParameters.put("CUSTOMER_IDS", customerIDs);
    connection.report(Customer.REPORT, reportParameters);
  }
}
package is.codion.framework.demos.chinook.client.loadtest.scenarios;

import is.codion.common.model.loadtest.LoadTest.Scenario.Performer;
import is.codion.framework.db.EntityConnection;
import is.codion.framework.db.EntityConnectionProvider;
import is.codion.framework.demos.chinook.domain.api.Chinook.Genre;
import is.codion.framework.demos.chinook.domain.api.Chinook.Track;
import is.codion.framework.domain.entity.Entity;

import java.util.ArrayList;
import java.util.List;

import static is.codion.framework.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.framework.demos.chinook.client.loadtest.scenarios;

import is.codion.common.model.loadtest.LoadTest.Scenario.Performer;
import is.codion.framework.db.EntityConnection;
import is.codion.framework.db.EntityConnectionProvider;
import is.codion.framework.demos.chinook.domain.api.Chinook.Customer;
import is.codion.framework.demos.chinook.domain.api.Chinook.Invoice;
import is.codion.framework.demos.chinook.domain.api.Chinook.InvoiceLine;
import is.codion.framework.domain.entity.Entity;

import java.util.List;

import static is.codion.framework.demos.chinook.client.loadtest.scenarios.LoadTestUtil.RANDOM;
import static is.codion.framework.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));
    }
  }
}