Dueuno Elements #7 — One-to-Many Relationships and Where to Find Them
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 aTCompany
It means that each record of a person will have a column (company_id
) referencing its company.TCompany
has manyTPerson
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 © <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
folderExecute 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.