At the end of part 2 the customer form worked and contained everything: e-mail rules, SQL strings, quote escaping, a number range, a duplicate query. All of it inside a form component, reachable only by a human clicking buttons.
This part does three things:
- Move the rules into a service (
CUSTOMER_SVC). - Write a test service (
CUSTOMER_TST_SVC) that exercises them automatically and leaves the database untouched. - Fix the bug that the refactoring exposed -
0129 - Error on field CUSTOMER_ID; subfield(s) are required- which took two attempts, because the first fix only moved it.
Why a service, concretely
A Uniface service is a component with no user interface, called with activate:
activate "CUSTOMER_SVC".VALIDATE(vLast, vFirst, vEmail, vPhone, vField, vError)
Parameters are typed and directional (IN, OUT, INOUT), and $status carries the return value. That is enough to make the rules callable from anything - a second form, a batch job, and most importantly a test.
The split I ended up with:
| Operation | Parameters | Responsibility |
|---|---|---|
VALIDATE |
last/first/email/phone INOUT, pField OUT, pError OUT
|
trim the values, enforce mandatory fields, check the e-mail |
BUILD_SEARCH_WHERE |
pText IN, pWhere OUT
|
build (and escape) the SQL WHERE clause |
NEXT_ID |
pId OUT, pError OUT
|
take the next number from the number range |
COUNT_DUPLICATES |
id/name/e-mail IN, pCount OUT, pError OUT
|
count possible duplicates |
plus three private entries: CHECK_EMAIL, TRIM_TEXT, SQL_ESCAPE.
Note that VALIDATE has INOUT name parameters. It does not only judge the input, it normalises it - the caller gets the trimmed values back and writes them into the fields. One rule, one place.
Two things that will stop you when creating a service
1017 - Compiling a component without entities is not allowed.
A service needs at least one entity, even when it never touches data. Paint one non-database dummy entity in it:
SVC_DMY.NOMODEL
You get a 1016 warning for it on every compile ("not found in application model, generating now..."), which is expected.
Component names are limited to 16 characters. CUSTOMER_TEST_SVC (17) was rejected with a distinctly 4GL error message:
subfield too large
Hence CUSTOMER_TST_SVC.
The validation, in full
This is the part I most wanted under test, because e-mail checking is where everyone writes a regex they later regret. Uniface has no regex in the base language, so it is string functions - which turns out to be easy to read and easy to test:
public operation VALIDATE
params
string pLastName : INOUT
string pFirstName : INOUT
string pEmail : INOUT
string pPhone : INOUT
string pField : OUT
string pError : OUT
endparams
pField = ""
pError = ""
call TRIM_TEXT(pLastName)
call TRIM_TEXT(pFirstName)
call TRIM_TEXT(pEmail)
call TRIM_TEXT(pPhone)
if (pLastName = "")
pField = "LAST_NAME"
pError = "Please enter a last name."
return -1
endif
if (pFirstName = "")
pField = "FIRST_NAME"
pError = "Please enter a first name."
return -1
endif
if (pEmail != "")
call CHECK_EMAIL(pEmail, pError)
if ($status < 0)
pField = "EMAIL"
return -1
endif
endif
return 0
end
entry CHECK_EMAIL
params
string pEmail : IN
string pError : OUT
endparams
variables
numeric vAt, vLen, vFrom, vCnt, vDot
string vDomain
endvariables
pError = "The e-mail address is not valid. Please use the format name@domain.tld."
vAt = $scan(pEmail, "@")
if (vAt = 0)
pError = "The e-mail address must contain an @ character."
return -1
endif
if ($scan(pEmail, " ") > 0)
return -1
endif
vLen = $length(pEmail)
if (vAt = 1 | vAt = vLen)
return -1
endif
vFrom = vAt + 1
vCnt = vLen - vAt
vDomain = pEmail[vFrom:vCnt]
if ($scan(vDomain, "@") > 0)
return -1
endif
vDot = $scan(vDomain, ".")
if (vDot < 2)
return -1
endif
if (vDomain[vCnt:1] = ".")
return -1
endif
if ($scan(vDomain, "..") > 0)
return -1
endif
pError = ""
return 0
end
entry TRIM_TEXT
params
string pText : INOUT
endparams
if (pText != "")
pText = $rtrim($ltrim(pText, " "), " ")
endif
return 0
end
ProcScript notes for anyone coming from another language:
-
$scan(haystack, needle)returns a 1-based position,0for "not found". -
vString[vFrom:vCnt]is the substring syntax: start position and count, both 1-based. -
$ltrim/$rtrimtake the character to strip as the second argument. -
|is "or",&is "and",!is "not". - The requirement was only "the e-mail must contain an @". The extra rules (no spaces,
@not first or last, exactly one@, a dot inside the domain that is not the first or last character, no..) are cheap once the check lives in one testable place - and the error message stays specific for the common case.
The test service
There is no xUnit here. The pattern is simple and works surprisingly well: a service whose exec operation runs everything, an assertion entry, and putmess for output.
public operation exec
activate $instancename.RUN_ALL()
return $status
end
public operation RUN_ALL
variables
numeric vTests, vFailures
endvariables
vTests = 0
vFailures = 0
putmess "Customer service tests started"
call TEST_VALIDATE(vTests, vFailures)
call TEST_SEARCH(vTests, vFailures)
call TEST_NEXT_ID(vTests, vFailures)
call TEST_DUPLICATES(vTests, vFailures)
rollback
putmess $concat("Customer service: ", vTests, " tests, ", vFailures, " failures")
if (vFailures > 0)
return -1
endif
return 0
end
entry CHECK
params
string pName : IN
boolean pOk : IN
string pDetail : IN
numeric pTests : INOUT
numeric pFailures : INOUT
endparams
pTests = pTests + 1
if (pOk)
putmess $concat("PASS: ", pName)
else
pFailures = pFailures + 1
putmess $concat("FAIL: ", pName, " (", pDetail, ")")
endif
return 0
end
putmess writes to the IDE log file, log\ide_<pid>.log. You start the whole suite with Compile & Test on the service and then read the tail of that file. The line you are looking for:
Customer service: 30 tests, 0 failures
A table-driven test case
Most validation tests differ only in their input, so one parameterised entry removes all the noise:
entry VALIDATE_CASE
params
string pName : IN
string pLast : IN
string pFirst : IN
string pEmail : IN
numeric pExpected : IN
string pExpectedField : IN
numeric pTests : INOUT
numeric pFailures : INOUT
endparams
variables
string vPhone, vField, vError, vDetail
numeric vStatus
boolean vOk
endvariables
vPhone = ""
activate "CUSTOMER_SVC".VALIDATE(pLast, pFirst, pEmail, vPhone, vField, vError)
vStatus = $status
vOk = (vStatus = pExpected & vField = pExpectedField)
vDetail = "status %%(vStatus), field %%(vField), %%(vError)"
call CHECK(pName, vOk, vDetail, pTests, pFailures)
return 0
end
which makes the actual test list readable:
entry TEST_VALIDATE
params
numeric pTests : INOUT
numeric pFailures : INOUT
endparams
call VALIDATE_CASE("VALIDATE valid customer", "Muster", "Max", "max@example.com", 0, "", pTests, pFailures)
call VALIDATE_CASE("VALIDATE empty e-mail allowed", "Muster", "Max", "", 0, "", pTests, pFailures)
call VALIDATE_CASE("VALIDATE subdomain e-mail", "Muster", "Max", "a.b@mail.example.com", 0, "", pTests, pFailures)
call VALIDATE_CASE("VALIDATE missing last name", "", "Max", "max@example.com", -1, "LAST_NAME", pTests, pFailures)
call VALIDATE_CASE("VALIDATE missing first name", "Muster", "", "max@example.com", -1, "FIRST_NAME", pTests, pFailures)
call VALIDATE_CASE("VALIDATE e-mail without @", "Muster", "Max", "maxexample.com", -1, "EMAIL", pTests, pFailures)
call VALIDATE_CASE("VALIDATE e-mail with space", "Muster", "Max", "max @example.com", -1, "EMAIL", pTests, pFailures)
call VALIDATE_CASE("VALIDATE two @ characters", "Muster", "Max", "a@b@example.com", -1, "EMAIL", pTests, pFailures)
call VALIDATE_CASE("VALIDATE domain without dot", "Muster", "Max", "max@example", -1, "EMAIL", pTests, pFailures)
return 0
end
Assert on the field name too, not just on the status: that is what proves the cursor will land in the right box.
Testing the escaping
The one test that actually catches an injection-shaped bug:
activate "CUSTOMER_SVC".BUILD_SEARCH_WHERE("O'Neil", vWhere)
call CHECK("SEARCH escapes single quotes", ($scan(vWhere, "'%O''Neil%'") > 0), vWhere, pTests, pFailures)
Testing things that write to the database
NEXT_ID increments a counter and COUNT_DUPLICATES needs a row to find. Both are tested for real - against the real database - and then undone:
-
TEST_NEXT_IDreadsLAST_ID, callsNEXT_IDtwice, asserts the two numbers differ by one and that the counter moved. -
TEST_DUPLICATESinserts a customer999999withsql, then asserts that the same name is found, that a different name is not, that the check is case-insensitive, and that passing the record's own ID excludes it. -
RUN_ALLends with a singlerollback.
After a full run, the customer table and the number range are exactly as they were. That is the property that makes it acceptable to run the suite against a development database whenever you like.
The bug the refactoring exposed
Everything compiled, the suite reported 30 tests, 0 failures, and the form passed a manual click-through. Then one specific sequence broke:
- Click New.
- Without typing anything, click a row in the list.
0129 - Error on field CUSTOMER_ID; subfield(s) are required.
No dialog, just a message in the status line, and the focus stuck in the ID field. The "unsaved changes" prompt from part 2 never appeared.
Why
Since part 2, the customer number is taken from the number range inside DO_SAVE. So a freshly created occurrence has an empty primary key until it is saved. Uniface validates the occurrence when focus leaves it - and the key field is not allowed to be empty. That check runs before my getFocus trigger, so no amount of ProcScript in SELECT_ROW can get in front of it.
Fix attempt 1: assign the number when the record is created
entry DO_NEW
variables
string vError
numeric vNewId
endvariables
if ($occdbmod(CUSTOMER) = 1)
askmess/question "The current customer has unsaved changes. Do you want to discard them?~New customer", "Yes,No"
if ($status != 1)
return 0
endif
call LOAD_LIST
endif
activate "CUSTOMER_SVC".NEXT_ID(vNewId, vError)
if ($status < 0)
rollback
message/error $concat(vError, " No new customer could be created.")
return -1
endif
commit
creocc "CUSTOMER", -1
creocc "LIST_DMY", -1
setocc "LIST_DMY", $curocc(CUSTOMER)
CUSTOMER_ID.CUSTOMER = vNewId
$prompt = LAST_NAME.CUSTOMER
return 0
end
The number is fetched before the occurrence is created, so a failure cannot leave a half-built record behind. The user now sees their customer number immediately, which is arguably better UX anyway. The price: abandoned "New" clicks leave gaps in the number range - normal for a number range.
This broke one thing elsewhere. DO_SAVE decided "is this a new record?" like this:
vIsNew = (CUSTOMER_ID.CUSTOMER = "")
which is now always false. The honest test is whether the occurrence exists in the database:
vIsNew = ($dbocc(CUSTOMER) = 0)
NEXT_ID stays in DO_SAVE as a fallback for a record that somehow has no number, and the error path no longer clears the ID (it is already reserved), only the timestamp.
And then the same error moved one field to the right
0129 - Error on field LAST_NAME; subfield(s) are required.
Same mechanism, different cause: LAST_NAME and FIRST_NAME had the field syntax MAN (mandatory) in the application model. Uniface enforces MAN when the occurrence is left - again before my code, again with a message that means nothing to a user.
Since the mandatory-field rule now lives in CUSTOMER_SVC.VALIDATE, with a message that actually tells you what to do ("Please enter a last name."), MAN was removed and the syntax set to LEN(0-50). Saving still requires both names; you just find out in a sentence instead of an error number.
After that, the sequence behaves:
- New + click a row -> the Save / Discard / Stay prompt.
- Choosing Save on an empty record -> "Please enter a last name. The customer was not saved.", cursor in the last name field, selection unchanged.
- Choosing Discard -> the clicked customer is loaded, the empty one is gone.
The general lesson
Two rules were being enforced in two places with two different qualities of message. The model-level checks (MAN, key completeness) fire earlier than any trigger, so whoever wins that race owns the user experience - and the platform always wins. Once a rule lives in a service with a good message, the duplicate in the model is not a safety net, it is a competitor.
It is also a good argument for the kind of test that clicks: the service suite was green the entire time this bug existed, because the bug lived exactly in the part that has no tests. Automated ProcScript tests cover the rules; a written click-through list covers the form.
What the whole thing looks like now
CUSTOMER_FRM CUSTOMER_SVC CUSTOMER_TST_SVC
exec, quit VALIDATE exec -> RUN_ALL
LOAD_LIST / FILL_LIST BUILD_SEARCH_WHERE CHECK
UPDATE_COUNT / SELECT_BY_ID NEXT_ID VALIDATE_CASE
SELECT_ROW / SORT_LIST COUNT_DUPLICATES TEST_VALIDATE
DO_SEARCH / DO_NEW TEST_SEARCH
DO_SAVE / DO_DELETE CHECK_EMAIL TEST_NEXT_ID
DO_CANCEL / PROMPT_FIELD TRIM_TEXT / SQL_ESCAPE TEST_DUPLICATES
Compile output, for the record:
Compile Form: 'CUSTOMER_FRM'
warning: 1016 - (Fields for) entity SEARCH_DMY not found in application model, generating now...
warning: 1016 - (Fields for) entity LIST_DMY not found in application model, generating now...
warning: 1016 - (Fields for) entity ACTION_DMY not found in application model, generating now...
warning: 1076 - Path to painted entity CUSTOMER.CUSTOMER_MDL not found.
Compilation done: [info 2, warnings 4, errors 0]
The next step for this application would be address fields - and that is the moment the split pays off: the address rules go into the service next to the existing ones, the tests grow by a handful of cases, and the form only learns where to paint them.
Takeaways
- A service is the unit of testability in Uniface. Anything a form does that you would want to assert about belongs in one.
-
INOUTparameters let validation normalise, not just judge. Trim once, in the rule. - Return the field name with the error. It is what makes a message actionable.
-
putmess+ a counter is a perfectly good test framework when the platform does not ship one.rollbackat the end makes database tests repeatable. Model-level syntax rules fire before your triggers. If your service owns a rule, remove the duplicate from the model - otherwise the platform's error message wins the race.
In part 4 the application gets concurrency handling - and the platform turns out to have had it all along.Error codes worth memorising:
1017(service without an entity),0129(empty key orMANfield when leaving an occurrence),-300 UVALERR_SYNTAX(LEN(1-n)on an empty optional field),-2 UIOSERR_OCC_NOT_FOUND(throwsin the generatedreadtrigger).
Top comments (0)