Dueuno Elements #7 — One-to-Many Relationships and Where to Find Them

Gianluca Sartori
7 min readJul 17, 2024

--

DISCLAIMER: NOBODY CARES HOW IT WORKS AS LONG AS IT WORKS. THIS INTRODUCTORY ARTICLES ARE LIKE THAT. WE ARE PROCESSING THE ‘WHAT’ NOT THE ‘HOW’. BE PATIENT.

Today we give a job to our people, we link them to aCompany. We are going to surf bottom-up from the Domain (data), to the Services (logic), the Controllers (GUI) ending up registering our new feature to the users.

I don’t care if you think this is right or wrong, this is how we usually approach Dueuno Elements application development. But sometimes we do the opposite. You see… it depends…

The Domain

We start from the bottom, the Domain. In our demo application it’s implemented as a set of GORM entities: Groovy classes that represents, and are mapped to, database tables.

Create the class ~/demo/grails-app/domain/com/example/TCompany.groovy

package com.example

import grails.gorm.MultiTenant
import org.grails.datastore.gorm.GormEntity
import java.time.LocalDateTime

class TCompany implements GormEntity, MultiTenant<TCompany> {
LocalDateTime dateCreated
String name

static hasMany = [
emplyees: TPerson,
]
}

Edit the class ~/demo/grails-app/domain/com/example/TPerson.groovy

package com.example

import grails.gorm.MultiTenant
import org.grails.datastore.gorm.GormEntity
import java.time.LocalDate
import java.time.LocalDateTime

class TPerson implements GormEntity, MultiTenant<TPerson> {
LocalDateTime dateCreated

String firstname
String lastname
LocalDate birthdate

String address
String city
String postCode
String state
String country

TCompany company
static belongsTo = [
company: TCompany,
]

static constraints = {
address nullable: true
city nullable: true
postCode nullable: true
state nullable: true
country nullable: true
}
}

We’ve associated the two domain classes as follow:

  • TPerson belongs to a TCompany
    It means that each record of a person will have a column (company_id) referencing its company.
  • TCompany has many TPerson
    It means that each company will be able to reference its employees navigating through them

To have a better understanding of the code above please refer to the GORM Hibernate documentation.

The Services

The CompanyService will hold the logic to query and operate with the TCompany domain object. It is almost the same as the PersonService, in fact I’ve just duplicated it replacing TPerson with TCompany and added some more filters to the main query.

Create the class ~/demo/grails-app/services/com/example/CompanyService.groovy

package com.example

import dueuno.elements.exceptions.ArgsException
import grails.gorm.DetachedCriteria
import grails.gorm.multitenancy.CurrentTenant

import javax.annotation.PostConstruct

@CurrentTenant
class CompanyService {

@PostConstruct
void init() {
// Executes only once when the application starts
}

private DetachedCriteria<TCompany> buildQuery(Map filterParams) {
def query = TCompany.where {}

if (filterParams.containsKey('id')) query = query.where { id == filterParams.id }

if (filterParams.find) {
String search = filterParams.find.replaceAll('\\*', '%')
query = query.where { 1 == 1
|| name =~ "%${search}%"
}
}

// Add additional filters here

return query
}

TCompany get(Serializable id) {
// Add any relationships here (Eg. references to other DomainObjects or hasMany)
Map fetch = [
relationshipName: 'join',
]

return buildQuery(id: id).get(fetch: fetch)
}

List<TCompany> list(Map filterParams = [:], Map fetchParams = [:]) {
if (!fetchParams.sort) fetchParams.sort = [dateCreated: 'asc']

// Add single-sided relationships here (Eg. references to other DomainObjects)
// DO NOT add hasMany relationships, you are going to have troubles with pagination
fetchParams.fetch = [
relationshipName: 'join',
]

def query = buildQuery(filterParams)
return query.list(fetchParams)
}

Integer count(Map filterParams = [:]) {
def query = buildQuery(filterParams)
return query.count()
}

TCompany create(Map args = [:]) {
if (args.failOnError == null) args.failOnError = false

TCompany obj = new TCompany(args)
obj.save(flush: true, failOnError: args.failOnError)
return obj
}

TCompany update(Map args = [:]) {
Serializable id = ArgsException.requireArgument(args, 'id')
if (args.failOnError == null) args.failOnError = false

TCompany obj = get(id)
obj.properties = args
obj.save(flush: true, failOnError: args.failOnError)
return obj
}

void delete(Serializable id) {
TCompany obj = get(id)
obj.delete(flush: true, failOnError: true)
}
}

Create the class ~/demo/grails-app/services/com/example/PersonService.groovy

package com.example

import dueuno.elements.exceptions.ArgsException
import grails.gorm.DetachedCriteria
import grails.gorm.multitenancy.CurrentTenant
import javax.annotation.PostConstruct

@CurrentTenant
class PersonService {

@PostConstruct
void init() {
// Executes only once when the application starts
}

private DetachedCriteria<TPerson> buildQuery(Map filterParams) {
def query = TPerson.where {}

if (filterParams.containsKey('id')) query = query.where { id == filterParams.id }
if (filterParams.containsKey('lastname')) query = query.where { lastname == filterParams.lastname }
if (filterParams.containsKey('birthdate')) query = query.where { birthdate == filterParams.birthdate }
if (filterParams.containsKey('company')) query = query.where { company.id == filterParams.company }

if (filterParams.find) {
String search = filterParams.find.replaceAll('\\*', '%')
query = query.where { 1 == 1
|| firstname =~ "%${search}%"
|| lastname =~ "%${search}%"
}
}

// Add additional filters here

return query
}

TPerson get(Serializable id) {
// Add any relationships here (Eg. references to other DomainObjects or hasMany)
Map fetch = [
company: 'join',
]

return buildQuery(id: id).get(fetch: fetch)
}

List<TPerson> list(Map filterParams = [:], Map fetchParams = [:]) {
if (!fetchParams.sort) fetchParams.sort = [dateCreated: 'asc']

// Add single-sided relationships here (Eg. references to other DomainObjects)
// DO NOT add hasMany relationships, you are going to have troubles with pagination
fetchParams.fetch = [
company: 'join',
]

def query = buildQuery(filterParams)
return query.list(fetchParams)
}

Integer count(Map filterParams = [:]) {
def query = buildQuery(filterParams)
return query.count()
}

TPerson create(Map args = [:]) {
if (args.failOnError == null) args.failOnError = false

TPerson obj = new TPerson(args)
obj.save(flush: true, failOnError: args.failOnError)
return obj
}

TPerson update(Map args = [:]) {
Serializable id = ArgsException.requireArgument(args, 'id')
if (args.failOnError == null) args.failOnError = false

TPerson obj = get(id)
obj.properties = args
obj.save(flush: true, failOnError: args.failOnError)
return obj
}

void delete(Serializable id) {
TPerson obj = get(id)
obj.delete(flush: true, failOnError: true)
}
}

The Controllers

The CompanyController edit() action will display the name of the company and a list of its employees. To do that we need to add a Table component to the Content.

The CompanyController is basically the same as the PersonController, in fact I’ve just duplicated it replacing TPerson with TCompany, adding a reference to theCompanyService (injected by Grails) and changing the buildForm() method to add theTable.

Create the class ~/demo/grails-app/controllers/com/example/CompanyController.groovy

package com.example

import dueuno.elements.components.Table
import dueuno.elements.contents.ContentCreate
import dueuno.elements.contents.ContentEdit
import dueuno.elements.contents.ContentList
import dueuno.elements.controls.TextField
import dueuno.elements.core.ElementsController
import dueuno.elements.style.TextDefault

class CompanyController implements ElementsController {

PersonService personService
CompanyService companyService

def index() {
def c = createContent(ContentList)
c.table.with {
filters.with {
addField(
class: TextField,
id: 'find',
label: TextDefault.FIND,
)
}
sortable = [
name: 'asc',
]
columns = [
'name',
]
}

c.table.body = companyService.list(c.table.filterParams, c.table.fetchParams)
c.table.paginate = companyService.count(c.table.filterParams)

display content: c
}

private buildForm(TCompany obj = null) {
def c = obj
? createContent(ContentEdit)
: createContent(ContentCreate)

c.form.with {
validate = TCompany
addField(
class: TextField,
id: 'name',
)
}

if (obj) {
c.form.values = obj

def table = c.addComponent(Table)
table.with {
rowActions = false
rowHighlight = false
columns = [
'firstname',
'lastname',
'country',
]
body = personService.list(company: obj.id)
}
}

return c
}

def create() {
def c = buildForm()
display content: c, modal: true
}

def onCreate() {
def obj = companyService.create(params)

if (obj.hasErrors()) {
display errors: obj
return
}

display action: 'index'
}

def edit() {
def obj = companyService.get(params.id)
def c = buildForm(obj)
display content: c, modal: true
}

def onEdit() {
def obj = companyService.update(params)
if (obj.hasErrors()) {
display errors: obj
return
}

display action: 'index'
}

def onDelete() {
try {
companyService.delete(params.id)
display action: 'index'

} catch (e) {
display exception: e
}
}
}

We need to add the company field to the PersonController table and form as well.

To be able to actually see something meaningful in the Select control listing all the companies, we need to register a PrettyPrinter. This is a templating mechanism we use to render a domain object as a String. We are going to register it in the next paragraph along with the new feature.

Edit ~/demo/grails-app/controllers/com.example/PersonController.groovy

package com.example

import dueuno.elements.components.Separator
import dueuno.elements.contents.ContentCreate
import dueuno.elements.contents.ContentEdit
import dueuno.elements.contents.ContentList
import dueuno.elements.controls.DateField
import dueuno.elements.controls.Select
import dueuno.elements.controls.TextField
import dueuno.elements.core.ElementsController
import dueuno.elements.style.TextDefault

class PersonController implements ElementsController {

PersonService personService
CompanyService companyService

def index() {
def c = createContent(ContentList)
c.table.with {
filters.with {
addField(
class: DateField,
id: 'birthdate',
cols: 3,
)
addField(
class: TextField,
id: 'find',
label: TextDefault.FIND,
cols: 9,
)
}
sortable = [
lastname: 'asc',
]
columns = [
'company',
'firstname',
'lastname',
'birthdate',
'address',
'city',
'postCode',
'state',
'country',
]
}

c.table.body = personService.list(c.table.filterParams, c.table.fetchParams)
c.table.paginate = personService.count(c.table.filterParams)

display content: c
}

private buildForm(TPerson obj = null) {
def c = obj
? createContent(ContentEdit)
: createContent(ContentCreate)

c.form.with {
validate = TPerson
addField(
class: Select,
id: 'company',
optionsFromRecordset: companyService.list(),
cols: 12,
)
addField(
class: TextField,
id: 'firstname',
cols: 6,
)
addField(
class: TextField,
id: 'lastname',
cols: 6,
)
addField(
class: DateField,
id: 'birthdate',
cols: 6,
)

addField(
class: Separator,
id: 's1',
icon: 'fa-earth-americas',
cols: 12,
)
addField(
class: TextField,
id: 'address',
cols: 12,
)
addField(
class: TextField,
id: 'city',
cols: 6,
)
addField(
class: TextField,
id: 'postCode',
cols: 6,
)
addField(
class: TextField,
id: 'state',
cols: 6,
)
addField(
class: TextField,
id: 'country',
cols: 6,
)
}

if (obj) {
c.form.values = obj
}

return c
}

def create() {
def c = buildForm()
display content: c, modal: true
}

def onCreate() {
def obj = personService.create(params)

if (obj.hasErrors()) {
display errors: obj
return
}

display action: 'index'
}

def edit() {
def obj = personService.get(params.id)
def c = buildForm(obj)
display content: c, modal: true
}

def onEdit() {
def obj = personService.update(params)
if (obj.hasErrors()) {
display errors: obj
return
}

display action: 'index'
}

def onDelete() {
try {
personService.delete(params.id)
display action: 'index'

} catch (e) {
display exception: e
}
}
}

The Features

We need to let the users access the newly created CompanyController. We do so by registering a new feature. Since we are here, we are going to mock-up a couple of companies too, so we can test the application.

Edit ~/demo/grails-app/init/com/example/BootStrap.groovy

package com.example

import dueuno.elements.core.ApplicationService
import dueuno.elements.tenants.TenantPropertyService
import java.time.LocalDate

class BootStrap {

ApplicationService applicationService
TenantPropertyService tenantPropertyService
PersonService personService
CompanyService companyService

def init = { servletContext ->

applicationService.onInstall { String tenantId ->
tenantPropertyService.setString('PRIMARY_BACKGROUND_COLOR', '#018B84')
tenantPropertyService.setNumber('PRIMARY_BACKGROUND_COLOR_ALPHA', 0.25)
tenantPropertyService.setString('LOGIN_COPY', '2024 &copy; <a href="https://my-company.com" target="_blank">My Company</a><br/>Made in Italy')
}

applicationService.onDevInstall { String tenantId ->

def yourCompany = companyService.create(name: 'Your Company', failOnError: true)
def theirCompany = companyService.create(name: 'Their Company', failOnError: true)

personService.create(
company: yourCompany,
firstname: 'Felicity',
lastname: 'Green',
birthdate: LocalDate.of(2021, 1, 2),
failOnError: true,
)
personService.create(
company: yourCompany,
firstname: 'Grace',
lastname: 'Blue',
birthdate: LocalDate.of(2021, 2, 1),
failOnError: true,
)
personService.create(
company: theirCompany,
firstname: 'Joy',
lastname: 'Red',
birthdate: LocalDate.of(2021, 12, 21),
failOnError: true,
)
}

applicationService.init {
registerPrettyPrinter(TCompany, '${it.name}')
registerFeature(
controller: 'person',
icon: 'fa-user',
favourite: true,
)
registerFeature(
controller: 'company',
icon: 'fa-briefcase',
)
}
}

def destroy = {
}
}

The registerPrettyPrinter() call configures a renderer for the TCompany objects. In the string template (see Groovy String Template Engines) we can reference any TCompany class property. The it symbol will references an instance of aTCompany object.

It’s Showtime

Delete the ~/demo/demo folder

Execute the application ./gradlew bootRun

Conclusions

Today we have seen a basic GUI handling a One-to-Many relationship. This is not the only way.

In the next article we are going to see another way to render a One-to-Many relationship using the Dueuno Elements Table Actions.

--

--

Gianluca Sartori

Author of Dueuno Elements (dueuno.org). Write backoffice web applications with one single programming language: Apache Groovy.