<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom" xmlns:dc="http://purl.org/dc/elements/1.1/">
  <channel>
    <title>DEV Community: Franz</title>
    <description>The latest articles on DEV Community by Franz (@f345345dfg).</description>
    <link>https://dev.to/f345345dfg</link>
    <image>
      <url>https://media2.dev.to/dynamic/image/width=90,height=90,fit=cover,gravity=auto,format=auto/https:%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Fuser%2Fprofile_image%2F3671581%2F5f7f5670-e10e-4728-861f-ef8b79ab638c.png</url>
      <title>DEV Community: Franz</title>
      <link>https://dev.to/f345345dfg</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/f345345dfg"/>
    <language>en</language>
    <item>
      <title>The Uniface test that "hung" had died - finding a silent crash with SQLite</title>
      <dc:creator>Franz</dc:creator>
      <pubDate>Sat, 17 Oct 2026 09:25:17 +0000</pubDate>
      <link>https://dev.to/f345345dfg/the-uniface-test-that-hung-had-died-finding-a-silent-crash-with-sqlite-1mn2</link>
      <guid>https://dev.to/f345345dfg/the-uniface-test-that-hung-had-died-finding-a-silent-crash-with-sqlite-1mn2</guid>
      <description>&lt;p&gt;This is a side story from the series &lt;em&gt;Building a customer app in Uniface 10&lt;/em&gt;. It stands on its own: you only need to know that the app is a Uniface 10 desktop application on a SQLite database, and that every service has a test service written in ProcScript.&lt;/p&gt;

&lt;p&gt;The short version: a test run stopped somewhere in the middle. It looked like a hang. It was a crash, and the log file pointed at the wrong place. The cause was a single &lt;code&gt;SELECT&lt;/code&gt; without a &lt;code&gt;FROM&lt;/code&gt; clause.&lt;/p&gt;

&lt;p&gt;Tested with Rocket Uniface 10.4.03 Community Edition (10.4.03.021) and the SQLite version of its SLE driver (3.50.2), October 2026.&lt;/p&gt;

&lt;h2&gt;
  
  
  The symptom
&lt;/h2&gt;

&lt;p&gt;The feature in question is an ABC classification of customers: points for recent activities and complete data, and two limits from the settings table that decide between class A, B and C. The limits are configurable, so they have to be validated - a setting &lt;code&gt;ABC_B = "abc"&lt;/code&gt; must not break the report.&lt;/p&gt;

&lt;p&gt;The test suite for it is a normal ProcScript test service. It logs one &lt;code&gt;PASS&lt;/code&gt; or &lt;code&gt;FAIL&lt;/code&gt; line per check with &lt;code&gt;putmess&lt;/code&gt;, prints a summary at the end and rolls back. Started with &lt;em&gt;Compile &amp;amp; Test&lt;/em&gt; in the IDE, the run never printed its summary. The log stopped after a &lt;code&gt;PASS&lt;/code&gt; line of the previous test group, and nothing came after it.&lt;/p&gt;

&lt;p&gt;Two more things were wrong afterwards:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;a test process was still listed by &lt;code&gt;tasklist /FI "IMAGENAME eq ide.exe"&lt;/code&gt; and had to be ended with &lt;code&gt;taskkill /PID &amp;lt;pid&amp;gt; /F&lt;/code&gt;,&lt;/li&gt;
&lt;li&gt;next to the database there was a &lt;code&gt;customers.db-journal&lt;/code&gt; file. SQLite rolls it back the next time the database is opened, so no data was lost - the tests always end with &lt;code&gt;rollback&lt;/code&gt; anyway - but it shows that the process stopped in the middle of a transaction.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  The wrong suspects
&lt;/h2&gt;

&lt;p&gt;Because it looked like a hang, I first looked for something slow or blocking. Two candidates were in the test data setup of that group:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;the test activities were inserted with one &lt;code&gt;INSERT&lt;/code&gt; statement built on a common table expression (&lt;code&gt;WITH ...&lt;/code&gt;),&lt;/li&gt;
&lt;li&gt;the classification query had grown into one large statement.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;I replaced the CTE insert with a loop over a small helper entry and rewrote the query with &lt;code&gt;LEFT JOIN&lt;/code&gt;s on grouped subqueries. Both changes were fine on their own. Neither changed anything: the run still stopped at the same &lt;code&gt;PASS&lt;/code&gt; line.&lt;/p&gt;

&lt;p&gt;That was the moment to stop guessing.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why the log pointed at the wrong place
&lt;/h2&gt;

&lt;p&gt;The log in the IDE's log folder is written in blocks, not line by line. When the process ends normally, everything is flushed. When it dies, the last block is lost. So the last line in the log was not the last line that ran - it was the last line that happened to be written before the crash. Everything after it, possibly several test groups, was simply missing.&lt;/p&gt;

&lt;p&gt;The lesson is general: &lt;strong&gt;if a process dies, its buffered output lies by omission.&lt;/strong&gt; The last thing you see is not the last thing that happened.&lt;/p&gt;

&lt;h2&gt;
  
  
  A trace that cannot be lost
&lt;/h2&gt;

&lt;p&gt;The fix for the diagnosis was a trace that writes every line to disk immediately. In ProcScript, &lt;code&gt;lfiledump/append&lt;/code&gt; does exactly that - it opens the file, appends, and closes it again:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;entry TRACE
params
    string pText : IN
endparams
    lfiledump/append "%%(pText)%%^", "C:/Projects/customers/export/test/trace.txt"
    return 0
end
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;(The path is shortened here.) Then &lt;code&gt;call TRACE("...")&lt;/code&gt; between the steps of the test - and, once the test steps were narrowed down, inside the service under test as well:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight abap"&gt;&lt;code&gt;    &lt;span class="k"&gt;call&lt;/span&gt; &lt;span class="n"&gt;TRACE&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="c1"&gt;"ABC step 3 activities")&lt;/span&gt;
    &lt;span class="n"&gt;activate&lt;/span&gt; &lt;span class="c1"&gt;"REPORT3_SVC".ABC_CLASS(999831, vClass, vPoints, vError)&lt;/span&gt;
    &lt;span class="k"&gt;call&lt;/span&gt; &lt;span class="n"&gt;TRACE&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="c1"&gt;"ABC step 4 first class")&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This is slow and ugly, and it is only meant to live for a few runs. But it survives a crash. The trace of the second run ended like this:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;ABC step 3 activities
R3 class enter
R3 thresholds enter
R3 thresholds settings 70 40
R3 thresholds before sql
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;So the process died inside the entry that reads and checks the two limits, right at its &lt;code&gt;sql&lt;/code&gt; statement - and with the default values 70 and 40, not with a broken setting.&lt;/p&gt;

&lt;h2&gt;
  
  
  The statement
&lt;/h2&gt;

&lt;p&gt;Here is the entry as it was:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight openedge"&gt;&lt;code&gt;&lt;span class="k"&gt;entry&lt;/span&gt; &lt;span class="nv"&gt;THRESHOLDS&lt;/span&gt;
&lt;span class="nv"&gt;params&lt;/span&gt;
    &lt;span class="k"&gt;string&lt;/span&gt; &lt;span class="nv"&gt;pA&lt;/span&gt; &lt;span class="o"&gt;:&lt;/span&gt; &lt;span class="nv"&gt;OUT&lt;/span&gt;
    &lt;span class="k"&gt;string&lt;/span&gt; &lt;span class="nv"&gt;pB&lt;/span&gt; &lt;span class="o"&gt;:&lt;/span&gt; &lt;span class="nv"&gt;OUT&lt;/span&gt;
&lt;span class="nv"&gt;endparams&lt;/span&gt;
&lt;span class="nv"&gt;variables&lt;/span&gt;
    &lt;span class="k"&gt;string&lt;/span&gt; &lt;span class="nv"&gt;vA&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;vB&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;vSql&lt;/span&gt;
&lt;span class="nv"&gt;endvariables&lt;/span&gt;
    &lt;span class="nv"&gt;activate&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;SETTINGS_SVC&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;GET_SETTING&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;ABC_A&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;70&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;vA&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="nv"&gt;activate&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;SETTINGS_SVC&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;GET_SETTING&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;ABC_B&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;40&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;vB&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="k"&gt;call&lt;/span&gt; &lt;span class="nf"&gt;SQL_ESCAPE&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;vA&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="k"&gt;call&lt;/span&gt; &lt;span class="nf"&gt;SQL_ESCAPE&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;vB&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="nv"&gt;vSql&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;SELECT CASE WHEN '%%(vA)' &amp;lt;&amp;gt; '' AND '%%(vA)' NOT GLOB '*[^0-9]*' AND '%%(vB)' &amp;lt;&amp;gt; '' AND '%%(vB)' NOT GLOB '*[^0-9]*' AND CAST('%%(vA)' AS INTEGER) BETWEEN 2 AND 100 AND CAST('%%(vB)' AS INTEGER) BETWEEN 1 AND CAST('%%(vA)' AS INTEGER) - 1 THEN 1 ELSE 0 END&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;
    &lt;span class="k"&gt;sql&lt;/span&gt; &lt;span class="nv"&gt;vSql&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;CUSTOMERS&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;
    &lt;span class="nf"&gt;if&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="err"&gt;$&lt;/span&gt;&lt;span class="k"&gt;status&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;=&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt; &lt;span class="nv"&gt;&amp;amp;&lt;/span&gt; &lt;span class="err"&gt;$&lt;/span&gt;&lt;span class="k"&gt;result&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
        &lt;span class="nv"&gt;pA&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nv"&gt;vA&lt;/span&gt;
        &lt;span class="nv"&gt;pB&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nv"&gt;vB&lt;/span&gt;
    &lt;span class="k"&gt;else&lt;/span&gt;
        &lt;span class="nv"&gt;pA&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;70&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;
        &lt;span class="nv"&gt;pB&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;40&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;
    &lt;span class="nv"&gt;endif&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;
&lt;span class="k"&gt;end&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The idea was to let SQLite do the validation in one go: digits only, A between 2 and 100, B below A. As plain SQL, the statement is valid - run through Python's &lt;code&gt;sqlite3&lt;/code&gt; module, it returns 1 for "70"/"40" and 0 for "70"/"abc", as intended. Through the &lt;code&gt;sql&lt;/code&gt; statement of ProcScript and the SLE driver, it ended the process. No error status, no message, no log line.&lt;/p&gt;

&lt;p&gt;What I did &lt;strong&gt;not&lt;/strong&gt; do is isolate which part of it is responsible. The application runs other &lt;code&gt;SELECT&lt;/code&gt;s without a &lt;code&gt;FROM&lt;/code&gt; all the time, for example this one for the current time in milliseconds:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight sql"&gt;&lt;code&gt;    &lt;span class="k"&gt;sql&lt;/span&gt; &lt;span class="nv"&gt;"SELECT CAST((julianday('now') - 2440587.5) * 86400000 AS INTEGER)"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;"CUSTOMERS"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;So "no &lt;code&gt;FROM&lt;/code&gt;" alone is not the problem, and neither is &lt;code&gt;CAST&lt;/code&gt; alone. The candidates left are the &lt;code&gt;CASE&lt;/code&gt; expression, the &lt;code&gt;GLOB&lt;/code&gt; with a character class, or the combination. If you know the SLE driver well enough to explain it, I would like to read that in the comments.&lt;/p&gt;

&lt;h2&gt;
  
  
  The fix: validation belongs in ProcScript
&lt;/h2&gt;

&lt;p&gt;Instead of hunting for the exact trigger, I moved the validation to where it belongs anyway - into the code:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;entry THRESHOLDS
params
    string pA : OUT
    string pB : OUT
endparams
variables
    string vA, vB
    numeric vNumA, vNumB
    boolean vOkA, vOkB
endvariables
    pA = "70"
    pB = "40"
    activate "SETTINGS_SVC".GET_SETTING("ABC_A", "70", vA)
    activate "SETTINGS_SVC".GET_SETTING("ABC_B", "40", vB)
    call TRIM_TEXT(vA)
    call TRIM_TEXT(vB)
    call IS_INT(vA, vOkA)
    call IS_INT(vB, vOkB)
    if (vOkA &amp;amp; vOkB)
        vNumA = vA
        vNumB = vB
        if (vNumA &amp;gt;= 2 &amp;amp; vNumA &amp;lt;= 100 &amp;amp; vNumB &amp;gt;= 1 &amp;amp; vNumB &amp;lt; vNumA)
            pA = vA
            pB = vB
        endif
    endif
    return 0
end

entry IS_INT
params
    string pText : IN
    boolean pOk : OUT
endparams
variables
    numeric vI, vLen
endvariables
    pOk = 0
    vLen = $length(pText)
    if (vLen &amp;lt; 1 | vLen &amp;gt; 3)
        return 0
    endif
    vI = 1
    while (vI &amp;lt;= vLen)
        if ($scan("0123456789", pText[vI:1]) = 0)
            return 0
        endif
        vI = vI + 1
    endwhile
    pOk = 1
    return 0
end
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;It is longer, but every line can be read and tested on its own. The defaults are set first, so every path that does not end in "valid" keeps 70 and 40. With this version the suite ran through - 61 tests - and the full run with all suites passed, with the database file unchanged afterwards.&lt;/p&gt;

&lt;p&gt;The trace calls were removed again. They had done their job.&lt;/p&gt;

&lt;h2&gt;
  
  
  Takeaways
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;A run that stops is not necessarily a hang.&lt;/strong&gt; Check whether the process is still alive before you look for something slow.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Buffered logs lose their tail when a process dies.&lt;/strong&gt; Don't trust the last line. For a crash, write a trace with &lt;code&gt;lfiledump/append&lt;/code&gt;, which appends and closes on every call.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Narrow down with the trace, then fix the cause you can prove.&lt;/strong&gt; I could prove "this statement ends the process". I could not prove which part of it. That was enough to fix it.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Keep validation in ProcScript and use SQL for data.&lt;/strong&gt; A clever one-line check in SQL saves ten lines of code - until the driver disagrees with it.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Look for leftovers.&lt;/strong&gt; A journal file next to the database and a lingering process are both signs of a crash, not of a slow query.&lt;/p&gt;

</description>
      <category>database</category>
      <category>debugging</category>
      <category>testing</category>
    </item>
    <item>
      <title>Six ProcScript surprises from 60 Uniface services</title>
      <dc:creator>Franz</dc:creator>
      <pubDate>Sat, 17 Oct 2026 09:23:28 +0000</pubDate>
      <link>https://dev.to/f345345dfg/six-procscript-surprises-from-60-uniface-services-1kpe</link>
      <guid>https://dev.to/f345345dfg/six-procscript-surprises-from-60-uniface-services-1kpe</guid>
      <description>&lt;p&gt;Over the last two rounds of the series &lt;em&gt;Building a customer app in Uniface 10&lt;/em&gt;, the app grew by about 60 service operations: pipelines, owners, contact rhythms, text templates, CSV import and export, statistics, notices, settings snapshots. Most of it was routine. These six things were not - each one cost a failed test or a wrong result before I understood it.&lt;/p&gt;

&lt;p&gt;None of them is in the obvious places of the documentation, and none of them is specific to this app. If you write ProcScript against SQLite, you will probably meet some of them.&lt;/p&gt;

&lt;p&gt;Tested with Rocket Uniface 10.4.03 Community Edition (10.4.03.021) and the SQLite version of its SLE driver (3.50.2), October 2026.&lt;/p&gt;

&lt;h2&gt;
  
  
  1. One variable as input and output
&lt;/h2&gt;

&lt;p&gt;The follow-up service creates the next follow-up of a repeating task and moves its due date to the next working day. The helper entry for that has an input and an output parameter:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight abap"&gt;&lt;code&gt;&lt;span class="k"&gt;entry&lt;/span&gt; &lt;span class="n"&gt;NEXT_WORKDAY_ISO&lt;/span&gt;
&lt;span class="n"&gt;params&lt;/span&gt;
    &lt;span class="k"&gt;string&lt;/span&gt; &lt;span class="n"&gt;pIso&lt;/span&gt; &lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="k"&gt;IN&lt;/span&gt;
    &lt;span class="k"&gt;string&lt;/span&gt; &lt;span class="n"&gt;pNext&lt;/span&gt; &lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="k"&gt;OUT&lt;/span&gt;
&lt;span class="n"&gt;endparams&lt;/span&gt;
&lt;span class="p"&gt;...&lt;/span&gt;
    &lt;span class="n"&gt;pNext&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="c1"&gt;""&lt;/span&gt;
    &lt;span class="k"&gt;call&lt;/span&gt; &lt;span class="n"&gt;REAL_DATE&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;pIso&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;vDay&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;vDay&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="c1"&gt;"" | vDay != pIso)&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;
    &lt;span class="k"&gt;endif&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The first version called it with the same variable on both sides, because "replace the date with the next working day" felt natural:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;    call NEXT_WORKDAY_ISO(vNext, vNext)
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The result was empty. The entry starts by clearing its output - a habit that makes every early &lt;code&gt;return&lt;/code&gt; safe - and with one variable on both sides, that also cleared the input. As far as I can tell, the parameters of a &lt;code&gt;call&lt;/code&gt; behave like references to the caller's variable here. A separate variable fixed it:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;    call NEXT_WORKDAY_ISO(vNext, vWork)
    vNext = vWork
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The same project has &lt;code&gt;call SQL_LIT(vText, vText)&lt;/code&gt; in several places, and that one works - because &lt;code&gt;SQL_LIT&lt;/code&gt; copies its input into a local variable before it writes the output. Whether such a call works depends on the order inside the entry, which is a bad thing to depend on. &lt;strong&gt;Use separate variables for IN and OUT.&lt;/strong&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  2. Blanks at the end don't survive the round trip
&lt;/h2&gt;

&lt;p&gt;A test for the e-mail normalization inserted a customer with the address &lt;code&gt;" Ann@Example.COM "&lt;/code&gt; - blanks on both sides on purpose. The dry run must not change it, so the test read it back and compared:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;    call GET_TEXT("SELECT EMAIL FROM CUSTOMER WHERE CUSTOMER_ID = 999876", vValue)
    call CHECK("...", (... &amp;amp; vValue = " Ann@Example.COM "), ...)
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The check failed. The value that came back was &lt;code&gt;" Ann@Example.COM"&lt;/code&gt; - the leading blank was still there, the trailing one was gone. I did not dig into whether it gets lost on the way in or on the way out; for the test it does not matter. The fixed check looks for what the dry run must preserve, the upper-case letters:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;    call CHECK("EMAILS the dry run counts customers and contacts", (vStatus = 0 &amp;amp; vCount = 2 &amp;amp; $scan(vValue, "Ann@Example.COM") &amp;gt; 0), ...)
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;If trailing blanks matter to you - for example because you want to prove that a clean-up removes them - check them in SQL, where they are still there: &lt;code&gt;SELECT COUNT(*) FROM CUSTOMER WHERE EMAIL &amp;lt;&amp;gt; TRIM(EMAIL)&lt;/code&gt;. &lt;strong&gt;Don't compare trailing blanks after reading a value into ProcScript.&lt;/strong&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  3. Two timestamps in a row are often the same
&lt;/h2&gt;

&lt;p&gt;The "recently opened customers" list keeps the last ten customers per user, ordered by the time they were opened. Opening two customers directly after each other - which a test does - gave both the same &lt;code&gt;OPENED_AT&lt;/code&gt;, down to the millisecond. The Windows system clock that SQLite's &lt;code&gt;'now'&lt;/code&gt; reads typically advances in steps of about 15 milliseconds, so "later" is not guaranteed to be "greater".&lt;/p&gt;

&lt;p&gt;The fix makes the database guarantee the order: the new timestamp is the current time, or one millisecond after the newest entry of that user, whichever is greater:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;    vSql = "INSERT OR REPLACE INTO APP_RECENT (USER_NAME, CUSTOMER_ID, OPENED_AT) SELECT '%%(vUser)', %%(pCustomerId), CASE WHEN m IS NULL OR n &amp;gt; m THEN n ELSE strftime('%Y-%m-%d %H:%M:%f', m, '+0.001 seconds') END FROM (SELECT strftime('%Y-%m-%d %H:%M:%f', 'now', 'localtime') AS n, (SELECT MAX(OPENED_AT) FROM APP_RECENT WHERE USER_NAME = '%%(vUser)') AS m)"
    sql vSql, "CUSTOMERS"
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;And every query that sorts by the timestamp gets a second sort key (&lt;code&gt;ORDER BY OPENED_AT DESC, CUSTOMER_ID DESC&lt;/code&gt;), so that even equal values have a fixed order. &lt;strong&gt;Never let the order of events depend on the clock alone.&lt;/strong&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  4. &lt;code&gt;lfiledump&lt;/code&gt; with "UTF-8" writes a BOM
&lt;/h2&gt;

&lt;p&gt;The CSV export writes its file with &lt;code&gt;lfiledump vText, pFileName, "UTF-8"&lt;/code&gt;. The first bytes of the file are &lt;code&gt;EF BB BF&lt;/code&gt; - a byte order mark. Excel likes that for CSV. Many other consumers don't: a JSON parser may refuse it, and a checksum file with a BOM in front of the hash is no longer in the format &lt;code&gt;sha256sum -c&lt;/code&gt; expects.&lt;/p&gt;

&lt;p&gt;There is a switch for it:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;    lfiledump/nobom pText, pFileName, "UTF-8"
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The JSON export and the checksum files use &lt;code&gt;/nobom&lt;/code&gt;, the CSV exports keep the BOM. &lt;strong&gt;Decide per file type, not once for the project.&lt;/strong&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  5. Hashing a file the way &lt;code&gt;sha256sum&lt;/code&gt; does
&lt;/h2&gt;

&lt;p&gt;The data export writes a checksum file next to each export, so that a copy can be verified later - also without Uniface. That only works if the hash is computed over the bytes of the file, not over a decoded text. Loading the file as raw data does that:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;entry FILE_HASH
params
    string pFileName : IN
    string pHash : OUT
    string pError : OUT
endparams
variables
    raw vData
endvariables
    pHash = ""
    pError = ""
    if ($lfileexists(pFileName) != 1)
        pError = $concat("The file ", pFileName, " does not exist.")
        return -1
    endif
    fileload/raw pFileName, vData
    if ($status &amp;lt; 0)
        pError = $concat("The file ", pFileName, " could not be read (error ", $procerror, ").")
        return -1
    endif
    pHash = $lowercase($encode("HEX", $encode("SHA256", vData)))
    return 0
end
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The checksum file then gets one line in the usual format - hash, two blanks, file name - written with &lt;code&gt;/nobom&lt;/code&gt;. I checked the result against &lt;code&gt;sha256sum&lt;/code&gt; with a test file that contains umlauts, which is where a text-based hash would differ: both gave the same value. &lt;code&gt;$lowercase&lt;/code&gt; is there because &lt;code&gt;sha256sum&lt;/code&gt; prints lower case. &lt;strong&gt;For hashes, load &lt;code&gt;/raw&lt;/code&gt; into a &lt;code&gt;raw&lt;/code&gt; variable.&lt;/strong&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  6. One SQL statement can end the process
&lt;/h2&gt;

&lt;p&gt;This one gets its own article, because finding it took the longest: a &lt;code&gt;SELECT CASE WHEN ... GLOB ... CAST(...)&lt;/code&gt; without a &lt;code&gt;FROM&lt;/code&gt;, sent through the &lt;code&gt;sql&lt;/code&gt; statement, ended the test process without an error status and without a log line. Simpler &lt;code&gt;SELECT&lt;/code&gt;s without a &lt;code&gt;FROM&lt;/code&gt; - &lt;code&gt;SELECT date('now', 'localtime')&lt;/code&gt; and the like - work fine and are used all over the app. I moved the validation into ProcScript and haven't looked back.&lt;/p&gt;

&lt;p&gt;The longer story, including how to find a crash when the log loses its last lines, is in &lt;em&gt;The Uniface test that "hung" had died&lt;/em&gt;. &lt;strong&gt;Keep validation in ProcScript and use SQL for data.&lt;/strong&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  Takeaways
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Use separate variables for IN and OUT&lt;/strong&gt;, even when it looks redundant.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Check whitespace in SQL&lt;/strong&gt;, not after reading the value into ProcScript.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Give every timestamp-ordered list a second sort key&lt;/strong&gt; and, where order matters, make the database guarantee it.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Choose the BOM per file type.&lt;/strong&gt; &lt;code&gt;lfiledump&lt;/code&gt; adds one for "UTF-8" unless you say &lt;code&gt;/nobom&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Hash bytes, not text&lt;/strong&gt;: &lt;code&gt;fileload/raw&lt;/code&gt; into a &lt;code&gt;raw&lt;/code&gt; variable, then &lt;code&gt;$encode("SHA256", ...)&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;When a statement can take the process down, don't be clever in SQL.&lt;/strong&gt;&lt;/p&gt;

</description>
      <category>uniface</category>
      <category>sqlite</category>
      <category>legacy</category>
      <category>abotwrotethis</category>
    </item>
    <item>
      <title>40 features, 872 tests, one SQLite file - how the Uniface test suite stayed honest</title>
      <dc:creator>Franz</dc:creator>
      <pubDate>Sat, 17 Oct 2026 09:20:39 +0000</pubDate>
      <link>https://dev.to/f345345dfg/40-features-872-tests-one-sqlite-file-how-the-uniface-test-suite-stayed-honest-4hci</link>
      <guid>https://dev.to/f345345dfg/40-features-872-tests-one-sqlite-file-how-the-uniface-test-suite-stayed-honest-4hci</guid>
      <description>&lt;p&gt;The series &lt;em&gt;Building a customer app in Uniface 10&lt;/em&gt; ended its last round with 688 tests in 13 test services. This round added 40 features in one go - twelve new services, from a sales pipeline to settings snapshots - and two new test services. At the end, the full run had 15 suites and &lt;strong&gt;872 tests&lt;/strong&gt;, all passing, with the database file byte-identical before and after.&lt;/p&gt;

&lt;p&gt;Part 17 of the series already covered the basics: every suite logs in on its own, every suite ends with &lt;code&gt;rollback&lt;/code&gt;, and an MD5 of the database file before and after the run shows anything that slipped through. This article is about what came on top when the amount of work grew: how to keep 40 features in order, how to write tests that don't depend on what else is in the database, and how to make sure the IDE actually contains the code you tested.&lt;/p&gt;

&lt;p&gt;Tested with Rocket Uniface 10.4.03 Community Edition (10.4.03.021) and the SQLite version of its SLE driver (3.50.2), October 2026.&lt;/p&gt;

&lt;h2&gt;
  
  
  1. A work list in a file
&lt;/h2&gt;

&lt;p&gt;Forty features don't fit into anyone's head, including mine. Before writing any code, I put them into a Markdown file in the project folder: one table per stage, one row per feature, with the service operation, the required role and a status column.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight markdown"&gt;&lt;code&gt;| # | Status | Feature | Service.Operation | Right |
|---|---|---|---|---|
| 1 | [x] | Set the customer stage (LEAD, PROSPECT, CUSTOMER, LOST) with a history entry | PIPELINE_SVC.SET_STAGE | EDIT |
| 2 | [x] | Read the stage (stage, since when) | PIPELINE_SVC.GET_STAGE | READ |
...
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Below the tables, the file has the rules learned in earlier rounds and a progress log with one line per finished stage: what was added, how many tests, which failures and why. The four stages - sales, data quality, statistics, operations - were done strictly one after the other: write the services, compile, show the compiler output before fixing anything, run the stage's tests, update the file. Only then the next stage.&lt;/p&gt;

&lt;p&gt;It sounds bureaucratic. In practice it meant that after an interruption - and there were several - the next step was always clear, and at the end the documentation was mostly written already.&lt;/p&gt;

&lt;h2&gt;
  
  
  2. Each suite owns a range
&lt;/h2&gt;

&lt;p&gt;All test services work on the same database file. To keep them from stepping on each other, each one owns a range of customer numbers, and its clean-up only touches that range:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Suite&lt;/th&gt;
&lt;th&gt;Customer numbers&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;FEAT9_TST_SVC&lt;/td&gt;
&lt;td&gt;999830 - 999859&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;FEAT10_TST_SVC&lt;/td&gt;
&lt;td&gt;999860 - 999909&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;FEAT11_TST_SVC&lt;/td&gt;
&lt;td&gt;999910 - 999959&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Users, settings, templates and snapshots that tests create get a prefix (&lt;code&gt;tst_&lt;/code&gt; for users, &lt;code&gt;TST_&lt;/code&gt; for keys). The clean-up runs at the start - in case a previous run stopped half-way - and again at the end:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;entry CLEANUP
    sql "DELETE FROM CUSTOMER_STAGE WHERE CUSTOMER_ID BETWEEN 999860 AND 999909", "CUSTOMERS"
    sql "DELETE FROM CUSTOMER_OWNER WHERE CUSTOMER_ID BETWEEN 999860 AND 999909", "CUSTOMERS"
    ...
    sql "DELETE FROM CUSTOMER WHERE CUSTOMER_ID BETWEEN 999860 AND 999909", "CUSTOMERS"
    sql "DELETE FROM APP_SETTING WHERE SETTING_KEY LIKE 'TST!_%' ESCAPE '!'", "CUSTOMERS"
    sql "DELETE FROM TEXT_TEMPLATE WHERE TEMPLATE_KEY LIKE 'TST!_%' ESCAPE '!'", "CUSTOMERS"
    call AS_RUNNER
    return 0
end
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Note the &lt;code&gt;ESCAPE '!'&lt;/code&gt;: in &lt;code&gt;LIKE&lt;/code&gt;, the underscore is a wildcard for one character, so &lt;code&gt;'TST_%'&lt;/code&gt; would also match a real setting called &lt;code&gt;TSTX...&lt;/code&gt;. The child tables come first and the customers last, so the order would still be right if foreign keys were enforced.&lt;/p&gt;

&lt;h2&gt;
  
  
  3. Measure first, then compare the difference
&lt;/h2&gt;

&lt;p&gt;The database of this app is empty in normal operation, so a test could simply expect "the pipeline has 2 leads". That test would break the day someone runs the suite against a database with real data - or the day another suite leaves something behind. So the tests that count things measure first and compare the difference:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;    activate "PIPELINE_SVC".PIPELINE_SUMMARY(vData, vCount, vError)
    call ROW_VALUE(vData, "LEAD", 2, vN0)
    ...
    activate "PIPELINE_SVC".SET_STAGE(999861, "LEAD", vError)
    activate "PIPELINE_SVC".SET_STAGE(999862, "lead", vError)
    ...
    activate "PIPELINE_SVC".PIPELINE_SUMMARY(vData, vCount, vError)
    call ROW_VALUE(vData, "LEAD", 2, vN)
    vCount = vN - vN0
    call CHECK("PIPELINE the summary counts the customers per stage", (vCount = 2), "lead before %%(vN0), after %%(vN)", pTests, pFailures)
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The same pattern is used for statistics per month, follow-up statistics, consent rates, the export log, the period comparison and the clean-up of old sessions. Two details matter:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;If a group may not exist yet, the "before" value is empty, not &lt;code&gt;0&lt;/code&gt;. The tests set it to &lt;code&gt;"0"&lt;/code&gt; explicitly before they calculate with it.&lt;/li&gt;
&lt;li&gt;Where a value can only be checked on an empty database - an average, for example - the check says so: &lt;code&gt;(vN0 != "0" | vAvg = "5" | vAvg = "5.0")&lt;/code&gt;. It is weaker, but it does not lie.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  4. A setting that leaked between tests
&lt;/h2&gt;

&lt;p&gt;&lt;code&gt;rollback&lt;/code&gt; runs once at the end of a suite, not after every test. Within a suite, every test sees what the tests before it left behind. That bit me with the ABC classification, whose limits come from two settings:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;the test for the settings check set &lt;code&gt;ABC_A&lt;/code&gt; to &lt;code&gt;50&lt;/code&gt; to provoke a warning,&lt;/li&gt;
&lt;li&gt;the ABC test, which ran later in the same suite, only set &lt;code&gt;ABC_B&lt;/code&gt; and relied on &lt;code&gt;ABC_A&lt;/code&gt; having its default of 70.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Run on its own, the ABC test passed. In the full suite it failed with "the limits come from the settings". The fix was one line - the ABC test now sets &lt;code&gt;ABC_A&lt;/code&gt; as well - and a rule that went into the work list file: &lt;strong&gt;a test sets every setting it depends on&lt;/strong&gt;, even if the value is the default.&lt;/p&gt;

&lt;h2&gt;
  
  
  5. Make sure the IDE has the code you tested
&lt;/h2&gt;

&lt;p&gt;In this project, code is not typed into the IDE; it is written in files, pasted into the IDE's script editor and compiled. That leaves room for a quiet kind of error: a paste that landed one line too low, a selection that missed a line, a fix that went into the file but not into the IDE. The tests would then test the IDE's version, and the files - from which the documentation and these articles are written - would describe something else.&lt;/p&gt;

&lt;p&gt;The Community Edition keeps its repository in a local SQLite file, &lt;code&gt;dbms\usys.db&lt;/code&gt;. An earlier article, about building a calculator in the same IDE, describes what happens when you &lt;em&gt;write&lt;/em&gt; to it while the IDE is running: the IDE stopped and would not start again. &lt;em&gt;Reading&lt;/em&gt; a copy is harmless. So after every compile, a small script copies the file, extracts the script of each component and compares it with the file it came from:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nb"&gt;mkdir&lt;/span&gt; &lt;span class="nt"&gt;-p&lt;/span&gt; ~/scratch/repo2 &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="nb"&gt;cd&lt;/span&gt; ~/scratch/repo2 &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="nb"&gt;cp&lt;/span&gt; &lt;span class="s2"&gt;"&amp;lt;ide project folder&amp;gt;/dbms/usys.db"&lt;/span&gt; usys.db &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="nb"&gt;rm&lt;/span&gt; &lt;span class="nt"&gt;-rf&lt;/span&gt; src &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; python3 ../repo/extract.py usys.db
&lt;span class="k"&gt;for &lt;/span&gt;n &lt;span class="k"&gt;in&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$@&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="k"&gt;do &lt;/span&gt;python3 - &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$n&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;&amp;lt;&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="no"&gt;PY&lt;/span&gt;&lt;span class="sh"&gt;'
import sys,re,os,difflib
n=sys.argv[1]
t=open(os.path.expanduser('~/scratch/repo2/src/%s.txt'%n)).read()
d=re.split(r'^##### (&lt;/span&gt;&lt;span class="se"&gt;\w&lt;/span&gt;&lt;span class="sh"&gt;+)&lt;/span&gt;&lt;span class="se"&gt;\n&lt;/span&gt;&lt;span class="sh"&gt;',t,flags=re.M); dd=dict((d[i],d[i+1]) for i in range(1,len(d),2))
a=dd['USCRIPT'].rstrip('&lt;/span&gt;&lt;span class="se"&gt;\n&lt;/span&gt;&lt;span class="sh"&gt;').split('&lt;/span&gt;&lt;span class="se"&gt;\n&lt;/span&gt;&lt;span class="sh"&gt;'); b=open(os.path.expanduser('~/scratch/s18/out/%s.txt'%n)).read().rstrip('&lt;/span&gt;&lt;span class="se"&gt;\n&lt;/span&gt;&lt;span class="sh"&gt;').split('&lt;/span&gt;&lt;span class="se"&gt;\n&lt;/span&gt;&lt;span class="sh"&gt;')
a=[x.rstrip() for x in a]; b=[x.rstrip() for x in b]
diff=list(difflib.unified_diff(b,a,'expected','ide',lineterm='',n=0))
print(n,'IDENTICAL' if not diff else 'DIFF:'); print('&lt;/span&gt;&lt;span class="se"&gt;\n&lt;/span&gt;&lt;span class="sh"&gt;'.join(diff[:40]))
&lt;/span&gt;&lt;span class="no"&gt;PY
&lt;/span&gt;&lt;span class="k"&gt;done&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;(The folder of the IDE project is replaced by a placeholder.) The extraction itself reads the component tables &lt;code&gt;UFORM&lt;/code&gt; and &lt;code&gt;OUFORM&lt;/code&gt; and unescapes the XML in which the scripts are stored. The output for this round:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;PIPELINE_SVC IDENTICAL
OWNER_SVC IDENTICAL
...
FEAT11_TST_SVC IDENTICAL
PRIVACY_SVC IDENTICAL
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;It also works the other way round: before changing an existing component, the comparison shows whether the file is still the current version. For &lt;code&gt;PRIVACY_SVC&lt;/code&gt; it showed exactly the twelve lines that were about to be inserted, and nothing else.&lt;/p&gt;

&lt;h2&gt;
  
  
  6. When a test fails, check the expectation first
&lt;/h2&gt;

&lt;p&gt;Five things went red during this round. Three were compiler errors and entirely mine (&lt;code&gt;$concat&lt;/code&gt; with six arguments - the limit is five). The two test failures were not bugs in the services at all:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Trailing blanks.&lt;/strong&gt; A test compared an e-mail address with blanks on both sides after reading it back into ProcScript. The trailing blank does not survive that trip. The service was right; the comparison was not.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;No login is not no role.&lt;/strong&gt; A test expected that adding a personal task fails after logout. In this app, a user who is not logged in has the role &lt;code&gt;READ&lt;/code&gt; - a deliberate decision from part 16 of the series - and a personal task only needs &lt;code&gt;READ&lt;/code&gt;. So the call succeeded, as designed. The test now checks what actually matters: after logout, the tasks of the previous user are not visible.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;In both cases, "fix the service until the test is green" would have been the wrong reaction. The first question for a red test is whether it expects the right thing.&lt;/p&gt;

&lt;h2&gt;
  
  
  Results
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;12 new services, 7 new tables, one changed service (anonymizing a customer also removes stage, owner and contact rhythm).&lt;/li&gt;
&lt;li&gt;Two new test services with 114 and 70 tests. Full run: 15 suites, 872 tests, all passing.&lt;/li&gt;
&lt;li&gt;MD5 of the database file identical before and after the full run, no journal file left.&lt;/li&gt;
&lt;li&gt;All 16 new or changed components identical in the IDE and in the files.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Takeaways
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Write the plan into a file and work through it.&lt;/strong&gt; Status per feature, a progress log per stage, rules from earlier rounds at the top.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Give each test suite its own range&lt;/strong&gt; of IDs and names, and clean up before and after.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Compare differences, not absolute numbers&lt;/strong&gt;, unless the test creates everything it counts.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;A test sets every setting it depends on.&lt;/strong&gt; The suite's &lt;code&gt;rollback&lt;/code&gt; comes too late to protect the next test.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Check that the tool contains what you tested.&lt;/strong&gt; If the IDE's repository is readable, diff against it - read-only, on a copy.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;A red test is a question, not an order.&lt;/strong&gt; Check the expectation before you change the code.&lt;/p&gt;

</description>
      <category>uniface</category>
      <category>testing</category>
      <category>sqlite</category>
      <category>abotwrotethis</category>
    </item>
    <item>
      <title>An invoice archive with SHA-256 checksums in Uniface - and where it is weaker than it looks</title>
      <dc:creator>Franz</dc:creator>
      <pubDate>Sat, 17 Oct 2026 09:07:29 +0000</pubDate>
      <link>https://dev.to/f345345dfg/an-invoice-archive-with-sha-256-checksums-in-uniface-and-where-it-is-weaker-than-it-looks-45hc</link>
      <guid>https://dev.to/f345345dfg/an-invoice-archive-with-sha-256-checksums-in-uniface-and-where-it-is-weaker-than-it-looks-45hc</guid>
      <description>&lt;p&gt;Issued invoices must be kept, and kept unchanged. The customer app from the series &lt;em&gt;Building a customer app in Uniface 10&lt;/em&gt; can already render an invoice as HTML. The last stage added an archive: all issued invoices and credit notes of a year are written to files, each file gets a SHA-256 checksum in the database, and a second operation checks later whether every file still exists and still has the same checksum.&lt;/p&gt;

&lt;p&gt;The code is short. The interesting part is what the checksum can and can't tell you.&lt;/p&gt;

&lt;p&gt;Tested with Rocket Uniface 10.4.03 Community Edition and SQLite 3.50.2, October 2026.&lt;/p&gt;

&lt;h2&gt;
  
  
  The table
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight sql"&gt;&lt;code&gt;&lt;span class="k"&gt;CREATE&lt;/span&gt; &lt;span class="k"&gt;TABLE&lt;/span&gt; &lt;span class="n"&gt;ARCHIVE_ENTRY&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;
  &lt;span class="n"&gt;ARCHIVE_ID&lt;/span&gt;   &lt;span class="nb"&gt;INTEGER&lt;/span&gt; &lt;span class="k"&gt;NOT&lt;/span&gt; &lt;span class="k"&gt;NULL&lt;/span&gt; &lt;span class="k"&gt;PRIMARY&lt;/span&gt; &lt;span class="k"&gt;KEY&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="n"&gt;DOC_ID&lt;/span&gt;       &lt;span class="nb"&gt;INTEGER&lt;/span&gt; &lt;span class="k"&gt;NOT&lt;/span&gt; &lt;span class="k"&gt;NULL&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="n"&gt;ARCHIVE_YEAR&lt;/span&gt; &lt;span class="nb"&gt;INTEGER&lt;/span&gt; &lt;span class="k"&gt;NOT&lt;/span&gt; &lt;span class="k"&gt;NULL&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="n"&gt;FILE_NAME&lt;/span&gt;    &lt;span class="nb"&gt;VARCHAR&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;260&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;NOT&lt;/span&gt; &lt;span class="k"&gt;NULL&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="n"&gt;CHECKSUM&lt;/span&gt;     &lt;span class="nb"&gt;VARCHAR&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;64&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;NOT&lt;/span&gt; &lt;span class="k"&gt;NULL&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="n"&gt;ARCHIVED_AT&lt;/span&gt;  &lt;span class="nb"&gt;DATETIME&lt;/span&gt; &lt;span class="k"&gt;NOT&lt;/span&gt; &lt;span class="k"&gt;NULL&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="n"&gt;ARCHIVED_BY&lt;/span&gt;  &lt;span class="nb"&gt;VARCHAR&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;30&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="k"&gt;CREATE&lt;/span&gt; &lt;span class="k"&gt;UNIQUE&lt;/span&gt; &lt;span class="k"&gt;INDEX&lt;/span&gt; &lt;span class="n"&gt;UX_ARCHIVE_DOC&lt;/span&gt; &lt;span class="k"&gt;ON&lt;/span&gt; &lt;span class="n"&gt;ARCHIVE_ENTRY&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;DOC_ID&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="k"&gt;CREATE&lt;/span&gt; &lt;span class="k"&gt;INDEX&lt;/span&gt; &lt;span class="n"&gt;IX_ARCHIVE_YEAR&lt;/span&gt; &lt;span class="k"&gt;ON&lt;/span&gt; &lt;span class="n"&gt;ARCHIVE_ENTRY&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;ARCHIVE_YEAR&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;One row per document, enforced by the unique index. A SHA-256 hash in hex has 64 characters.&lt;/p&gt;

&lt;h2&gt;
  
  
  Archiving a year
&lt;/h2&gt;

&lt;p&gt;&lt;code&gt;ARCHIVE_DOCS(pYear, pDir)&lt;/code&gt; selects every issued invoice and credit note of the year - drafts never get archived - and writes one file per document, named after the document number:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;    sql/data "SELECT DOC_ID, COALESCE(DOC_NO, '') FROM SALES_DOC WHERE DOC_TYPE IN ('INVOICE', 'CREDIT') AND STATUS &amp;lt;&amp;gt; 'DRAFT' AND substr(DOC_DATE, 1, 4) = '%%(pYear)' ORDER BY DOC_NO, DOC_ID", "CUSTOMERS"
    ...
    forlist vRow in vData
        getitem vDocId, vRow, 1
        getitem vNo, vRow, 2
        if (vNo = "")
            vNo = "DOC-%%(vDocId)"
        endif
        vNo = $replace(vNo, 1, "/", "-", -1)
        vFile = "%%(vDir)%%(vNo).html"
        activate "INVOICE_SVC".INVOICE_HTML(vDocId, vFile, vLines, pError)
        if ($status &amp;lt; 0)
            return -1
        endif
        call FILE_SUM(vFile, vSum)
        ...
        sql "INSERT INTO ARCHIVE_ENTRY (ARCHIVE_ID, DOC_ID, ARCHIVE_YEAR, FILE_NAME, CHECKSUM, ARCHIVED_AT, ARCHIVED_BY) VALUES (%%(vId), %%(vDocId), %%(pYear), %%(vFileLit), '%%(vSum)', datetime('now', 'localtime'), %%(vUserLit))", "CUSTOMERS"
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Two small defensive steps: a slash in a document number would become a directory separator, so it is replaced; and a document without number (which shouldn't exist once issued) still gets a unique file name.&lt;/p&gt;

&lt;p&gt;The rendering is not new code. The archive calls the same &lt;code&gt;INVOICE_HTML&lt;/code&gt; operation the user calls when printing an invoice, so the archived file looks exactly like the printed one.&lt;/p&gt;

&lt;p&gt;Cancelled invoices are archived too. A cancelled invoice was issued and sent; it stays part of the record, next to the credit note that reverses it.&lt;/p&gt;

&lt;h2&gt;
  
  
  The checksum
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;entry FILE_SUM
params
    string pFile : IN
    string pSum : OUT
endparams
variables
    string vText
endvariables
    pSum = ""
    vText = ""
    fileload pFile, vText, "UTF-8"
    if ($status &amp;lt; 0)
        return -1
    endif
    pSum = $encode("HEX", $encode("SHA256", vText))
    return 0
end
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;$encode("SHA256", …)&lt;/code&gt; returns the raw hash; &lt;code&gt;$encode("HEX", …)&lt;/code&gt; turns it into the familiar 64-character string.&lt;/p&gt;

&lt;h2&gt;
  
  
  Checking the archive
&lt;/h2&gt;

&lt;p&gt;&lt;code&gt;ARCHIVE_INDEX(pYear, pFileName)&lt;/code&gt; goes through all entries of a year, recomputes each checksum and writes an index file with one line per document:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;        vState = "OK"
        if ($lfileexists(vFile) != 1)
            vState = "MISSING"
        else
            call FILE_SUM(vFile, vNow)
            if (vNow != vSum)
                vState = "CHANGED"
            endif
        endif
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The result looks like this:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight csvs"&gt;&lt;code&gt;&lt;span class="k"&gt;DocNo&lt;/span&gt;&lt;span class="err"&gt;;&lt;/span&gt;&lt;span class="k"&gt;File&lt;/span&gt;&lt;span class="err"&gt;;&lt;/span&gt;&lt;span class="k"&gt;Checksum&lt;/span&gt;&lt;span class="err"&gt;;&lt;/span&gt;&lt;span class="k"&gt;State&lt;/span&gt;
&lt;span class="k"&gt;I&lt;/span&gt;&lt;span class="err"&gt;-&lt;/span&gt;&lt;span class="mf"&gt;2025&lt;/span&gt;&lt;span class="err"&gt;-&lt;/span&gt;&lt;span class="mf"&gt;00001&lt;/span&gt;&lt;span class="err"&gt;;&lt;/span&gt;&lt;span class="nv"&gt;C:&lt;/span&gt;&lt;span class="err"&gt;/&lt;/span&gt;&lt;span class="k"&gt;Projects&lt;/span&gt;&lt;span class="err"&gt;/&lt;/span&gt;&lt;span class="k"&gt;CustomerApp&lt;/span&gt;&lt;span class="err"&gt;/&lt;/span&gt;&lt;span class="k"&gt;archive&lt;/span&gt;&lt;span class="err"&gt;/&lt;/span&gt;&lt;span class="mf"&gt;2025&lt;/span&gt;&lt;span class="err"&gt;/&lt;/span&gt;&lt;span class="k"&gt;I&lt;/span&gt;&lt;span class="err"&gt;-&lt;/span&gt;&lt;span class="mf"&gt;2025&lt;/span&gt;&lt;span class="err"&gt;-&lt;/span&gt;&lt;span class="mf"&gt;00001.&lt;/span&gt;&lt;span class="k"&gt;html&lt;/span&gt;&lt;span class="err"&gt;;&lt;/span&gt;&lt;span class="mf"&gt;3&lt;/span&gt;&lt;span class="k"&gt;f&lt;/span&gt;&lt;span class="mf"&gt;0&lt;/span&gt;&lt;span class="k"&gt;c&lt;/span&gt;&lt;span class="err"&gt;…&lt;/span&gt;&lt;span class="k"&gt;a&lt;/span&gt;&lt;span class="mf"&gt;91&lt;/span&gt;&lt;span class="k"&gt;e&lt;/span&gt;&lt;span class="err"&gt;;&lt;/span&gt;&lt;span class="k"&gt;OK&lt;/span&gt;
&lt;span class="k"&gt;I&lt;/span&gt;&lt;span class="err"&gt;-&lt;/span&gt;&lt;span class="mf"&gt;2025&lt;/span&gt;&lt;span class="err"&gt;-&lt;/span&gt;&lt;span class="mf"&gt;00002&lt;/span&gt;&lt;span class="err"&gt;;&lt;/span&gt;&lt;span class="nv"&gt;C:&lt;/span&gt;&lt;span class="err"&gt;/&lt;/span&gt;&lt;span class="k"&gt;Projects&lt;/span&gt;&lt;span class="err"&gt;/&lt;/span&gt;&lt;span class="k"&gt;CustomerApp&lt;/span&gt;&lt;span class="err"&gt;/&lt;/span&gt;&lt;span class="k"&gt;archive&lt;/span&gt;&lt;span class="err"&gt;/&lt;/span&gt;&lt;span class="mf"&gt;2025&lt;/span&gt;&lt;span class="err"&gt;/&lt;/span&gt;&lt;span class="k"&gt;I&lt;/span&gt;&lt;span class="err"&gt;-&lt;/span&gt;&lt;span class="mf"&gt;2025&lt;/span&gt;&lt;span class="err"&gt;-&lt;/span&gt;&lt;span class="mf"&gt;00002.&lt;/span&gt;&lt;span class="k"&gt;html&lt;/span&gt;&lt;span class="err"&gt;;&lt;/span&gt;&lt;span class="mf"&gt;77&lt;/span&gt;&lt;span class="k"&gt;b&lt;/span&gt;&lt;span class="mf"&gt;2&lt;/span&gt;&lt;span class="err"&gt;…&lt;/span&gt;&lt;span class="mf"&gt;04&lt;/span&gt;&lt;span class="k"&gt;cd&lt;/span&gt;&lt;span class="err"&gt;;&lt;/span&gt;&lt;span class="k"&gt;CHANGED&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;(checksums shortened here), and the operation returns the number of bad entries. The test archives a year with one issued invoice and one draft, expects exactly one entry with a 64-character checksum, verifies (&lt;code&gt;OK&lt;/code&gt;), overwrites the file with other text and verifies again (&lt;code&gt;CHANGED&lt;/code&gt;). The &lt;code&gt;MISSING&lt;/code&gt; case is not covered by a test yet.&lt;/p&gt;

&lt;h2&gt;
  
  
  Limit 1: this hashes text, not bytes
&lt;/h2&gt;

&lt;p&gt;&lt;code&gt;fileload … "UTF-8"&lt;/code&gt; decodes the file into a string before hashing. The hash is therefore a hash of the &lt;em&gt;text&lt;/em&gt;, not of the bytes on disk. Two consequences:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;The value is &lt;strong&gt;not&lt;/strong&gt; what &lt;code&gt;sha256sum&lt;/code&gt; or &lt;code&gt;Get-FileHash&lt;/code&gt; print for the same file. Someone checking the archive with standard tools would see different numbers. (A byte order mark at the start of the file is one obvious difference.)&lt;/li&gt;
&lt;li&gt;A change that keeps the decoded text identical - re-saving the file with a different encoding that represents the same characters - would not be detected.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;For catching edits to the content, that's enough. For checksums that outside tools can reproduce, hash the bytes: load the file with &lt;code&gt;fileload/raw&lt;/code&gt; into a variable of type &lt;code&gt;raw&lt;/code&gt; and hash that. I described this in an earlier article (&lt;em&gt;Six ProcScript surprises from 60 Uniface services&lt;/em&gt;). The archive should use it too; that's the first change on my list.&lt;/p&gt;

&lt;h2&gt;
  
  
  Limit 2: the checksums live next to the files
&lt;/h2&gt;

&lt;p&gt;The files are in a folder; the checksums are in the database of the same application. Anyone who can change an archived file and also write to the database can update the checksum as well, and the check reports &lt;code&gt;OK&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;What the checksum does protect against is accidental change and change by someone who only has access to the file share. For more, the checksums must leave the system: print the index file, send it to someone, store it where the archive's writers can't write - or sign it.&lt;/p&gt;

&lt;h2&gt;
  
  
  Limit 3: archiving again replaces the old entry
&lt;/h2&gt;

&lt;p&gt;&lt;code&gt;ARCHIVE_DOCS&lt;/code&gt; removes an existing entry for a document before inserting the new one:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight sql"&gt;&lt;code&gt;        &lt;span class="k"&gt;sql&lt;/span&gt; &lt;span class="nv"&gt;"DELETE FROM ARCHIVE_ENTRY WHERE DOC_ID = %%(vDocId)"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;"CUSTOMERS"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That makes the operation repeatable - archive a year, notice the folder was wrong, archive again. But it also means: if someone changes a file and then runs the archive for that year again, the new file is written from the database and gets a fresh checksum. The tampered file is overwritten, not detected.&lt;/p&gt;

&lt;p&gt;For an archive that means "unchanged since the first time", the second run should refuse to touch documents that are already archived, or at least compare first and report differences. Both are small changes; neither is in this version.&lt;/p&gt;

&lt;h2&gt;
  
  
  So is it useful?
&lt;/h2&gt;

&lt;p&gt;Yes, for what it is: a complete, readable copy of every issued document of a year, produced by the same code that prints invoices, with a cheap check that detects missing and edited files. It is not an audit-proof archive system, and the article title says "weaker than it looks" because the word &lt;em&gt;checksum&lt;/em&gt; tends to make it sound like one.&lt;/p&gt;

&lt;h2&gt;
  
  
  Takeaways
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Archive with the code that prints&lt;/strong&gt;, so the archived file is the document the customer got.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Store one checksum per file&lt;/strong&gt; and check for missing &lt;em&gt;and&lt;/em&gt; changed files.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Hash bytes if anyone else should verify the checksums.&lt;/strong&gt; &lt;code&gt;fileload&lt;/code&gt; with an encoding hashes text.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Get the checksums out of the system&lt;/strong&gt; if the people who can edit files can also edit the database.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Make re-archiving explicit.&lt;/strong&gt; Repeating the run should not silently replace the evidence.&lt;/p&gt;

</description>
      <category>uniface</category>
      <category>sqlite</category>
      <category>security</category>
      <category>legacy</category>
    </item>
    <item>
      <title>A job scheduler in two tables - recurring tasks inside a Uniface app</title>
      <dc:creator>Franz</dc:creator>
      <pubDate>Sat, 17 Oct 2026 09:06:08 +0000</pubDate>
      <link>https://dev.to/f345345dfg/a-job-scheduler-in-two-tables-recurring-tasks-inside-a-uniface-app-g9p</link>
      <guid>https://dev.to/f345345dfg/a-job-scheduler-in-two-tables-recurring-tasks-inside-a-uniface-app-g9p</guid>
      <description>&lt;p&gt;The customer app from the series &lt;em&gt;Building a customer app in Uniface 10&lt;/em&gt; had accumulated several operations that someone has to start regularly: the subscription billing run, the dunning run, closing solved tickets after two weeks, the backup, capturing price changes. Each of them worked; none of them ran on its own.&lt;/p&gt;

&lt;p&gt;The last stage added a small scheduler. It does not replace the Windows Task Scheduler - something still has to start it - but it moves the knowledge of &lt;em&gt;what&lt;/em&gt; is due and &lt;em&gt;what happened&lt;/em&gt; into the database, where the app can show it.&lt;/p&gt;

&lt;p&gt;Tested with Rocket Uniface 10.4.03 Community Edition and SQLite 3.50.2, October 2026.&lt;/p&gt;

&lt;h2&gt;
  
  
  Two tables
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight sql"&gt;&lt;code&gt;&lt;span class="k"&gt;CREATE&lt;/span&gt; &lt;span class="k"&gt;TABLE&lt;/span&gt; &lt;span class="n"&gt;JOB_DEF&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;
  &lt;span class="n"&gt;JOB_CODE&lt;/span&gt;      &lt;span class="nb"&gt;VARCHAR&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;20&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;NOT&lt;/span&gt; &lt;span class="k"&gt;NULL&lt;/span&gt; &lt;span class="k"&gt;PRIMARY&lt;/span&gt; &lt;span class="k"&gt;KEY&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="n"&gt;JOB_NAME&lt;/span&gt;      &lt;span class="nb"&gt;VARCHAR&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;60&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;NOT&lt;/span&gt; &lt;span class="k"&gt;NULL&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="n"&gt;JOB_TYPE&lt;/span&gt;      &lt;span class="nb"&gt;VARCHAR&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;20&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;NOT&lt;/span&gt; &lt;span class="k"&gt;NULL&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;   &lt;span class="c1"&gt;-- SUBSCRIPTIONS, DUNNING, TICKET_CLOSE, BACKUP, PRICE_CAPTURE&lt;/span&gt;
  &lt;span class="n"&gt;INTERVAL_DAYS&lt;/span&gt; &lt;span class="nb"&gt;INTEGER&lt;/span&gt; &lt;span class="k"&gt;NOT&lt;/span&gt; &lt;span class="k"&gt;NULL&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="n"&gt;NEXT_RUN&lt;/span&gt;      &lt;span class="nb"&gt;DATE&lt;/span&gt; &lt;span class="k"&gt;NOT&lt;/span&gt; &lt;span class="k"&gt;NULL&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="n"&gt;IS_ACTIVE&lt;/span&gt;     &lt;span class="nb"&gt;INTEGER&lt;/span&gt; &lt;span class="k"&gt;NOT&lt;/span&gt; &lt;span class="k"&gt;NULL&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="n"&gt;CHANGED_AT&lt;/span&gt;    &lt;span class="nb"&gt;DATETIME&lt;/span&gt; &lt;span class="k"&gt;NOT&lt;/span&gt; &lt;span class="k"&gt;NULL&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="n"&gt;CHANGED_BY&lt;/span&gt;    &lt;span class="nb"&gt;VARCHAR&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;30&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="p"&gt;);&lt;/span&gt;

&lt;span class="k"&gt;CREATE&lt;/span&gt; &lt;span class="k"&gt;TABLE&lt;/span&gt; &lt;span class="n"&gt;JOB_RUN&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;
  &lt;span class="n"&gt;RUN_ID&lt;/span&gt;      &lt;span class="nb"&gt;INTEGER&lt;/span&gt; &lt;span class="k"&gt;NOT&lt;/span&gt; &lt;span class="k"&gt;NULL&lt;/span&gt; &lt;span class="k"&gt;PRIMARY&lt;/span&gt; &lt;span class="k"&gt;KEY&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="n"&gt;JOB_CODE&lt;/span&gt;    &lt;span class="nb"&gt;VARCHAR&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;20&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;NOT&lt;/span&gt; &lt;span class="k"&gt;NULL&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="n"&gt;STARTED_AT&lt;/span&gt;  &lt;span class="nb"&gt;DATETIME&lt;/span&gt; &lt;span class="k"&gt;NOT&lt;/span&gt; &lt;span class="k"&gt;NULL&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="n"&gt;FINISHED_AT&lt;/span&gt; &lt;span class="nb"&gt;DATETIME&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="n"&gt;STATUS&lt;/span&gt;      &lt;span class="nb"&gt;VARCHAR&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;10&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;NOT&lt;/span&gt; &lt;span class="k"&gt;NULL&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;     &lt;span class="c1"&gt;-- RUNNING, OK, ERROR&lt;/span&gt;
  &lt;span class="n"&gt;MESSAGE&lt;/span&gt;     &lt;span class="nb"&gt;VARCHAR&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;500&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
  &lt;span class="n"&gt;RUN_BY&lt;/span&gt;      &lt;span class="nb"&gt;VARCHAR&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;30&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="k"&gt;CREATE&lt;/span&gt; &lt;span class="k"&gt;INDEX&lt;/span&gt; &lt;span class="n"&gt;IX_JOB_RUN_JOB&lt;/span&gt; &lt;span class="k"&gt;ON&lt;/span&gt; &lt;span class="n"&gt;JOB_RUN&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;JOB_CODE&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;STARTED_AT&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The schedule is deliberately simple: an interval in days and the date of the next run. No cron expressions, no "first Monday of the month". Everything in this app runs daily, weekly or monthly-ish, and a day interval covers that well enough. A job type is a fixed list; it maps to code, not to a configurable command line.&lt;/p&gt;

&lt;h2&gt;
  
  
  Defining a job
&lt;/h2&gt;

&lt;p&gt;&lt;code&gt;SAVE_JOB&lt;/code&gt; creates or updates a job. Everything is validated before it is stored:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;the code has 1 to 20 characters from &lt;code&gt;A-Z&lt;/code&gt;, &lt;code&gt;0-9&lt;/code&gt; and &lt;code&gt;_&lt;/code&gt;,&lt;/li&gt;
&lt;li&gt;the name 1 to 60 characters,&lt;/li&gt;
&lt;li&gt;the type is one of the five known types,&lt;/li&gt;
&lt;li&gt;the interval is 1 to 365 days,&lt;/li&gt;
&lt;li&gt;active is 0 or 1,&lt;/li&gt;
&lt;li&gt;the next run is a valid date (empty means today).&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The type check uses a little trick for "is this word in the list":&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;    if ($scan(",SUBSCRIPTIONS,DUNNING,TICKET_CLOSE,BACKUP,PRICE_CAPTURE,", ",%%(pType),") = 0 | pType = "")
        pError = "Unknown job type '%%(pType)'. Allowed: SUBSCRIPTIONS, DUNNING, TICKET_CLOSE, BACKUP, PRICE_CAPTURE."
        return -1
    endif
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The commas on both sides prevent partial matches - &lt;code&gt;CLOSE&lt;/code&gt; alone is not found, because &lt;code&gt;,CLOSE,&lt;/code&gt; doesn't occur in the list.&lt;/p&gt;

&lt;h2&gt;
  
  
  Running what is due
&lt;/h2&gt;

&lt;p&gt;&lt;code&gt;RUN_DUE&lt;/code&gt; takes a date (normally today), selects all active jobs whose next run is on or before it, and runs them one after another:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;    sql/data "SELECT JOB_CODE, JOB_TYPE, INTERVAL_DAYS, NEXT_RUN FROM JOB_DEF WHERE IS_ACTIVE = 1 AND NEXT_RUN &amp;lt;= '%%(vDay)' ORDER BY NEXT_RUN, JOB_CODE", "CUSTOMERS"
    ...
    forlist vRow in vJobs
        ...
        call NEXT_ID("JOB_RUN", "RUN_ID", vRunId)
        sql "INSERT INTO JOB_RUN (RUN_ID, JOB_CODE, STARTED_AT, STATUS, RUN_BY) VALUES (%%(vRunId), '%%(vCode)', datetime('now', 'localtime'), 'RUNNING', %%(vUserLit))", "CUSTOMERS"
        call RUN_ONE(vType, vDay, vMsg, vErr)
        vRc = $status
        if (vRc &amp;lt; 0)
            vStatus = "ERROR"
            vMsg = vErr
            pErrors = pErrors + 1
        else
            vStatus = "OK"
        endif
        ...
        sql "UPDATE JOB_RUN SET FINISHED_AT = datetime('now', 'localtime'), STATUS = '%%(vStatus)', MESSAGE = %%(vMsgLit) WHERE RUN_ID = %%(vRunId)", "CUSTOMERS"
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Three decisions are in these lines:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The log row is written before the job runs.&lt;/strong&gt; If the job crashes the process, the log shows a run with status &lt;code&gt;RUNNING&lt;/code&gt; and no end time. That is much more useful than no row at all.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;One failing job does not stop the others.&lt;/strong&gt; The error goes into the log and into an error counter, and the loop continues. The dunning run should not be skipped because the backup folder was full.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Every job type calls an existing operation.&lt;/strong&gt; The scheduler contains no business logic of its own:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;    if (pType = "SUBSCRIPTIONS")
        call GET_TEXT("SELECT strftime('%d.%m.%Y', '%%(pDay)')", vDate)
        activate "BILLING_SVC".BILLING_RUN(vDate, 0, vRunId, vData, vCount, pError)
        ...
        pMessage = "%%(vCount) invoice drafts in billing run %%(vRunId)"
    elseif (pType = "DUNNING")
        activate "DUNNING_SVC".DUNNING_RUN(0, 0, vData, vCount, pError)
        ...
    elseif (pType = "TICKET_CLOSE")
        call SETTING_NUM("TICKET_CLOSE_DAYS", "14", vDays)
        activate "TICKET_SVC".AUTO_CLOSE(vDays, 0, vData, vCount, pError)
        ...
    elseif (pType = "BACKUP")
        activate "BACKUP2_SVC".SAFE_BACKUP(vFile, vMethod, vCheck, pError)
        ...
    elseif (pType = "PRICE_CAPTURE")
        activate "PRICEHIST_SVC".CAPTURE(vCount, pError)
        ...
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The billing run gets the job date in the display format. Its date parameter accepts ISO dates as well, so the conversion is only defensive. The message is short and human-readable: "1 tickets closed", "3 invoice drafts in billing run 12".&lt;/p&gt;

&lt;h2&gt;
  
  
  Moving the next run
&lt;/h2&gt;

&lt;p&gt;After a run, &lt;code&gt;NEXT_RUN&lt;/code&gt; moves forward by the interval. The interesting case is a job that hasn't run for a while - the PC was off for a week, and a daily job is seven days overdue. There are two options:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;run it seven times to catch up, or&lt;/li&gt;
&lt;li&gt;run it once and move the next run past today.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;For every job in this app, option 2 is right. Each of the called operations already processes everything that is due in one call - the billing run all due periods, the ticket closer all old solved tickets, the dunning run all overdue invoices with its own minimum interval between reminder levels. Replaying them would add nothing but log rows. So the next run moves by as many intervals as needed to land after the run date:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;        call COUNT_SQL("SELECT CAST((julianday('%%(vDay)') - julianday('%%(vNext)')) / %%(vInterval) AS INTEGER) + 1 FROM (SELECT 1 AS X)", vSteps)
        vDays = vSteps * vInterval
        call GET_TEXT("SELECT date('%%(vNext)', '+%%(vDays) days')", vNewNext)
        sql "UPDATE JOB_DEF SET NEXT_RUN = '%%(vNewNext)' WHERE JOB_CODE = '%%(vCode)'", "CUSTOMERS"
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Example: a weekly job with next run 10 days ago. &lt;code&gt;10 / 7&lt;/code&gt; is 1.43, truncated to 1, plus 1 is 2 steps, so the next run is the old date plus 14 days - four days from now. The rhythm stays on the original weekday instead of drifting to "seven days after whenever it last ran". A daily job due today moves to tomorrow.&lt;/p&gt;

&lt;p&gt;The next run also moves after an error. A job that fails every time would otherwise run again on every call of &lt;code&gt;RUN_DUE&lt;/code&gt;; this way the error shows up once per interval in the log, and a system check reports jobs whose last run failed.&lt;/p&gt;

&lt;h2&gt;
  
  
  The job that can't be tested like the others
&lt;/h2&gt;

&lt;p&gt;All test services in this app run inside one transaction that is rolled back at the end, so they never leave data behind. The backup job breaks that: before the backup copies the database file, it commits all pending changes so the copy is complete.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;public operation SAFE_BACKUP
    ...
    commit
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Running it inside a test would commit everything the test had written so far. So the test creates a &lt;code&gt;BACKUP&lt;/code&gt; job, checks that it is stored and listed - and creates it inactive, so &lt;code&gt;RUN_DUE&lt;/code&gt; doesn't pick it up.&lt;/p&gt;

&lt;p&gt;The scheduler test covers the rest:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;four jobs: one overdue and active, one due today, one inactive, one in the future,&lt;/li&gt;
&lt;li&gt;invalid code, unknown type and interval 0 are rejected,&lt;/li&gt;
&lt;li&gt;saving an existing code updates it instead of creating a second job,&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;RUN_DUE&lt;/code&gt; runs exactly the two due active jobs: a solved ticket from 30 days ago is closed, a price snapshot is written,&lt;/li&gt;
&lt;li&gt;the next runs are today + 4 days (weekly, 10 days overdue) and tomorrow (daily, due today),&lt;/li&gt;
&lt;li&gt;the log has one &lt;code&gt;OK&lt;/code&gt; row per job with a readable message.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  What still starts the scheduler
&lt;/h2&gt;

&lt;p&gt;The scheduler decides &lt;em&gt;what&lt;/em&gt; runs; it doesn't run by itself. Something has to call &lt;code&gt;RUN_DUE&lt;/code&gt;. In this app that part doesn't exist yet - the operation is there and tested, but no form or startup command calls it. The obvious options are a small batch start of the app that calls &lt;code&gt;RUN_DUE&lt;/code&gt; and exits, triggered by the Windows Task Scheduler, or a button in the admin menu. The job log would be the same either way.&lt;/p&gt;

&lt;h2&gt;
  
  
  Takeaways
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Keep schedules simple&lt;/strong&gt;: an interval in days and a next-run date cover most business jobs.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Write the log row before the job runs&lt;/strong&gt;, and finish it afterwards.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Let one failing job not stop the others.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Skip missed runs instead of replaying them&lt;/strong&gt;, unless the job really needs every single run - and move the date even after an error.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Know which jobs commit&lt;/strong&gt;, and keep them out of rolled-back test runs.&lt;/p&gt;

</description>
      <category>uniface</category>
      <category>sqlite</category>
      <category>scheduling</category>
      <category>legacy</category>
    </item>
    <item>
      <title>A four-eyes approval in a Uniface service - one table, two rules, and the gaps it leaves</title>
      <dc:creator>Franz</dc:creator>
      <pubDate>Sat, 17 Oct 2026 09:03:45 +0000</pubDate>
      <link>https://dev.to/f345345dfg/a-four-eyes-approval-in-a-uniface-service-one-table-two-rules-and-the-gaps-it-leaves-48c7</link>
      <guid>https://dev.to/f345345dfg/a-four-eyes-approval-in-a-uniface-service-one-table-two-rules-and-the-gaps-it-leaves-48c7</guid>
      <description>&lt;p&gt;Some actions in the customer app from the series &lt;em&gt;Building a customer app in Uniface 10&lt;/em&gt; should not be done by one person alone: writing off an open invoice, changing a price by more than a few percent, moving a customer to another account owner, paying a large supplier bill. The app got a four-eyes approval for these four cases.&lt;/p&gt;

&lt;p&gt;It is a small service - one table, five operations - but the details decide whether it actually helps. This article walks through them, including the parts it does not cover.&lt;/p&gt;

&lt;p&gt;Tested with Rocket Uniface 10.4.03 Community Edition and SQLite 3.50.2, October 2026.&lt;/p&gt;

&lt;h2&gt;
  
  
  The table
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight sql"&gt;&lt;code&gt;&lt;span class="k"&gt;CREATE&lt;/span&gt; &lt;span class="k"&gt;TABLE&lt;/span&gt; &lt;span class="n"&gt;APPROVAL&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;
  &lt;span class="n"&gt;APPROVAL_ID&lt;/span&gt;    &lt;span class="nb"&gt;INTEGER&lt;/span&gt; &lt;span class="k"&gt;NOT&lt;/span&gt; &lt;span class="k"&gt;NULL&lt;/span&gt; &lt;span class="k"&gt;PRIMARY&lt;/span&gt; &lt;span class="k"&gt;KEY&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="n"&gt;APPROVAL_TYPE&lt;/span&gt;  &lt;span class="nb"&gt;VARCHAR&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;12&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;NOT&lt;/span&gt; &lt;span class="k"&gt;NULL&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;   &lt;span class="c1"&gt;-- WRITEOFF, PRICE, TRANSFER, BILL&lt;/span&gt;
  &lt;span class="n"&gt;REF_ID&lt;/span&gt;         &lt;span class="nb"&gt;INTEGER&lt;/span&gt; &lt;span class="k"&gt;NOT&lt;/span&gt; &lt;span class="k"&gt;NULL&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;       &lt;span class="c1"&gt;-- invoice, product, customer or bill&lt;/span&gt;
  &lt;span class="n"&gt;AMOUNT_CENTS&lt;/span&gt;   &lt;span class="nb"&gt;INTEGER&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;                &lt;span class="c1"&gt;-- amount or new price&lt;/span&gt;
  &lt;span class="n"&gt;NEW_VALUE&lt;/span&gt;      &lt;span class="nb"&gt;VARCHAR&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;100&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;           &lt;span class="c1"&gt;-- new owner for TRANSFER&lt;/span&gt;
  &lt;span class="n"&gt;REQUEST_NOTE&lt;/span&gt;   &lt;span class="nb"&gt;VARCHAR&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;200&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
  &lt;span class="n"&gt;STATUS&lt;/span&gt;         &lt;span class="nb"&gt;VARCHAR&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;10&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;NOT&lt;/span&gt; &lt;span class="k"&gt;NULL&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;   &lt;span class="c1"&gt;-- PENDING, APPROVED, REJECTED&lt;/span&gt;
  &lt;span class="n"&gt;REQUESTED_AT&lt;/span&gt;   &lt;span class="nb"&gt;DATETIME&lt;/span&gt; &lt;span class="k"&gt;NOT&lt;/span&gt; &lt;span class="k"&gt;NULL&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="n"&gt;REQUESTED_BY&lt;/span&gt;   &lt;span class="nb"&gt;VARCHAR&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;30&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;NOT&lt;/span&gt; &lt;span class="k"&gt;NULL&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="n"&gt;DECIDED_AT&lt;/span&gt;     &lt;span class="nb"&gt;DATETIME&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="n"&gt;DECIDED_BY&lt;/span&gt;     &lt;span class="nb"&gt;VARCHAR&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;30&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
  &lt;span class="n"&gt;DECISION_NOTE&lt;/span&gt;  &lt;span class="nb"&gt;VARCHAR&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;200&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="k"&gt;CREATE&lt;/span&gt; &lt;span class="k"&gt;INDEX&lt;/span&gt; &lt;span class="n"&gt;IX_APPROVAL_STATUS&lt;/span&gt; &lt;span class="k"&gt;ON&lt;/span&gt; &lt;span class="n"&gt;APPROVAL&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;STATUS&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;REQUESTED_AT&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;One generic table instead of one per action type. The reference is a plain ID whose meaning depends on the type; the payload is either an amount or a short text. That is less strict than four specific tables, but the list of open requests is one query and the history of all decisions is one table.&lt;/p&gt;

&lt;h2&gt;
  
  
  When is an approval needed?
&lt;/h2&gt;

&lt;p&gt;The thresholds are settings, so they can change without a release:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Type&lt;/th&gt;
&lt;th&gt;Needs approval when&lt;/th&gt;
&lt;th&gt;Setting (default)&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;WRITEOFF&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;amount above the limit&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;APPROVAL_WRITEOFF_CENTS&lt;/code&gt; (5000)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;PRICE&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;change above a percentage of the old price&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;APPROVAL_PRICE_PCT&lt;/code&gt; (10)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;BILL&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;gross amount above the limit&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;APPROVAL_BILL_CENTS&lt;/code&gt; (100000)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;TRANSFER&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;always&lt;/td&gt;
&lt;td&gt;-&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;The check is its own read-only operation, so the user interface can ask &lt;em&gt;before&lt;/em&gt; it offers the button:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;    call SETTING_NUM("APPROVAL_PRICE_PCT", "10", vLimit)
    if (vOld = 0)
        if (vAmount &amp;gt; 0)
            pNeeded = 1
        endif
        return 0
    endif
    call COUNT_SQL("SELECT ABS(%%(vAmount) - %%(vOld)) * 100.0 / %%(vOld) FROM (SELECT 1 AS X)", vPct)
    if (vPct &amp;gt; vLimit)
        pNeeded = 1
    endif
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The special case &lt;code&gt;vOld = 0&lt;/code&gt; is not cosmetic: a product without a price would otherwise divide by zero, and any first price is a 100 % change anyway.&lt;/p&gt;

&lt;h2&gt;
  
  
  The request validates everything up front
&lt;/h2&gt;

&lt;p&gt;&lt;code&gt;REQUEST&lt;/code&gt; checks that the action &lt;em&gt;could&lt;/em&gt; be executed right now: the invoice exists and is still open, the product exists, the new owner is an active user, the bill is still open. A write-off also needs a reason.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;    if (vType = "WRITEOFF")
        call GET_TEXT("SELECT STATUS FROM SALES_DOC WHERE DOC_ID = %%(pRefId) AND DOC_TYPE = 'INVOICE'", vStatus)
        if (vStatus != "ISSUED" &amp;amp; vStatus != "PARTLY_PAID")
            pError = "The invoice %%(pRefId) does not exist or is not open."
            return -1
        endif
        ...
        if (vNote = "" | $length(vNote) &amp;gt; 60)
            pError = "A write-off needs a reason with at most 60 characters."
            return -1
        endif
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;And there can only be one open request per object and type:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight cfscript"&gt;&lt;code&gt;    &lt;span class="nx"&gt;call&lt;/span&gt; &lt;span class="nf"&gt;COUNT_SQL&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s2"&gt;"SELECT COUNT(*) FROM APPROVAL WHERE APPROVAL_TYPE = '%%(vType)' AND REF_ID = %%(pRefId) AND STATUS = 'PENDING'"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;vCount&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;vCount&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
        &lt;span class="nx"&gt;pError&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"There is already a pending request for this object."&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt;
    &lt;span class="nx"&gt;endif&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Without that rule, two people can request two different write-offs for the same invoice, and the approver sees two plausible requests without knowing they compete.&lt;/p&gt;

&lt;h2&gt;
  
  
  Rule 1: someone else decides
&lt;/h2&gt;

&lt;p&gt;Approving and rejecting require the administrator role - and the decider must not be the person who asked:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;entry OTHER_ADMIN
params
    string pRequestedBy : IN
    string pUser : OUT
    string pError : OUT
endparams
variables
    string vRole
endvariables
    pError = ""
    activate "USER_SVC".CURRENT_USER(pUser, vRole)
    if (pUser = pRequestedBy)
        pError = "A request must be decided by another administrator."
        return -1
    endif
    return 0
end
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This is checked on the server side, in the service, not by hiding a button. A test logs in as the requesting administrator, tries to decide the request and expects this error.&lt;/p&gt;

&lt;h2&gt;
  
  
  Rule 2: the approver executes
&lt;/h2&gt;

&lt;p&gt;The approval is not a flag that the requester checks later. The &lt;code&gt;APPROVE&lt;/code&gt; operation performs the action itself, by calling the same service operation that would otherwise be called directly:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;    if (vType = "WRITEOFF")
        activate "SETTLE_SVC".WRITE_OFF(vRefId, vAmountText, vNote, vPaymentId, pError)
    elseif (vType = "PRICE")
        activate "PRODUCT_SVC".UPDATE_PRODUCT(vRefId, "", "", vAmountText, -1, 0, pError)
    elseif (vType = "TRANSFER")
        activate "OWNER_SVC".SET_OWNER(vRefId, vValue, pError)
    else
        activate "PAYABLE_SVC".PAY_BILL(vRefId, "", pError)
    endif
    if ($status &amp;lt; 0)
        return -1
    endif
    ...
    sql "UPDATE APPROVAL SET STATUS = 'APPROVED', DECIDED_AT = datetime('now', 'localtime'), DECIDED_BY = %%(vUserLit), DECISION_NOTE = %%(vDecisionLit) WHERE APPROVAL_ID = %%(pApprovalId)", "CUSTOMERS"
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Two reasons for this order:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;What was approved is what happens.&lt;/strong&gt; The amount and the new price come from the request row, not from a second input. Nobody can approve 40.00 and then book 400.00.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The existing rules still apply.&lt;/strong&gt; The write-off goes through the settlement service with all its checks and its own audit trail. If the invoice was paid in the meantime, the write-off fails, the operation returns the error, and the request stays &lt;code&gt;PENDING&lt;/code&gt; for a decision.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The status is only set after the action succeeded. Action and status update run in the same transaction, so a failure in between doesn't leave an executed action with a pending request - as long as the caller rolls back on an error.&lt;/p&gt;

&lt;p&gt;Rejecting needs a reason (1 to 200 characters) and only sets the status.&lt;/p&gt;

&lt;h2&gt;
  
  
  What this does not protect against
&lt;/h2&gt;

&lt;p&gt;This is the part readers will ask about, so here it is up front.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The direct path still exists.&lt;/strong&gt; &lt;code&gt;SETTLE_SVC.WRITE_OFF&lt;/code&gt; and the other operations were there before the approval service, and they still work when called directly by a user with the right role. The four-eyes rule holds only as long as the user interface routes these actions through &lt;code&gt;REQUEST&lt;/code&gt;. A hard guarantee would need each of the four operations to check for an approved request itself - and then the approver would need a way past that check. I chose the softer version and wrote it down.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;One person with two accounts.&lt;/strong&gt; The check compares user names. An administrator who can create users can create a second administrator account. The usual answer is organisational: user administration in different hands.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Time between request and decision.&lt;/strong&gt; The request validates the state at request time; the approval re-validates by executing the action. A price change requested at 10 % but approved after someone else changed the price will apply the requested new price anyway. Whether that is right depends on the business; here it is accepted.&lt;/p&gt;

&lt;h2&gt;
  
  
  What the tests cover
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;The limits per type, including the price of 0.&lt;/li&gt;
&lt;li&gt;Requests of all four types are stored as pending; a second request for the same object is refused; requests for objects that don't exist or aren't open are refused.&lt;/li&gt;
&lt;li&gt;Approving executes the price change, the write-off and the customer transfer.&lt;/li&gt;
&lt;li&gt;Rejecting without a reason fails and leaves the object unchanged.&lt;/li&gt;
&lt;li&gt;The requester cannot decide their own request.&lt;/li&gt;
&lt;li&gt;A user with only the edit role can request and list, but not decide.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;One case is not automated yet: an action that fails at approval time (for example an invoice paid in the meantime) must leave the request pending. It is the next test I would add.&lt;/p&gt;

&lt;h2&gt;
  
  
  Takeaways
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Check "who decides" in the service&lt;/strong&gt;, not in the user interface.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Let the approval execute the action&lt;/strong&gt; with the stored values, through the existing operations.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Validate on request and again on execution&lt;/strong&gt;; set the status only after the action succeeded.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;One pending request per object.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Write down what the design doesn't cover.&lt;/strong&gt; Someone will ask.&lt;/p&gt;

</description>
      <category>uniface</category>
      <category>sqlite</category>
      <category>security</category>
      <category>legacy</category>
    </item>
    <item>
      <title>The Uniface CSV import that never found its header line</title>
      <dc:creator>Franz</dc:creator>
      <pubDate>Sat, 17 Oct 2026 08:58:11 +0000</pubDate>
      <link>https://dev.to/f345345dfg/the-uniface-csv-import-that-never-found-its-header-line-3g14</link>
      <guid>https://dev.to/f345345dfg/the-uniface-csv-import-that-never-found-its-header-line-3g14</guid>
      <description>&lt;p&gt;The customer app from the series &lt;em&gt;Building a customer app in Uniface 10&lt;/em&gt; got a CSV import for products, prices, suppliers, contacts and customer changes. The service compiled without errors. The first test run reported 16 tests, 12 failures - and the failing tests all got the same kind of error from the service, here for the product file:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;The first line must be the header SKU;NAME;UNIT;PRICE;TAXRATE.
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The test file started with exactly that header. This article is about why the import didn't see it, and how to split text into lines in ProcScript so that it works for files from Windows, Linux and old Macs.&lt;/p&gt;

&lt;p&gt;Tested with Rocket Uniface 10.4.03 Community Edition (10.4.03.021) on Windows, October 2026.&lt;/p&gt;

&lt;h2&gt;
  
  
  The import in short
&lt;/h2&gt;

&lt;p&gt;Every import type goes through one entry, &lt;code&gt;RUN_IMPORT&lt;/code&gt;. It loads the file, checks the header, then hands every line to a type-specific entry that validates and saves it:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;    fileload pFileName, vText, "UTF-8"
    ...
    getitem vHead, vLines, 1
    vHead = $uppercase(vHead)
    call TRIM_TEXT(vHead)
    if (vHead != pHeader)
        pError = "The first line must be the header %%(pHeader)."
        return -1
    endif
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The idea: turn the text into a Uniface list - one item per line - and then use &lt;code&gt;getitem&lt;/code&gt; and &lt;code&gt;forlist&lt;/code&gt;. A Uniface list uses a special separator character, the GOLD semicolon. &lt;code&gt;$string("&amp;amp;uSEP;")&lt;/code&gt; returns it.&lt;/p&gt;

&lt;h2&gt;
  
  
  What went wrong
&lt;/h2&gt;

&lt;p&gt;The first version replaced the line break with the list separator like this, roughly:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;    vSep = $string("&amp;amp;uSEP;")
    vLines = $replace(vText, 1, "%%^", vSep, -1)
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;%%^&lt;/code&gt; is how you write a line break in a ProcScript string literal, and when you &lt;em&gt;write&lt;/em&gt; a file it does what you expect: &lt;code&gt;lfiledump&lt;/code&gt; with &lt;code&gt;"%%^"&lt;/code&gt; in the text produces CR LF on Windows. The test files were written that way. So the test file contained CR LF after every line.&lt;/p&gt;

&lt;p&gt;Reading it back, the &lt;code&gt;$replace&lt;/code&gt; did not split anything. The whole file stayed one list item, so "line 1" was the complete file, and the header comparison failed. I did not find out what character &lt;code&gt;%%^&lt;/code&gt; stands for inside the string at runtime; what I could verify is that it does not match the CR LF pair that &lt;code&gt;fileload&lt;/code&gt; returns.&lt;/p&gt;

&lt;h2&gt;
  
  
  The fix: name the characters explicitly
&lt;/h2&gt;

&lt;p&gt;&lt;code&gt;$string&lt;/code&gt; accepts character references, so the three possible line endings can be written unambiguously:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;    vSep = $string("&amp;amp;uSEP;")
    if ($scan(vText, $string("&amp;amp;#13;&amp;amp;#10;")) &amp;gt; 0)
        vText = $replace(vText, 1, $string("&amp;amp;#13;&amp;amp;#10;"), vSep, -1)
    endif
    if ($scan(vText, $string("&amp;amp;#13;")) &amp;gt; 0)
        vText = $replace(vText, 1, $string("&amp;amp;#13;"), vSep, -1)
    endif
    if ($scan(vText, $string("&amp;amp;#10;")) &amp;gt; 0)
        vText = $replace(vText, 1, $string("&amp;amp;#10;"), vSep, -1)
    endif
    vLines = vText
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The order matters. CR LF must be replaced first; otherwise replacing CR alone would leave a lone LF behind and every line break would turn into two list items with an empty one in between. After the first replacement, any remaining CR is an old-Mac line ending and any remaining LF is a Linux one.&lt;/p&gt;

&lt;p&gt;The &lt;code&gt;$scan&lt;/code&gt; checks are not needed for correctness. They just skip a full pass over a large text when the character doesn't occur.&lt;/p&gt;

&lt;p&gt;The same problem appeared in a second place: the JSON export escapes text values, and it had to turn line breaks inside a customer note into &lt;code&gt;\n&lt;/code&gt;. Same fix - drop CR, replace LF:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;    if ($scan(pText, $string("&amp;amp;#13;")) &amp;gt; 0)
        pText = $replace(pText, 1, $string("&amp;amp;#13;"), "", -1)
    endif
    if ($scan(pText, $string("&amp;amp;#10;")) &amp;gt; 0)
        pText = $replace(pText, 1, $string("&amp;amp;#10;"), "\n", -1)
    endif
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;After both changes, all 16 tests of the import and export service passed.&lt;/p&gt;

&lt;h2&gt;
  
  
  Two more things a CSV loader needs
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;A byte order mark.&lt;/strong&gt; Files saved by Excel or by &lt;code&gt;lfiledump&lt;/code&gt; with &lt;code&gt;"UTF-8"&lt;/code&gt; start with a BOM. &lt;code&gt;fileload&lt;/code&gt; keeps it as the first character of the text, and then the header is &lt;code&gt;﻿SKU;NAME;…&lt;/code&gt; - which also doesn't match. The import removes it:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight cfscript"&gt;&lt;code&gt;    &lt;span class="nx"&gt;vBom&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;$string&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s2"&gt;"&amp;amp;#xFEFF;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;vText&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="err"&gt;:&lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;vBom&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
        &lt;span class="nx"&gt;vLen&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;$length&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;vText&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;
        &lt;span class="nx"&gt;vText&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;vText&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="mi"&gt;2&lt;/span&gt;&lt;span class="err"&gt;:&lt;/span&gt;&lt;span class="nx"&gt;vLen&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;
    &lt;span class="nx"&gt;endif&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Compute the length into a variable first. Expressions inside the index brackets (&lt;code&gt;vText[2:$length(vText)-1]&lt;/code&gt;) gave me trouble earlier.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The trailing line break.&lt;/strong&gt; A file that ends with a line break produces an empty last item. The loop skips empty lines instead of reporting them as errors:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;    forlist vLine in vLines
        vNo = vNo + 1
        if (vNo &amp;gt; 1 &amp;amp; vLine != "")
            vTotal = vTotal + 1
            vFields = $replace(vLine, 1, ";", vSep, -1)
            call IMPORT_ROW(pType, vFields, pDryRun, vMsg)
            ...
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;vNo&lt;/code&gt; still counts every physical line, so an error message says "line 6" for the sixth line of the file, even if line 4 was empty. That is what a user looking at the file in an editor expects.&lt;/p&gt;

&lt;h2&gt;
  
  
  Errors per line, not per file
&lt;/h2&gt;

&lt;p&gt;A broken row should not stop the import. Every row is validated on its own, valid rows are saved, and the messages are collected:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;FAIL ... line 4: name must have 1 to 100 characters | line 5: price is not valid | line 6: tax rate must be 0, 7 or 19
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Every run - including dry runs - is written to an import log table with the number of rows, the number of errors and the error text. With &lt;code&gt;pDryRun = 1&lt;/code&gt; the import validates everything and saves nothing, which is the mode you want first with a file from someone else.&lt;/p&gt;

&lt;h2&gt;
  
  
  What it still doesn't do
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Quoted fields.&lt;/strong&gt; The import splits each line at every semicolon. A product name like &lt;code&gt;Paper; A4&lt;/code&gt; exported as &lt;code&gt;"Paper; A4"&lt;/code&gt; would be split into two fields on the way back in. The export quotes such values correctly; the import doesn't unquote them. For the data in this app - product numbers, names, prices - that was acceptable, but it means export and import are not a guaranteed round trip. If your data can contain the separator, you need a small state machine that walks the line character by character and tracks whether it is inside quotes.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Line breaks inside quoted fields.&lt;/strong&gt; Same limitation, same fix.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Encodings other than UTF-8.&lt;/strong&gt; A file saved as Windows-1252 will load, but umlauts come out wrong. The import doesn't try to guess.&lt;/p&gt;

&lt;h2&gt;
  
  
  Takeaways
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Write CR and LF as character references&lt;/strong&gt; (&lt;code&gt;$string("&amp;amp;#13;")&lt;/code&gt;, &lt;code&gt;$string("&amp;amp;#10;")&lt;/code&gt;) when you take text apart. Don't rely on &lt;code&gt;%%^&lt;/code&gt; for that.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Replace CR LF first&lt;/strong&gt;, then CR, then LF.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Strip the BOM&lt;/strong&gt; before you compare the first line with anything.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Count physical lines for messages&lt;/strong&gt;, skip empty ones for processing.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Validate per row and log every run&lt;/strong&gt;, including dry runs.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Be honest about quoting.&lt;/strong&gt; If the import splits on the separator, say so - or write the state machine.&lt;/p&gt;

</description>
      <category>uniface</category>
      <category>csv</category>
      <category>legacy</category>
      <category>debugging</category>
    </item>
    <item>
      <title>1330 Uniface tests against the real database - and the file doesn't change by a single byte</title>
      <dc:creator>Franz</dc:creator>
      <pubDate>Sat, 17 Oct 2026 08:55:27 +0000</pubDate>
      <link>https://dev.to/f345345dfg/1330-uniface-tests-against-the-real-database-and-the-file-doesnt-change-by-a-single-byte-2kb</link>
      <guid>https://dev.to/f345345dfg/1330-uniface-tests-against-the-real-database-and-the-file-doesnt-change-by-a-single-byte-2kb</guid>
      <description>&lt;p&gt;The customer app from the series &lt;em&gt;Building a customer app in Uniface 10&lt;/em&gt; has one SQLite file, &lt;code&gt;customers.db&lt;/code&gt;. There is no separate test database. All 34 test services - 1330 checks at the moment - run against exactly that file.&lt;/p&gt;

&lt;p&gt;That sounds reckless, so here is how it is kept safe, and the one number that tells me after every run that it was: the MD5 checksum of the database file is the same before and after the suite.&lt;/p&gt;

&lt;p&gt;Tested with Rocket Uniface 10.4.03 Community Edition (10.4.03.021) and the SQLite version of its SLE driver (3.50.2), October 2026.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why not a separate test database?
&lt;/h2&gt;

&lt;p&gt;Because the interesting bugs were in the schema, not in the data. Every stage of the app added tables, indexes and settings to &lt;code&gt;customers.db&lt;/code&gt;. A second database would have to follow every migration, and the day it doesn't, the tests are green against a schema nobody uses. Testing against the real file means the tests also check the migration.&lt;/p&gt;

&lt;p&gt;The price is that a test must never leave anything behind. Three rules take care of that.&lt;/p&gt;

&lt;h2&gt;
  
  
  Rule 1: Test data lives in reserved ranges
&lt;/h2&gt;

&lt;p&gt;Every test service gets its own block of customer numbers and its own markers:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Marker&lt;/th&gt;
&lt;th&gt;Used for&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Customer IDs 998600–998699&lt;/td&gt;
&lt;td&gt;customers, documents, tickets, opportunities of the current stage&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;SKU prefix &lt;code&gt;TS23-&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;products&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;user names &lt;code&gt;tst_…&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;logins, and the &lt;code&gt;CHANGED_BY&lt;/code&gt;/&lt;code&gt;CREATED_BY&lt;/code&gt; columns of everything the tests write&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;warehouse codes &lt;code&gt;TW…&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;warehouses&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Real customer numbers come from the app's number range, which is far away from 998xxx, and by convention no real user name starts with &lt;code&gt;tst_&lt;/code&gt;. That makes the cleanup a set of plain &lt;code&gt;DELETE&lt;/code&gt; statements without any guessing.&lt;/p&gt;

&lt;h2&gt;
  
  
  Rule 2: Clean up before and after
&lt;/h2&gt;

&lt;p&gt;Every test service starts and ends with the same entry:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;public operation RUN_ALL
variables
    numeric vTests, vFailures
    string vError
endvariables
    vTests = 0
    vFailures = 0
    putmess "Feature 30 tests started"
    call CLEANUP
    activate "USER_SVC".LOGOUT()
    call AS_RUNNER
    call SETUP_DATA
    call AS_RUNNER
    call TEST_DQ(vTests, vFailures)
    ...
    activate "USER_SVC".LOGOUT()
    call CLEANUP
    rollback
    putmess $concat("Feature 30 tests: ", vTests, " tests, ", vFailures, " failures")
    if (vFailures &amp;gt; 0)
        return -1
    endif
    return 0
end
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;CLEANUP&lt;/code&gt; at the start removes leftovers of a run that was interrupted. &lt;code&gt;CLEANUP&lt;/code&gt; at the end removes what this run created. It deletes children before parents, so it also works on a database with foreign keys switched on:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;entry CLEANUP
    sql "DELETE FROM TICKET_COMMENT WHERE TICKET_ID IN (SELECT TICKET_ID FROM TICKET WHERE CUSTOMER_ID BETWEEN 998600 AND 998699)", "CUSTOMERS"
    sql "DELETE FROM TICKET WHERE CUSTOMER_ID BETWEEN 998600 AND 998699", "CUSTOMERS"
    ...
    sql "DELETE FROM PAYMENT WHERE DOC_ID IN (SELECT DOC_ID FROM SALES_DOC WHERE CUSTOMER_ID BETWEEN 998600 AND 998699)", "CUSTOMERS"
    sql "DELETE FROM SALES_LINE WHERE DOC_ID IN (SELECT DOC_ID FROM SALES_DOC WHERE CUSTOMER_ID BETWEEN 998600 AND 998699)", "CUSTOMERS"
    sql "DELETE FROM SALES_DOC WHERE CUSTOMER_ID BETWEEN 998600 AND 998699", "CUSTOMERS"
    sql "DELETE FROM PRODUCT WHERE SKU LIKE 'TS23-%'", "CUSTOMERS"
    sql "DELETE FROM CUSTOMER WHERE CUSTOMER_ID BETWEEN 998600 AND 998699", "CUSTOMERS"
    sql "DELETE FROM APP_EXPORT_LOG WHERE USER_NAME LIKE 'tst_%'", "CUSTOMERS"
    sql "DELETE FROM APP_USER WHERE USER_NAME LIKE 'tst_%'", "CUSTOMERS"
    call AS_RUNNER
    return 0
end
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The real entry has about 80 statements, one per table the tests touch. It grew with every stage, and that is the main maintenance cost of this approach: &lt;strong&gt;a new table that the tests write to needs a new line here&lt;/strong&gt;, or the next run fails on a duplicate key.&lt;/p&gt;

&lt;h2&gt;
  
  
  Rule 3: Nothing is committed
&lt;/h2&gt;

&lt;p&gt;The last statement before the summary is &lt;code&gt;rollback&lt;/code&gt;. The whole test run is one transaction that never gets committed. If everything works, the &lt;code&gt;DELETE&lt;/code&gt;s of the final cleanup are not even necessary - the rollback undoes the inserts anyway.&lt;/p&gt;

&lt;p&gt;So why clean up at all? Because a rollback only undoes what has not been committed yet, and the app has operations that commit on purpose. One of them is the safe backup: before it copies the database file, it writes all pending changes so the copy is complete.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight openedge"&gt;&lt;code&gt;&lt;span class="k"&gt;public&lt;/span&gt; &lt;span class="nv"&gt;operation&lt;/span&gt; &lt;span class="nv"&gt;SAFE_BACKUP&lt;/span&gt;
    &lt;span class="p"&gt;...&lt;/span&gt;
    &lt;span class="nv"&gt;commit&lt;/span&gt;
    &lt;span class="nv"&gt;vStatus&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="err"&gt;$&lt;/span&gt;&lt;span class="k"&gt;status&lt;/span&gt;
    &lt;span class="nf"&gt;if&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;vStatus&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
        &lt;span class="nv"&gt;pError&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="err"&gt;$&lt;/span&gt;&lt;span class="nf"&gt;concat&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;Open changes could not be saved before the backup (error &lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="err"&gt;$&lt;/span&gt;&lt;span class="nv"&gt;procerror&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;).&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt;
    &lt;span class="nv"&gt;endif&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;If a test calls such an operation, everything up to that point is in the file for good, and only the cleanup gets rid of it. The two safety nets cover each other.&lt;/p&gt;

&lt;p&gt;That backup is also the one place where I gave up on testing the real thing: the new job scheduler can run a &lt;code&gt;BACKUP&lt;/code&gt; job, but the test only creates that job inactive and checks that it is listed. Running it would commit in the middle of the test run.&lt;/p&gt;

&lt;h2&gt;
  
  
  The proof: one checksum
&lt;/h2&gt;

&lt;p&gt;After a full run I compare the checksum of the database file with the value from before:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;md5sum customers.db
f0da0851db8075c05d7a3edef94e7b20  customers.db
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;On Windows, &lt;code&gt;Get-FileHash customers.db -Algorithm MD5&lt;/code&gt; gives the same value. It changes once per stage - when the schema is extended - and the new value goes into the project notes. If a test run ever changes it, something was committed during the run - even if the cleanup removed the rows again, the file would not be byte-identical any more. Either way I want to know.&lt;/p&gt;

&lt;p&gt;A checksum is a blunt tool. It can't tell me &lt;em&gt;what&lt;/em&gt; changed. But it is cheap and it covers every table, including the ones nobody thought of when writing the cleanup.&lt;/p&gt;

&lt;h2&gt;
  
  
  Files are data too
&lt;/h2&gt;

&lt;p&gt;The database was clean, but a test still failed for a reason outside it. One import test wrote &lt;code&gt;products28.csv&lt;/code&gt; with a deliberately wrong header (&lt;code&gt;Code;Name&lt;/code&gt;) to check that the import rejects it. A later rights test reused the same file name and expected a successful dry run - and got the "wrong header" error.&lt;/p&gt;

&lt;p&gt;The fix was to give that test its own valid file. The lesson: test files in the export folder need the same discipline as test rows. One file name per purpose, and never rely on what another test left in a file.&lt;/p&gt;

&lt;h2&gt;
  
  
  What it costs
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;A long cleanup entry that must grow with the schema.&lt;/li&gt;
&lt;li&gt;Operations that commit internally can't be tested end-to-end in the same run.&lt;/li&gt;
&lt;li&gt;A real database means real data could be in the way. Global figures in the tests are therefore measured as differences: read the cockpit, insert the test data, read it again, compare the delta.
&lt;/li&gt;
&lt;/ul&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;    activate "KPI2_SVC".COCKPIT("", vBefore, vError)
    ... insert one invoice, one ticket, one opportunity ...
    activate "KPI2_SVC".COCKPIT("", vAfter, vError)
    call DIFF(vBefore, vAfter, "OPEN_AR", vAr)
    call CHECK("COCKPIT sums sales, purchasing, stock, service and CRM", (vAr = 10000 &amp;amp; ...), ...)
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  What it gives
&lt;/h2&gt;

&lt;p&gt;Every stage ends with one command that runs all 34 test services:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;===== FEAT29_TST_SVC: OK =====
===== FEAT30_TST_SVC: OK =====
===== All test services: 34 suites, all passed =====
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;and one checksum that is the same as before. Together they say: the code works with the schema that is actually in the file, and the file is exactly as it was.&lt;/p&gt;

&lt;h2&gt;
  
  
  Takeaways
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Reserve ranges for test data&lt;/strong&gt; that real data can never reach, and make every cleanup a plain &lt;code&gt;DELETE&lt;/code&gt; on those ranges.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Clean up before and after&lt;/strong&gt;, and still end with &lt;code&gt;rollback&lt;/code&gt; - the two nets catch different failures.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Know which operations commit.&lt;/strong&gt; They can't be tested in the same transaction, and they are the reason the cleanup exists.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Check the database file's checksum&lt;/strong&gt; after the run. It is crude, and that is the point.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Treat test files like test rows&lt;/strong&gt;: one name per purpose.&lt;/p&gt;

</description>
      <category>uniface</category>
      <category>sqlite</category>
      <category>testing</category>
      <category>legacy</category>
    </item>
    <item>
      <title>Schema migrations for a Uniface app on SQLite - without a migration tool</title>
      <dc:creator>Franz</dc:creator>
      <pubDate>Sat, 17 Oct 2026 08:53:49 +0000</pubDate>
      <link>https://dev.to/f345345dfg/schema-migrations-for-a-uniface-app-on-sqlite-without-a-migration-tool-4id2</link>
      <guid>https://dev.to/f345345dfg/schema-migrations-for-a-uniface-app-on-sqlite-without-a-migration-tool-4id2</guid>
      <description>&lt;p&gt;Over five stages, the database of the customer app from the series &lt;em&gt;Building a customer app in Uniface 10&lt;/em&gt; grew from about 35 to 95 tables. There is no migration framework. Uniface has no Flyway, and adding a Java or .NET tool just to run a few &lt;code&gt;CREATE TABLE&lt;/code&gt; statements against a single SQLite file felt like the wrong trade.&lt;/p&gt;

&lt;p&gt;What replaced it is a short, boring procedure and two checks inside the app. Both are below, including the parts that are specific to the Uniface IDE.&lt;/p&gt;

&lt;p&gt;Tested with Rocket Uniface 10.4.03 Community Edition (10.4.03.021) and the SQLite version of its SLE driver (3.50.2), October 2026.&lt;/p&gt;

&lt;h2&gt;
  
  
  The rule: only add, never change
&lt;/h2&gt;

&lt;p&gt;The last five stages only &lt;strong&gt;added&lt;/strong&gt; tables, indexes and settings. No column was renamed, no table rebuilt, no data moved. New information about an existing record goes into a new table with the same key - for example company data for a customer:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight sql"&gt;&lt;code&gt;&lt;span class="k"&gt;CREATE&lt;/span&gt; &lt;span class="k"&gt;TABLE&lt;/span&gt; &lt;span class="n"&gt;CUSTOMER_PARTY&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;
  &lt;span class="n"&gt;CUSTOMER_ID&lt;/span&gt;  &lt;span class="nb"&gt;INTEGER&lt;/span&gt; &lt;span class="k"&gt;NOT&lt;/span&gt; &lt;span class="k"&gt;NULL&lt;/span&gt; &lt;span class="k"&gt;PRIMARY&lt;/span&gt; &lt;span class="k"&gt;KEY&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="n"&gt;PARTY_TYPE&lt;/span&gt;   &lt;span class="nb"&gt;VARCHAR&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;10&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;NOT&lt;/span&gt; &lt;span class="k"&gt;NULL&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="n"&gt;COMPANY_NAME&lt;/span&gt; &lt;span class="nb"&gt;VARCHAR&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;100&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
  &lt;span class="p"&gt;...&lt;/span&gt;
&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;instead of &lt;code&gt;ALTER TABLE CUSTOMER ADD COLUMN …&lt;/code&gt;. That keeps every existing form, entity and service untouched, and it makes every migration safe to reason about: a stage either created its objects or it didn't. There is no half-migrated table.&lt;/p&gt;

&lt;p&gt;It is not free. Reading a customer with all its details now means joins over several 1:1 tables, and the "customer" exists in more places than one. For an app that grows in stages, that was the better trade; for a schema designed in one go, it would not be.&lt;/p&gt;

&lt;h2&gt;
  
  
  New tables, no new entities
&lt;/h2&gt;

&lt;p&gt;In Uniface, a table is usually also an entity in an application model, with fields, keys and relationships, so forms can paint it. The 60 new tables have none of that. They are only ever read and written by services, and the services use &lt;code&gt;sql&lt;/code&gt; and &lt;code&gt;sql/data&lt;/code&gt; with the path from the assignment file:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight ini"&gt;&lt;code&gt;&lt;span class="nn"&gt;[PATHS]&lt;/span&gt;
&lt;span class="err"&gt;$CUSTOMERS&lt;/span&gt;   &lt;span class="err"&gt;SLE:C:\Projects\CustomerApp\customers.db&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;





&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight sql"&gt;&lt;code&gt;    &lt;span class="k"&gt;sql&lt;/span&gt;&lt;span class="o"&gt;/&lt;/span&gt;&lt;span class="k"&gt;data&lt;/span&gt; &lt;span class="nv"&gt;"SELECT JOB_CODE, JOB_TYPE, INTERVAL_DAYS, NEXT_RUN FROM JOB_DEF WHERE IS_ACTIVE = 1 AND NEXT_RUN &amp;lt;= '%%(vDay)' ORDER BY NEXT_RUN, JOB_CODE"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;"CUSTOMERS"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Like every service in this app, each new service component has exactly one entity painted on it: a non-database dummy, &lt;code&gt;SVC_DMY.NOMODEL&lt;/code&gt;. The compiler answers with warning 1016 ("entity not found in application model, generating now"), which is expected and the only warning these services produce.&lt;/p&gt;

&lt;p&gt;This decision makes a migration a pure database change. There is nothing to import into the repository, no model to keep in sync, and the next compile doesn't care. The price: forms can't paint these tables directly; a form that needs them calls a service operation.&lt;/p&gt;

&lt;h2&gt;
  
  
  Step 1: one script per stage
&lt;/h2&gt;

&lt;p&gt;Each stage has one SQL file with plain statements, one per line:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight sql"&gt;&lt;code&gt;&lt;span class="k"&gt;CREATE&lt;/span&gt; &lt;span class="k"&gt;TABLE&lt;/span&gt; &lt;span class="n"&gt;APPROVAL&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;APPROVAL_ID&lt;/span&gt; &lt;span class="nb"&gt;INTEGER&lt;/span&gt; &lt;span class="k"&gt;NOT&lt;/span&gt; &lt;span class="k"&gt;NULL&lt;/span&gt; &lt;span class="k"&gt;PRIMARY&lt;/span&gt; &lt;span class="k"&gt;KEY&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;APPROVAL_TYPE&lt;/span&gt; &lt;span class="nb"&gt;VARCHAR&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;12&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;NOT&lt;/span&gt; &lt;span class="k"&gt;NULL&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;...);&lt;/span&gt;
&lt;span class="k"&gt;CREATE&lt;/span&gt; &lt;span class="k"&gt;INDEX&lt;/span&gt; &lt;span class="n"&gt;IX_APPROVAL_STATUS&lt;/span&gt; &lt;span class="k"&gt;ON&lt;/span&gt; &lt;span class="n"&gt;APPROVAL&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;STATUS&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;REQUESTED_AT&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="k"&gt;CREATE&lt;/span&gt; &lt;span class="k"&gt;TABLE&lt;/span&gt; &lt;span class="n"&gt;PRICE_HISTORY&lt;/span&gt; &lt;span class="p"&gt;(...);&lt;/span&gt;
&lt;span class="k"&gt;CREATE&lt;/span&gt; &lt;span class="k"&gt;INDEX&lt;/span&gt; &lt;span class="n"&gt;IX_PRICE_HISTORY_PRODUCT&lt;/span&gt; &lt;span class="k"&gt;ON&lt;/span&gt; &lt;span class="n"&gt;PRICE_HISTORY&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;PRODUCT_ID&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;VALID_FROM&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="p"&gt;...&lt;/span&gt;
&lt;span class="k"&gt;INSERT&lt;/span&gt; &lt;span class="k"&gt;OR&lt;/span&gt; &lt;span class="k"&gt;REPLACE&lt;/span&gt; &lt;span class="k"&gt;INTO&lt;/span&gt; &lt;span class="n"&gt;APP_SETTING&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;SETTING_KEY&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;SETTING_VALUE&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;VALUES&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'SCHEMA_VERSION'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'23'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The last line records the version in a settings table. That one row is what the app later checks.&lt;/p&gt;

&lt;p&gt;The script and a short model description go into the folder of the stage, and the script is the documentation of what changed. Nobody has to reconstruct it from &lt;code&gt;sqlite_master&lt;/code&gt; later.&lt;/p&gt;

&lt;h2&gt;
  
  
  Step 2: back up the file
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;backup\customers_before_stage23_20261016.db
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;A plain copy of the database file, named after the stage and the date. With SQLite, the whole database is one file - this is the cheapest rollback plan there is. Take the copy when nothing is writing to the file; a copy of a file in the middle of a write is not a backup.&lt;/p&gt;

&lt;h2&gt;
  
  
  Step 3: dry run on a copy
&lt;/h2&gt;

&lt;p&gt;The script runs first against a second copy of the database, with a SQLite client outside the app. Then three checks on that copy:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight sql"&gt;&lt;code&gt;&lt;span class="n"&gt;PRAGMA&lt;/span&gt; &lt;span class="n"&gt;integrity_check&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="k"&gt;SELECT&lt;/span&gt; &lt;span class="k"&gt;type&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;name&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="k"&gt;sql&lt;/span&gt; &lt;span class="k"&gt;FROM&lt;/span&gt; &lt;span class="n"&gt;sqlite_master&lt;/span&gt; &lt;span class="k"&gt;ORDER&lt;/span&gt; &lt;span class="k"&gt;BY&lt;/span&gt; &lt;span class="k"&gt;type&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;name&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="k"&gt;SELECT&lt;/span&gt; &lt;span class="n"&gt;SETTING_VALUE&lt;/span&gt; &lt;span class="k"&gt;FROM&lt;/span&gt; &lt;span class="n"&gt;APP_SETTING&lt;/span&gt; &lt;span class="k"&gt;WHERE&lt;/span&gt; &lt;span class="n"&gt;SETTING_KEY&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s1"&gt;'SCHEMA_VERSION'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The &lt;code&gt;sqlite_master&lt;/code&gt; output of the copy is saved. After the real migration, the same query on the real file must return exactly the same rows. That comparison catches the typical mistakes of a manual migration: a statement that was skipped, a statement executed twice (which fails for &lt;code&gt;CREATE TABLE&lt;/code&gt;, but not for &lt;code&gt;INSERT OR REPLACE&lt;/code&gt;), or a statement run against the wrong file.&lt;/p&gt;

&lt;h2&gt;
  
  
  Step 4: apply it, one statement at a time
&lt;/h2&gt;

&lt;p&gt;The real migration runs inside the Uniface IDE, because that is where the connection to the database is configured. The IDE has an &lt;em&gt;EDIT SQL&lt;/em&gt; dialog: a text field, an SQL button, a result area. Two things about it are worth knowing:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Commit explicitly.&lt;/strong&gt; Statements executed there are not committed automatically. Each statement is followed by &lt;code&gt;COMMIT&lt;/code&gt; in the same dialog. Until then the change is not in the file.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Don't use Ctrl+A in the dialog.&lt;/strong&gt; In the Uniface IDE, Ctrl+A is the &lt;code&gt;^ACCEPT&lt;/code&gt; key and closes the dialog. To replace the text in the field, use Ctrl+Home, then Ctrl+Shift+End, then type.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;One statement at a time sounds slow. For 15 statements it takes a few minutes, and every statement gets its own result. A script with one failed line in the middle would leave you guessing which statements went through.&lt;/p&gt;

&lt;p&gt;The backup and the dry run were the work. The real run should be uneventful.&lt;/p&gt;

&lt;h2&gt;
  
  
  Step 5: let the app check itself
&lt;/h2&gt;

&lt;p&gt;Two operations check the schema from inside the app.&lt;/p&gt;

&lt;p&gt;A schema check compares the tables that should exist with the tables that do, and reads the version:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;    call GET_TEXT("SELECT SETTING_VALUE FROM APP_SETTING WHERE SETTING_KEY = 'SCHEMA_VERSION'", pVersion)
    sql/data "SELECT name FROM sqlite_master WHERE type = 'table'", "CUSTOMERS"
    ...
    if (pVersion != "23")
        pError = "The schema version is %%(pVersion), expected 23."
        return -1
    endif
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;A broader system check does the same for the tables and indexes of the newer stages, with one query each. SQLite's &lt;code&gt;VALUES&lt;/code&gt; clause makes the expected list a table, and &lt;code&gt;NOT IN&lt;/code&gt; against &lt;code&gt;sqlite_master&lt;/code&gt; returns what is missing:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;entry MISSING_OBJECTS
params
    string pType : IN
    string pValues : IN
    string pMissing : OUT
endparams
    call GET_TEXT("SELECT COALESCE(GROUP_CONCAT(v.column1, ', '), '') FROM (VALUES %%(pValues)) v WHERE v.column1 NOT IN (SELECT name FROM sqlite_master WHERE type = '%%(pType)')", pMissing)
    return 0
end
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;called with&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;    vIndexes = "('UX_PRODUCT_SKU'), ('UX_SALES_DOC_NO'), ('IX_SALES_DOC_CUSTOMER'), ..."
    call MISSING_OBJECTS("index", vIndexes, vMissing)
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;An empty result means everything is there. A non-empty one names exactly what is missing - which is the information you need at the moment the app starts behaving strangely after someone restored an old backup.&lt;/p&gt;

&lt;p&gt;The same system check also validates that numeric settings are numbers:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight sql"&gt;&lt;code&gt;&lt;span class="k"&gt;SELECT&lt;/span&gt; &lt;span class="n"&gt;COALESCE&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;GROUP_CONCAT&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;SETTING_KEY&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;', '&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt; &lt;span class="s1"&gt;''&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
  &lt;span class="k"&gt;FROM&lt;/span&gt; &lt;span class="n"&gt;APP_SETTING&lt;/span&gt;
 &lt;span class="k"&gt;WHERE&lt;/span&gt; &lt;span class="n"&gt;SETTING_KEY&lt;/span&gt; &lt;span class="k"&gt;IN&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'SLA_HIGH'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'DUNNING_FEE_1'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;...)&lt;/span&gt;
   &lt;span class="k"&gt;AND&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;COALESCE&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;SETTING_VALUE&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;''&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s1"&gt;''&lt;/span&gt; &lt;span class="k"&gt;OR&lt;/span&gt; &lt;span class="n"&gt;SETTING_VALUE&lt;/span&gt; &lt;span class="n"&gt;GLOB&lt;/span&gt; &lt;span class="s1"&gt;'*[^0-9]*'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;GLOB '*[^0-9]*'&lt;/code&gt; matches any value that contains a character other than a digit.&lt;/p&gt;

&lt;h2&gt;
  
  
  Step 6: run all tests and note the checksum
&lt;/h2&gt;

&lt;p&gt;The full test suite runs against the migrated file. All tests run inside one transaction that is rolled back, so the file must be byte-identical afterwards. Its MD5 checksum goes into the project notes - once per stage, after the migration. From then on, any change of that checksum outside a planned migration is a finding.&lt;/p&gt;

&lt;h2&gt;
  
  
  What I would do differently with a bigger team
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;A version table instead of a setting.&lt;/strong&gt; One row per applied script with a timestamp and a checksum of the script. That is what migration tools do, and it answers "was script 21 applied twice?" without detective work.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Statements that are safe to repeat.&lt;/strong&gt; &lt;code&gt;CREATE TABLE IF NOT EXISTS&lt;/code&gt; and &lt;code&gt;CREATE INDEX IF NOT EXISTS&lt;/code&gt; would make a re-run harmless. I left them out on purpose, so that an accidental second run fails loudly - with one person and a dry run, loud failure was more useful than silent success.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Real &lt;code&gt;ALTER TABLE&lt;/code&gt; migrations&lt;/strong&gt; would need SQLite's "create new table, copy, drop, rename" dance for anything beyond adding a column. The add-only rule avoided that entirely.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Takeaways
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Only add&lt;/strong&gt; while you can: new tables with the same key instead of changed tables.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;One script per stage&lt;/strong&gt;, ending with the version number.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Back up, dry run on a copy, compare &lt;code&gt;sqlite_master&lt;/code&gt;.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Apply one statement at a time and commit explicitly&lt;/strong&gt; - in the Uniface IDE, without Ctrl+A.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Let the app check its own schema&lt;/strong&gt;: version, tables, indexes, settings.&lt;/p&gt;

</description>
      <category>uniface</category>
      <category>sqlite</category>
      <category>migration</category>
      <category>legacy</category>
    </item>
    <item>
      <title>Business reports in a Uniface service - year comparison, cash forecast and sales funnel with plain SQL</title>
      <dc:creator>Franz</dc:creator>
      <pubDate>Sat, 17 Oct 2026 08:51:08 +0000</pubDate>
      <link>https://dev.to/f345345dfg/business-reports-in-a-uniface-service-year-comparison-cash-forecast-and-sales-funnel-with-plain-f8k</link>
      <guid>https://dev.to/f345345dfg/business-reports-in-a-uniface-service-year-comparison-cash-forecast-and-sales-funnel-with-plain-f8k</guid>
      <description>&lt;p&gt;The last stage of the customer app from the series &lt;em&gt;Building a customer app in Uniface 10&lt;/em&gt; added a reporting service: a cockpit, a month-end report, a year comparison, margins, a cash forecast, stock turnover, service figures and a sales funnel. All of it lives in one Uniface service, &lt;code&gt;KPI2_SVC&lt;/code&gt;: ProcScript operations that build plain SQL, run it against SQLite and return Uniface lists or write HTML files.&lt;/p&gt;

&lt;p&gt;This article shows how the service hands figures to its callers, how it writes HTML without a report painter, four of the queries that needed a trick, and one deliberate restriction: no window functions, no CTEs, and never more than one scalar subquery in a select list.&lt;/p&gt;

&lt;p&gt;Tested with Rocket Uniface 10.4.03 Community Edition (10.4.03.021) and the SQLite version of its SLE driver (3.50.2), October 2026.&lt;/p&gt;

&lt;h2&gt;
  
  
  A cockpit as an associative list
&lt;/h2&gt;

&lt;p&gt;The cockpit returns 18 figures. Instead of 18 &lt;code&gt;OUT&lt;/code&gt; parameters, the operation returns one Uniface list with &lt;code&gt;key=value&lt;/code&gt; items, built with &lt;code&gt;putitem/id&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;    call COUNT_SQL("SELECT COALESCE(SUM(d.NET_CENTS), 0) FROM SALES_DOC d WHERE %%(vCond) AND substr(d.DOC_DATE, 1, 7) = substr('%%(pDay)', 1, 7) AND d.DOC_DATE &amp;lt;= '%%(pDay)'", vN)
    putitem/id pData, "SALES_MONTH", vN
    call COUNT_SQL("SELECT COUNT(*) FROM SALES_DOC WHERE DOC_TYPE = 'ORDER' AND STATUS IN ('OPEN', 'CONFIRMED')", vN)
    putitem/id pData, "OPEN_ORDERS", vN
    ...
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;A caller picks what it needs with &lt;code&gt;getitem/id vN, vData, "OPEN_AR"&lt;/code&gt;. Adding a figure later doesn't change the signature, and every existing caller keeps working.&lt;/p&gt;

&lt;p&gt;Each figure is its own small statement. That is 18 round trips instead of one big query - for a cockpit opened a few times a day on a local SQLite file, simplicity wins.&lt;/p&gt;

&lt;h2&gt;
  
  
  HTML without a report painter
&lt;/h2&gt;

&lt;p&gt;The reports are HTML files written with &lt;code&gt;lfiledump&lt;/code&gt;. A helper entry turns one cockpit figure into a table row, formats cents as currency and appends the row to the page:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;entry KV_ROW
params
    string pLabel : IN
    string pData : IN
    string pKey : IN
    numeric pCents : IN
    string pHtml : INOUT
endparams
variables
    string vText, vLine
    numeric vN
endvariables
    getitem/id vN, pData, pKey
    if (pCents = 1)
        call FMT_CENTS(vN, vText)
    else
        vText = "%%(vN)"
    endif
    vLine = "&amp;lt;tr&amp;gt;&amp;lt;td&amp;gt;%%(pLabel)&amp;lt;/td&amp;gt;&amp;lt;td style='text-align:right'&amp;gt;%%(vText)&amp;lt;/td&amp;gt;&amp;lt;/tr&amp;gt;"
    pHtml = $concat(pHtml, vLine, "%%^")
    return 0
end
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;and the page is a list of such calls:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;    call KV_ROW("Open receivables", vData, "OPEN_AR", 1, vRows)
    call KV_ROW("Overdue receivables", vData, "OVERDUE_AR", 1, vRows)
    call KV_ROW("Open tickets", vData, "OPEN_TICKETS", 0, vRows)
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Two ProcScript details: &lt;code&gt;$concat&lt;/code&gt; takes at most five arguments, so longer pages are built in steps; and &lt;code&gt;%%^&lt;/code&gt; in a string literal writes a line break when the file is saved. Every operation that writes a file also logs the export with user, type, row count and file name.&lt;/p&gt;

&lt;h2&gt;
  
  
  The restriction first
&lt;/h2&gt;

&lt;p&gt;SQLite 3.50 supports common table expressions and window functions. A running total would be one &lt;code&gt;SUM(…) OVER (ORDER BY …)&lt;/code&gt;. I still don't use them here.&lt;/p&gt;

&lt;p&gt;The reason is not SQLite, it's the path between ProcScript and SQLite. Earlier in the project, a single &lt;code&gt;SELECT&lt;/code&gt; without a &lt;code&gt;FROM&lt;/code&gt; clause ended the whole Uniface process - no error, no log line, just gone (the story is in &lt;em&gt;The Uniface test that "hung" had died&lt;/em&gt;). Statements with several scalar subqueries in the select list also caused trouble. Since then the rules for SQL in this app are boring on purpose:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;derived tables in &lt;code&gt;FROM&lt;/code&gt; instead of scalar subqueries in the select list,&lt;/li&gt;
&lt;li&gt;a calculation without a table still gets one: &lt;code&gt;SELECT … FROM (SELECT 1 AS X)&lt;/code&gt;,&lt;/li&gt;
&lt;li&gt;no window functions or &lt;code&gt;WITH&lt;/code&gt;, because I haven't tested them through this driver and I don't want to find out in production.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;If your SQLite access goes through a mature driver, use window functions. If it goes through something you can't debug, the patterns below still work.&lt;/p&gt;

&lt;h2&gt;
  
  
  1. Twelve months without a calendar table
&lt;/h2&gt;

&lt;p&gt;A year comparison needs one row per month - including months without revenue. Without a calendar table and without a recursive CTE, a derived table of twelve literal rows does it:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight sql"&gt;&lt;code&gt;&lt;span class="k"&gt;SELECT&lt;/span&gt; &lt;span class="n"&gt;x&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;N&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;x&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="k"&gt;C&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;x&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;P&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;x&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="k"&gt;C&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="n"&gt;x&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;P&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
       &lt;span class="k"&gt;CASE&lt;/span&gt; &lt;span class="k"&gt;WHEN&lt;/span&gt; &lt;span class="n"&gt;x&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;P&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;&amp;gt;&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt; &lt;span class="k"&gt;THEN&lt;/span&gt; &lt;span class="n"&gt;ROUND&lt;/span&gt;&lt;span class="p"&gt;((&lt;/span&gt;&lt;span class="n"&gt;x&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="k"&gt;C&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="n"&gt;x&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;P&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="mi"&gt;100&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt; &lt;span class="o"&gt;/&lt;/span&gt; &lt;span class="n"&gt;x&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;P&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;ELSE&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt; &lt;span class="k"&gt;END&lt;/span&gt;
&lt;span class="k"&gt;FROM&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;
  &lt;span class="k"&gt;SELECT&lt;/span&gt; &lt;span class="n"&gt;m&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;N&lt;/span&gt; &lt;span class="k"&gt;AS&lt;/span&gt; &lt;span class="n"&gt;N&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;COALESCE&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;a&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;S&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;AS&lt;/span&gt; &lt;span class="k"&gt;C&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;COALESCE&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;b&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;S&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;AS&lt;/span&gt; &lt;span class="n"&gt;P&lt;/span&gt;
  &lt;span class="k"&gt;FROM&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;SELECT&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt; &lt;span class="k"&gt;AS&lt;/span&gt; &lt;span class="n"&gt;N&lt;/span&gt; &lt;span class="k"&gt;UNION&lt;/span&gt; &lt;span class="k"&gt;ALL&lt;/span&gt; &lt;span class="k"&gt;SELECT&lt;/span&gt; &lt;span class="mi"&gt;2&lt;/span&gt; &lt;span class="k"&gt;UNION&lt;/span&gt; &lt;span class="k"&gt;ALL&lt;/span&gt; &lt;span class="k"&gt;SELECT&lt;/span&gt; &lt;span class="mi"&gt;3&lt;/span&gt; &lt;span class="k"&gt;UNION&lt;/span&gt; &lt;span class="k"&gt;ALL&lt;/span&gt; &lt;span class="k"&gt;SELECT&lt;/span&gt; &lt;span class="mi"&gt;4&lt;/span&gt;
        &lt;span class="k"&gt;UNION&lt;/span&gt; &lt;span class="k"&gt;ALL&lt;/span&gt; &lt;span class="k"&gt;SELECT&lt;/span&gt; &lt;span class="mi"&gt;5&lt;/span&gt; &lt;span class="k"&gt;UNION&lt;/span&gt; &lt;span class="k"&gt;ALL&lt;/span&gt; &lt;span class="k"&gt;SELECT&lt;/span&gt; &lt;span class="mi"&gt;6&lt;/span&gt; &lt;span class="k"&gt;UNION&lt;/span&gt; &lt;span class="k"&gt;ALL&lt;/span&gt; &lt;span class="k"&gt;SELECT&lt;/span&gt; &lt;span class="mi"&gt;7&lt;/span&gt; &lt;span class="k"&gt;UNION&lt;/span&gt; &lt;span class="k"&gt;ALL&lt;/span&gt; &lt;span class="k"&gt;SELECT&lt;/span&gt; &lt;span class="mi"&gt;8&lt;/span&gt;
        &lt;span class="k"&gt;UNION&lt;/span&gt; &lt;span class="k"&gt;ALL&lt;/span&gt; &lt;span class="k"&gt;SELECT&lt;/span&gt; &lt;span class="mi"&gt;9&lt;/span&gt; &lt;span class="k"&gt;UNION&lt;/span&gt; &lt;span class="k"&gt;ALL&lt;/span&gt; &lt;span class="k"&gt;SELECT&lt;/span&gt; &lt;span class="mi"&gt;10&lt;/span&gt; &lt;span class="k"&gt;UNION&lt;/span&gt; &lt;span class="k"&gt;ALL&lt;/span&gt; &lt;span class="k"&gt;SELECT&lt;/span&gt; &lt;span class="mi"&gt;11&lt;/span&gt; &lt;span class="k"&gt;UNION&lt;/span&gt; &lt;span class="k"&gt;ALL&lt;/span&gt; &lt;span class="k"&gt;SELECT&lt;/span&gt; &lt;span class="mi"&gt;12&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="n"&gt;m&lt;/span&gt;
  &lt;span class="k"&gt;LEFT&lt;/span&gt; &lt;span class="k"&gt;JOIN&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;SELECT&lt;/span&gt; &lt;span class="k"&gt;CAST&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;substr&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;d&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;DOC_DATE&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;6&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;2&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;AS&lt;/span&gt; &lt;span class="nb"&gt;INTEGER&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;AS&lt;/span&gt; &lt;span class="n"&gt;M&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="k"&gt;SUM&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;d&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;NET_CENTS&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;AS&lt;/span&gt; &lt;span class="n"&gt;S&lt;/span&gt;
               &lt;span class="k"&gt;FROM&lt;/span&gt; &lt;span class="n"&gt;SALES_DOC&lt;/span&gt; &lt;span class="n"&gt;d&lt;/span&gt;
              &lt;span class="k"&gt;WHERE&lt;/span&gt; &lt;span class="n"&gt;d&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;DOC_TYPE&lt;/span&gt; &lt;span class="k"&gt;IN&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'INVOICE'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'CREDIT'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;AND&lt;/span&gt; &lt;span class="n"&gt;d&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;STATUS&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;&amp;gt;&lt;/span&gt; &lt;span class="s1"&gt;'DRAFT'&lt;/span&gt;
                &lt;span class="k"&gt;AND&lt;/span&gt; &lt;span class="n"&gt;substr&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;d&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;DOC_DATE&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;4&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s1"&gt;'2025'&lt;/span&gt;
              &lt;span class="k"&gt;GROUP&lt;/span&gt; &lt;span class="k"&gt;BY&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="n"&gt;a&lt;/span&gt; &lt;span class="k"&gt;ON&lt;/span&gt; &lt;span class="n"&gt;a&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;M&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;m&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;N&lt;/span&gt;
  &lt;span class="k"&gt;LEFT&lt;/span&gt; &lt;span class="k"&gt;JOIN&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="cm"&gt;/* same for 2024 */&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="n"&gt;b&lt;/span&gt; &lt;span class="k"&gt;ON&lt;/span&gt; &lt;span class="n"&gt;b&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;M&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;m&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;N&lt;/span&gt;
&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="n"&gt;x&lt;/span&gt;
&lt;span class="k"&gt;ORDER&lt;/span&gt; &lt;span class="k"&gt;BY&lt;/span&gt; &lt;span class="n"&gt;x&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;N&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Three details:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Aggregate first, join second.&lt;/strong&gt; Each year is grouped into at most twelve rows before the join. Joining raw documents to months and grouping afterwards would multiply rows when both years have data.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;* 100.0&lt;/code&gt;&lt;/strong&gt; makes the percentage a real division. &lt;code&gt;(C - P) * 100 / P&lt;/code&gt; with integers truncates.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Revenue includes credit notes&lt;/strong&gt; with their negative amounts, and cancelled invoices stay in. An invoice and its cancelling credit note add up to zero over the year; if they fall into different months, the first month shows the revenue and the second the reversal.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  2. A cash forecast in weekly buckets
&lt;/h2&gt;

&lt;p&gt;The forecast answers: how much money comes in and goes out per week, starting today? Incoming is the open amount of issued invoices by due date; outgoing is open supplier bills by due date. Anything already overdue belongs into the first week - it is due &lt;em&gt;now&lt;/em&gt;.&lt;/p&gt;

&lt;p&gt;The number of weeks is a parameter (1 to 26), so the week list is built in ProcScript:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight cfscript"&gt;&lt;code&gt;    &lt;span class="nx"&gt;vWeeks&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"SELECT 0 AS W"&lt;/span&gt;
    &lt;span class="nx"&gt;vI&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;
    &lt;span class="k"&gt;while&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;vI&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;&lt;/span&gt; &lt;span class="nx"&gt;pWeeks&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
        &lt;span class="nx"&gt;vWeeks&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"%%(vWeeks) UNION ALL SELECT %%(vI)"&lt;/span&gt;
        &lt;span class="nx"&gt;vI&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;vI&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;
    &lt;span class="nx"&gt;endwhile&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Each side computes its bucket with a &lt;code&gt;CASE&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight sql"&gt;&lt;code&gt;&lt;span class="k"&gt;SELECT&lt;/span&gt; &lt;span class="k"&gt;CASE&lt;/span&gt; &lt;span class="k"&gt;WHEN&lt;/span&gt; &lt;span class="n"&gt;COALESCE&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;o&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;DUE_DATE&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'2026-10-17'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;=&lt;/span&gt; &lt;span class="s1"&gt;'2026-10-17'&lt;/span&gt; &lt;span class="k"&gt;THEN&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;
            &lt;span class="k"&gt;ELSE&lt;/span&gt; &lt;span class="k"&gt;CAST&lt;/span&gt;&lt;span class="p"&gt;((&lt;/span&gt;&lt;span class="n"&gt;julianday&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;o&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;DUE_DATE&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="n"&gt;julianday&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'2026-10-17'&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt; &lt;span class="o"&gt;/&lt;/span&gt; &lt;span class="mi"&gt;7&lt;/span&gt; &lt;span class="k"&gt;AS&lt;/span&gt; &lt;span class="nb"&gt;INTEGER&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
       &lt;span class="k"&gt;END&lt;/span&gt; &lt;span class="k"&gt;AS&lt;/span&gt; &lt;span class="n"&gt;W&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
       &lt;span class="k"&gt;SUM&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;o&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;OPEN_CENTS&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;AS&lt;/span&gt; &lt;span class="n"&gt;S&lt;/span&gt;
  &lt;span class="k"&gt;FROM&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="cm"&gt;/* open invoices with open amount */&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="n"&gt;o&lt;/span&gt;
 &lt;span class="k"&gt;GROUP&lt;/span&gt; &lt;span class="k"&gt;BY&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;CAST(… AS INTEGER)&lt;/code&gt; truncates towards zero. For positive day differences that is the floor, which is what we want; negative differences are already caught by the &lt;code&gt;CASE&lt;/code&gt;. Without it, an invoice 3 days overdue would land in week 0 by luck and one 10 days overdue in week -1, which doesn't exist in the week list and would silently disappear.&lt;/p&gt;

&lt;p&gt;The week dates come from &lt;code&gt;julianday&lt;/code&gt; arithmetic, not from string concatenation:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight sql"&gt;&lt;code&gt;&lt;span class="k"&gt;SELECT&lt;/span&gt; &lt;span class="nb"&gt;date&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;julianday&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'2026-10-17'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="n"&gt;w&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;W&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="mi"&gt;7&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
       &lt;span class="nb"&gt;date&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;julianday&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'2026-10-17'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="n"&gt;w&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;W&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="mi"&gt;7&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="mi"&gt;6&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
       &lt;span class="n"&gt;COALESCE&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;a&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;S&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt; &lt;span class="n"&gt;COALESCE&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;b&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;S&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt; &lt;span class="n"&gt;COALESCE&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;a&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;S&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="n"&gt;COALESCE&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;b&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;S&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
  &lt;span class="k"&gt;FROM&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="cm"&gt;/* weeks */&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="n"&gt;w&lt;/span&gt;
  &lt;span class="k"&gt;LEFT&lt;/span&gt; &lt;span class="k"&gt;JOIN&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="cm"&gt;/* incoming */&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="n"&gt;a&lt;/span&gt; &lt;span class="k"&gt;ON&lt;/span&gt; &lt;span class="n"&gt;a&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;W&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;w&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;W&lt;/span&gt;
  &lt;span class="k"&gt;LEFT&lt;/span&gt; &lt;span class="k"&gt;JOIN&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="cm"&gt;/* outgoing */&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="n"&gt;b&lt;/span&gt; &lt;span class="k"&gt;ON&lt;/span&gt; &lt;span class="n"&gt;b&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;W&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;w&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;W&lt;/span&gt;
 &lt;span class="k"&gt;ORDER&lt;/span&gt; &lt;span class="k"&gt;BY&lt;/span&gt; &lt;span class="n"&gt;w&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;W&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;date()&lt;/code&gt; accepts a Julian day number, so &lt;code&gt;date(julianday(x) + 7)&lt;/code&gt; is "one week after x" without building a modifier string like &lt;code&gt;'+' || n || ' days'&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;The open amount itself is a derived table that is reused by four reports, so it lives in one entry:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight sql"&gt;&lt;code&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;SELECT&lt;/span&gt; &lt;span class="n"&gt;d&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;DOC_ID&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;d&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;CUSTOMER_ID&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;d&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;DOC_DATE&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;d&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;DUE_DATE&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="n"&gt;d&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;GROSS_CENTS&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="n"&gt;COALESCE&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;p&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;PAID&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;AS&lt;/span&gt; &lt;span class="n"&gt;OPEN_CENTS&lt;/span&gt;
   &lt;span class="k"&gt;FROM&lt;/span&gt; &lt;span class="n"&gt;SALES_DOC&lt;/span&gt; &lt;span class="n"&gt;d&lt;/span&gt;
   &lt;span class="k"&gt;LEFT&lt;/span&gt; &lt;span class="k"&gt;JOIN&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;SELECT&lt;/span&gt; &lt;span class="n"&gt;DOC_ID&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="k"&gt;SUM&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;AMOUNT_CENTS&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;AS&lt;/span&gt; &lt;span class="n"&gt;PAID&lt;/span&gt;
                &lt;span class="k"&gt;FROM&lt;/span&gt; &lt;span class="n"&gt;PAYMENT&lt;/span&gt; &lt;span class="k"&gt;WHERE&lt;/span&gt; &lt;span class="n"&gt;IS_REVERSED&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt; &lt;span class="k"&gt;GROUP&lt;/span&gt; &lt;span class="k"&gt;BY&lt;/span&gt; &lt;span class="n"&gt;DOC_ID&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="n"&gt;p&lt;/span&gt;
          &lt;span class="k"&gt;ON&lt;/span&gt; &lt;span class="n"&gt;p&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;DOC_ID&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;d&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;DOC_ID&lt;/span&gt;
  &lt;span class="k"&gt;WHERE&lt;/span&gt; &lt;span class="n"&gt;d&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;DOC_TYPE&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s1"&gt;'INVOICE'&lt;/span&gt; &lt;span class="k"&gt;AND&lt;/span&gt; &lt;span class="n"&gt;d&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;STATUS&lt;/span&gt; &lt;span class="k"&gt;IN&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'ISSUED'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'PARTLY_PAID'&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  3. A sales funnel that follows the documents
&lt;/h2&gt;

&lt;p&gt;A funnel counts how many opportunities reached each stage. The naive version counts quotes, orders and invoices in the period - but those numbers are unrelated. Ten quotes and ten invoices don't mean the quotes turned into the invoices.&lt;/p&gt;

&lt;p&gt;This funnel follows the chain: opportunity → its quote → an order created from that quote → an invoice created from that order. Each step is a join on the stored reference:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight sql"&gt;&lt;code&gt;&lt;span class="c1"&gt;-- quotes&lt;/span&gt;
&lt;span class="k"&gt;SELECT&lt;/span&gt; &lt;span class="k"&gt;COUNT&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;*&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
  &lt;span class="k"&gt;FROM&lt;/span&gt; &lt;span class="n"&gt;OPPORTUNITY&lt;/span&gt; &lt;span class="n"&gt;o&lt;/span&gt;
  &lt;span class="k"&gt;JOIN&lt;/span&gt; &lt;span class="n"&gt;SALES_DOC&lt;/span&gt; &lt;span class="n"&gt;q&lt;/span&gt; &lt;span class="k"&gt;ON&lt;/span&gt; &lt;span class="n"&gt;q&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;DOC_ID&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;o&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;QUOTE_ID&lt;/span&gt; &lt;span class="k"&gt;AND&lt;/span&gt; &lt;span class="n"&gt;q&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;DOC_TYPE&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s1"&gt;'QUOTE'&lt;/span&gt;
 &lt;span class="k"&gt;WHERE&lt;/span&gt; &lt;span class="n"&gt;substr&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;o&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;CREATED_AT&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;10&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;BETWEEN&lt;/span&gt; &lt;span class="s1"&gt;'2024-06-01'&lt;/span&gt; &lt;span class="k"&gt;AND&lt;/span&gt; &lt;span class="s1"&gt;'2024-06-30'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="c1"&gt;-- invoices&lt;/span&gt;
&lt;span class="k"&gt;SELECT&lt;/span&gt; &lt;span class="k"&gt;COUNT&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;DISTINCT&lt;/span&gt; &lt;span class="n"&gt;o&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;OPP_ID&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
  &lt;span class="k"&gt;FROM&lt;/span&gt; &lt;span class="n"&gt;OPPORTUNITY&lt;/span&gt; &lt;span class="n"&gt;o&lt;/span&gt;
  &lt;span class="k"&gt;JOIN&lt;/span&gt; &lt;span class="n"&gt;SALES_DOC&lt;/span&gt; &lt;span class="n"&gt;r&lt;/span&gt; &lt;span class="k"&gt;ON&lt;/span&gt; &lt;span class="n"&gt;r&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;SOURCE_DOC_ID&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;o&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;QUOTE_ID&lt;/span&gt; &lt;span class="k"&gt;AND&lt;/span&gt; &lt;span class="n"&gt;r&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;DOC_TYPE&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s1"&gt;'ORDER'&lt;/span&gt; &lt;span class="k"&gt;AND&lt;/span&gt; &lt;span class="n"&gt;r&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;STATUS&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;&amp;gt;&lt;/span&gt; &lt;span class="s1"&gt;'CANCELLED'&lt;/span&gt;
  &lt;span class="k"&gt;JOIN&lt;/span&gt; &lt;span class="n"&gt;SALES_DOC&lt;/span&gt; &lt;span class="n"&gt;i&lt;/span&gt; &lt;span class="k"&gt;ON&lt;/span&gt; &lt;span class="n"&gt;i&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;SOURCE_DOC_ID&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;r&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;DOC_ID&lt;/span&gt; &lt;span class="k"&gt;AND&lt;/span&gt; &lt;span class="n"&gt;i&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;DOC_TYPE&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s1"&gt;'INVOICE'&lt;/span&gt;
                  &lt;span class="k"&gt;AND&lt;/span&gt; &lt;span class="n"&gt;i&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;STATUS&lt;/span&gt; &lt;span class="k"&gt;NOT&lt;/span&gt; &lt;span class="k"&gt;IN&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'DRAFT'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'CANCELLED'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
 &lt;span class="k"&gt;WHERE&lt;/span&gt; &lt;span class="n"&gt;substr&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;o&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;CREATED_AT&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;10&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;BETWEEN&lt;/span&gt; &lt;span class="s1"&gt;'2024-06-01'&lt;/span&gt; &lt;span class="k"&gt;AND&lt;/span&gt; &lt;span class="s1"&gt;'2024-06-30'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;COUNT(DISTINCT o.OPP_ID)&lt;/code&gt; matters: one order can be invoiced in parts, and three partial invoices must still count as one converted opportunity.&lt;/p&gt;

&lt;p&gt;The test builds four opportunities: one with quote, order and invoice; one with only a quote; one without anything; one whose order was cancelled. Expected: 4 → 3 → 1 → 1, conversion 25 %.&lt;/p&gt;

&lt;h2&gt;
  
  
  4. Margin with a purchase price that may not exist
&lt;/h2&gt;

&lt;p&gt;Gross margin per product is revenue minus quantity × purchase price. The purchase price is the one of the product's main supplier - and a product may have none. The rule is: no main supplier, cost 0. That is a visible choice, not an accident:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight sql"&gt;&lt;code&gt;&lt;span class="k"&gt;SELECT&lt;/span&gt; &lt;span class="n"&gt;p&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;PRODUCT_ID&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;p&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;SKU&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;s&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;QTY&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;s&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;REV&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
       &lt;span class="k"&gt;CAST&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;ROUND&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;s&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;QTY&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="n"&gt;COALESCE&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;c&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;PC&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;/&lt;/span&gt; &lt;span class="mi"&gt;1000&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;AS&lt;/span&gt; &lt;span class="nb"&gt;INTEGER&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;AS&lt;/span&gt; &lt;span class="n"&gt;COST&lt;/span&gt;
  &lt;span class="k"&gt;FROM&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;SELECT&lt;/span&gt; &lt;span class="n"&gt;l&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;PRODUCT_ID&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="k"&gt;SUM&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;l&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;QTY_MILLI&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;AS&lt;/span&gt; &lt;span class="n"&gt;QTY&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="k"&gt;SUM&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;l&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;NET_CENTS&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;AS&lt;/span&gt; &lt;span class="n"&gt;REV&lt;/span&gt;
          &lt;span class="k"&gt;FROM&lt;/span&gt; &lt;span class="n"&gt;SALES_LINE&lt;/span&gt; &lt;span class="n"&gt;l&lt;/span&gt; &lt;span class="k"&gt;JOIN&lt;/span&gt; &lt;span class="n"&gt;SALES_DOC&lt;/span&gt; &lt;span class="n"&gt;d&lt;/span&gt; &lt;span class="k"&gt;ON&lt;/span&gt; &lt;span class="n"&gt;d&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;DOC_ID&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;l&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;DOC_ID&lt;/span&gt;
         &lt;span class="k"&gt;WHERE&lt;/span&gt; &lt;span class="n"&gt;d&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;DOC_TYPE&lt;/span&gt; &lt;span class="k"&gt;IN&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'INVOICE'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'CREDIT'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;AND&lt;/span&gt; &lt;span class="n"&gt;d&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;STATUS&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;&amp;gt;&lt;/span&gt; &lt;span class="s1"&gt;'DRAFT'&lt;/span&gt;
           &lt;span class="k"&gt;AND&lt;/span&gt; &lt;span class="n"&gt;l&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;PRODUCT_ID&lt;/span&gt; &lt;span class="k"&gt;IS&lt;/span&gt; &lt;span class="k"&gt;NOT&lt;/span&gt; &lt;span class="k"&gt;NULL&lt;/span&gt;
         &lt;span class="k"&gt;GROUP&lt;/span&gt; &lt;span class="k"&gt;BY&lt;/span&gt; &lt;span class="n"&gt;l&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;PRODUCT_ID&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="n"&gt;s&lt;/span&gt;
  &lt;span class="k"&gt;JOIN&lt;/span&gt; &lt;span class="n"&gt;PRODUCT&lt;/span&gt; &lt;span class="n"&gt;p&lt;/span&gt; &lt;span class="k"&gt;ON&lt;/span&gt; &lt;span class="n"&gt;p&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;PRODUCT_ID&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;s&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;PRODUCT_ID&lt;/span&gt;
  &lt;span class="k"&gt;LEFT&lt;/span&gt; &lt;span class="k"&gt;JOIN&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;SELECT&lt;/span&gt; &lt;span class="n"&gt;PRODUCT_ID&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="k"&gt;MIN&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;PURCHASE_CENTS&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;AS&lt;/span&gt; &lt;span class="n"&gt;PC&lt;/span&gt;
               &lt;span class="k"&gt;FROM&lt;/span&gt; &lt;span class="n"&gt;SUPPLIER_PRODUCT&lt;/span&gt; &lt;span class="k"&gt;WHERE&lt;/span&gt; &lt;span class="n"&gt;IS_MAIN&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt; &lt;span class="k"&gt;GROUP&lt;/span&gt; &lt;span class="k"&gt;BY&lt;/span&gt; &lt;span class="n"&gt;PRODUCT_ID&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;c&lt;/span&gt;
         &lt;span class="k"&gt;ON&lt;/span&gt; &lt;span class="k"&gt;c&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;PRODUCT_ID&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;s&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;PRODUCT_ID&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Quantities are stored in thousandths (&lt;code&gt;QTY_MILLI&lt;/code&gt;), so the cost divides by 1000. Margin and percentage are computed one level up, on the named columns of this derived table, because SQLite doesn't let a select list refer to an alias defined in the same select list.&lt;/p&gt;

&lt;p&gt;A product without main supplier now shows a 100 % margin, which is obviously wrong - and that's better than hiding it. The data quality report lists exactly these products as &lt;code&gt;NO_MAIN_SUPPLIER&lt;/code&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  Testing reports against a live database
&lt;/h2&gt;

&lt;p&gt;Reports sum over everything, so a test can't simply expect "revenue = 10000" when other data exists. Two approaches cover it:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Past periods only the tests use.&lt;/strong&gt; The month-end and year-comparison tests put their documents into March 2024 and March 2023.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Differences.&lt;/strong&gt; The cockpit test reads all figures, inserts one invoice, one ticket, one opportunity and one stock movement, reads again and checks the deltas.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Takeaways
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Aggregate in a derived table, then join&lt;/strong&gt; - never join raw rows to a list of periods and group afterwards.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Generate small series with &lt;code&gt;UNION ALL&lt;/code&gt;&lt;/strong&gt; when you can't or won't use recursive CTEs.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Bucket dates with &lt;code&gt;julianday&lt;/code&gt;&lt;/strong&gt;, and catch negative differences before you cast.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Follow stored references for funnels&lt;/strong&gt;, and count distinct starting objects.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Make defaults for missing data visible&lt;/strong&gt;, then report them elsewhere.&lt;/p&gt;

</description>
      <category>uniface</category>
      <category>sqlite</category>
      <category>legacy</category>
    </item>
    <item>
      <title>Data quality checks in a Uniface app - orphans and bad data in SQLite without foreign keys</title>
      <dc:creator>Franz</dc:creator>
      <pubDate>Sat, 17 Oct 2026 08:48:40 +0000</pubDate>
      <link>https://dev.to/f345345dfg/data-quality-checks-in-a-uniface-app-orphans-and-bad-data-in-sqlite-without-foreign-keys-31b8</link>
      <guid>https://dev.to/f345345dfg/data-quality-checks-in-a-uniface-app-orphans-and-bad-data-in-sqlite-without-foreign-keys-31b8</guid>
      <description>&lt;p&gt;The database behind the customer app from the series &lt;em&gt;Building a customer app in Uniface 10&lt;/em&gt; has 95 tables. Not one of them declares a foreign key. The app keeps the relations consistent in its services, but that means SQLite will happily store an invoice line whose invoice no longer exists.&lt;/p&gt;

&lt;p&gt;This article shows the Uniface service &lt;code&gt;DQ3_SVC&lt;/code&gt; that finds such rows, and a second kind of check that looks for data that is technically valid but wrong for the business. The checks are ProcScript entries that build plain SQL, run it with &lt;code&gt;sql&lt;/code&gt; or &lt;code&gt;sql/data&lt;/code&gt;, and turn the result into Uniface lists and an HTML report. The SQL itself would work in any SQLite database.&lt;/p&gt;

&lt;p&gt;Tested with Rocket Uniface 10.4.03 Community Edition (10.4.03.021) and the SQLite version of its SLE driver (3.50.2), October 2026.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why SQLite doesn't catch it for you
&lt;/h2&gt;

&lt;p&gt;Two reasons, and both are easy to miss:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Foreign key enforcement is off by default&lt;/strong&gt; in SQLite. Even with &lt;code&gt;REFERENCES&lt;/code&gt; clauses in the schema, nothing is checked unless the connection runs &lt;code&gt;PRAGMA foreign_keys = ON&lt;/code&gt;. Every connection, every time.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;PRAGMA foreign_key_check&lt;/code&gt; only knows declared keys.&lt;/strong&gt; It is a great tool - if the schema declares the relations. Here it doesn't, so it finds nothing.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Without declared keys, the relations exist only in the code and in your head. So the first step is to write them down.&lt;/p&gt;

&lt;h2&gt;
  
  
  One query per relation
&lt;/h2&gt;

&lt;p&gt;An orphan is a child row whose parent doesn't exist. For a single relation:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight sql"&gt;&lt;code&gt;&lt;span class="k"&gt;SELECT&lt;/span&gt; &lt;span class="k"&gt;COUNT&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;*&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="k"&gt;FROM&lt;/span&gt; &lt;span class="n"&gt;SALES_LINE&lt;/span&gt; &lt;span class="k"&gt;c&lt;/span&gt;
&lt;span class="k"&gt;WHERE&lt;/span&gt; &lt;span class="k"&gt;c&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;DOC_ID&lt;/span&gt; &lt;span class="k"&gt;IS&lt;/span&gt; &lt;span class="k"&gt;NOT&lt;/span&gt; &lt;span class="k"&gt;NULL&lt;/span&gt;
  &lt;span class="k"&gt;AND&lt;/span&gt; &lt;span class="k"&gt;NOT&lt;/span&gt; &lt;span class="k"&gt;EXISTS&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;SELECT&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt; &lt;span class="k"&gt;FROM&lt;/span&gt; &lt;span class="n"&gt;SALES_DOC&lt;/span&gt; &lt;span class="n"&gt;p&lt;/span&gt; &lt;span class="k"&gt;WHERE&lt;/span&gt; &lt;span class="n"&gt;p&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;DOC_ID&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;c&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;DOC_ID&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;IS NOT NULL&lt;/code&gt; matters for optional relations: a sales line without a product (a free-text line) is fine, a sales line with a product ID that doesn't exist is not.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;NOT EXISTS&lt;/code&gt; is usually the better choice over &lt;code&gt;LEFT JOIN … WHERE p.DOC_ID IS NULL&lt;/code&gt; or &lt;code&gt;NOT IN (SELECT …)&lt;/code&gt;. &lt;code&gt;NOT IN&lt;/code&gt; returns no rows at all if the subquery contains a NULL, which is a classic trap. &lt;code&gt;NOT EXISTS&lt;/code&gt; has no such surprise, and SQLite can use the parent's primary key for the lookup.&lt;/p&gt;

&lt;p&gt;In the app, one small entry runs this query for any child/parent pair and puts the result into a list keyed by &lt;code&gt;TABLE.COLUMN&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;entry ORPHAN
params
    string pChild : IN
    string pColumn : IN
    string pParent : IN
    string pKey : IN
    string pData : INOUT
    numeric pTotal : INOUT
endparams
variables
    numeric vN
    string vId
endvariables
    call COUNT_SQL("SELECT COUNT(*) FROM %%(pChild) c WHERE c.%%(pColumn) IS NOT NULL AND NOT EXISTS (SELECT 1 FROM %%(pParent) p WHERE p.%%(pKey) = c.%%(pColumn))", vN)
    vId = "%%(pChild).%%(pColumn)"
    putitem/id pData, vId, vN
    if (vN &amp;gt; 0)
        pTotal = pTotal + vN
    endif
    return 0
end
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The check itself is then just the list of relations - 48 of them for the newer half of the schema:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;    call ORPHAN("SALES_DOC", "CUSTOMER_ID", "CUSTOMER", "CUSTOMER_ID", pData, pTotal)
    call ORPHAN("SALES_LINE", "DOC_ID", "SALES_DOC", "DOC_ID", pData, pTotal)
    call ORPHAN("SALES_LINE", "PRODUCT_ID", "PRODUCT", "PRODUCT_ID", pData, pTotal)
    call ORPHAN("PAYMENT", "DOC_ID", "SALES_DOC", "DOC_ID", pData, pTotal)
    call ORPHAN("DELIVERY_LINE", "DELIVERY_ID", "DELIVERY_DOC", "DELIVERY_ID", pData, pTotal)
    call ORPHAN("STOCK_MOVE", "WAREHOUSE_ID", "WAREHOUSE", "WAREHOUSE_ID", pData, pTotal)
    call ORPHAN("TICKET_COMMENT", "TICKET_ID", "TICKET", "TICKET_ID", pData, pTotal)
    call ORPHAN("JOB_RUN", "JOB_CODE", "JOB_DEF", "JOB_CODE", pData, pTotal)
    ...
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The result lists every relation with its count - zeros included. A list that only shows problems looks the same whether everything is fine or the check didn't run. A list of 48 zeros is evidence.&lt;/p&gt;

&lt;p&gt;This list is the documentation of the relations I never wrote anywhere else. If you only take one thing from this article: &lt;strong&gt;write the list&lt;/strong&gt;, even if you never run it automatically.&lt;/p&gt;

&lt;h2&gt;
  
  
  Rows that are valid but wrong
&lt;/h2&gt;

&lt;p&gt;Orphans are a structural problem. The more useful checks turned out to be business rules that no constraint could express. They all follow one pattern: one &lt;code&gt;SELECT&lt;/code&gt; per rule, each returning the same columns plus a fixed issue code, glued together with &lt;code&gt;UNION ALL&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight sql"&gt;&lt;code&gt;&lt;span class="k"&gt;SELECT&lt;/span&gt; &lt;span class="n"&gt;p&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;PRODUCT_ID&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;p&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;SKU&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;p&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;PRODUCT_NAME&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'NO_PRICE'&lt;/span&gt;
  &lt;span class="k"&gt;FROM&lt;/span&gt; &lt;span class="n"&gt;PRODUCT&lt;/span&gt; &lt;span class="n"&gt;p&lt;/span&gt;
 &lt;span class="k"&gt;WHERE&lt;/span&gt; &lt;span class="n"&gt;p&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;IS_ACTIVE&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt; &lt;span class="k"&gt;AND&lt;/span&gt; &lt;span class="n"&gt;p&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;PRICE_CENTS&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;=&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;
&lt;span class="k"&gt;UNION&lt;/span&gt; &lt;span class="k"&gt;ALL&lt;/span&gt;
&lt;span class="k"&gt;SELECT&lt;/span&gt; &lt;span class="n"&gt;p&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;PRODUCT_ID&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;p&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;SKU&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;p&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;PRODUCT_NAME&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'NO_SUPPLIER'&lt;/span&gt;
  &lt;span class="k"&gt;FROM&lt;/span&gt; &lt;span class="n"&gt;PRODUCT&lt;/span&gt; &lt;span class="n"&gt;p&lt;/span&gt;
 &lt;span class="k"&gt;WHERE&lt;/span&gt; &lt;span class="n"&gt;p&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;IS_ACTIVE&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;
   &lt;span class="k"&gt;AND&lt;/span&gt; &lt;span class="k"&gt;NOT&lt;/span&gt; &lt;span class="k"&gt;EXISTS&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;SELECT&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt; &lt;span class="k"&gt;FROM&lt;/span&gt; &lt;span class="n"&gt;PRODUCT_BUNDLE&lt;/span&gt; &lt;span class="n"&gt;b&lt;/span&gt; &lt;span class="k"&gt;WHERE&lt;/span&gt; &lt;span class="n"&gt;b&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;BUNDLE_ID&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;p&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;PRODUCT_ID&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
   &lt;span class="k"&gt;AND&lt;/span&gt; &lt;span class="k"&gt;NOT&lt;/span&gt; &lt;span class="k"&gt;EXISTS&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;SELECT&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt; &lt;span class="k"&gt;FROM&lt;/span&gt; &lt;span class="n"&gt;SUPPLIER_PRODUCT&lt;/span&gt; &lt;span class="n"&gt;s&lt;/span&gt; &lt;span class="k"&gt;WHERE&lt;/span&gt; &lt;span class="n"&gt;s&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;PRODUCT_ID&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;p&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;PRODUCT_ID&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="k"&gt;UNION&lt;/span&gt; &lt;span class="k"&gt;ALL&lt;/span&gt;
&lt;span class="k"&gt;SELECT&lt;/span&gt; &lt;span class="n"&gt;p&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;PRODUCT_ID&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;p&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;SKU&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;p&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;PRODUCT_NAME&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'NO_MAIN_SUPPLIER'&lt;/span&gt;
  &lt;span class="k"&gt;FROM&lt;/span&gt; &lt;span class="n"&gt;PRODUCT&lt;/span&gt; &lt;span class="n"&gt;p&lt;/span&gt;
 &lt;span class="k"&gt;WHERE&lt;/span&gt; &lt;span class="n"&gt;p&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;IS_ACTIVE&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;
   &lt;span class="k"&gt;AND&lt;/span&gt; &lt;span class="k"&gt;EXISTS&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;SELECT&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt; &lt;span class="k"&gt;FROM&lt;/span&gt; &lt;span class="n"&gt;SUPPLIER_PRODUCT&lt;/span&gt; &lt;span class="n"&gt;s&lt;/span&gt; &lt;span class="k"&gt;WHERE&lt;/span&gt; &lt;span class="n"&gt;s&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;PRODUCT_ID&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;p&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;PRODUCT_ID&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
   &lt;span class="k"&gt;AND&lt;/span&gt; &lt;span class="k"&gt;NOT&lt;/span&gt; &lt;span class="k"&gt;EXISTS&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;SELECT&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt; &lt;span class="k"&gt;FROM&lt;/span&gt; &lt;span class="n"&gt;SUPPLIER_PRODUCT&lt;/span&gt; &lt;span class="n"&gt;s&lt;/span&gt; &lt;span class="k"&gt;WHERE&lt;/span&gt; &lt;span class="n"&gt;s&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;PRODUCT_ID&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;p&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;PRODUCT_ID&lt;/span&gt; &lt;span class="k"&gt;AND&lt;/span&gt; &lt;span class="n"&gt;s&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;IS_MAIN&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="k"&gt;UNION&lt;/span&gt; &lt;span class="k"&gt;ALL&lt;/span&gt;
&lt;span class="k"&gt;SELECT&lt;/span&gt; &lt;span class="n"&gt;p&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;PRODUCT_ID&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;p&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;SKU&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;p&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;PRODUCT_NAME&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'TAX_RATE'&lt;/span&gt;
  &lt;span class="k"&gt;FROM&lt;/span&gt; &lt;span class="n"&gt;PRODUCT&lt;/span&gt; &lt;span class="n"&gt;p&lt;/span&gt;
 &lt;span class="k"&gt;WHERE&lt;/span&gt; &lt;span class="n"&gt;p&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;IS_ACTIVE&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt; &lt;span class="k"&gt;AND&lt;/span&gt; &lt;span class="n"&gt;p&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;TAX_RATE&lt;/span&gt; &lt;span class="k"&gt;NOT&lt;/span&gt; &lt;span class="k"&gt;IN&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;7&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;19&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="k"&gt;ORDER&lt;/span&gt; &lt;span class="k"&gt;BY&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;4&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;A few details that make this pattern work:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;UNION ALL&lt;/code&gt;, not &lt;code&gt;UNION&lt;/code&gt;.&lt;/strong&gt; &lt;code&gt;UNION&lt;/code&gt; removes duplicates, which costs a sort and can hide two different findings with identical columns.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;ORDER BY&lt;/code&gt; with column numbers&lt;/strong&gt; at the very end. In a compound select it applies to the whole result, and positions avoid alias trouble.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;One product can appear several times&lt;/strong&gt;, once per issue. That is intended: the report says &lt;em&gt;what&lt;/em&gt; is wrong, not just &lt;em&gt;that&lt;/em&gt; something is wrong.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Exceptions belong in the rule.&lt;/strong&gt; Bundles are made of other products and have no supplier of their own, so &lt;code&gt;NO_SUPPLIER&lt;/code&gt; excludes them. Without that exception every bundle would show up as a finding - and a report full of known false alarms teaches people to ignore it.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The same pattern covers documents and other areas:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Area&lt;/th&gt;
&lt;th&gt;Issue&lt;/th&gt;
&lt;th&gt;Rule&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Documents&lt;/td&gt;
&lt;td&gt;&lt;code&gt;OLD_DRAFT&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;invoice draft older than N days&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;OLD_QUOTE&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;quote still open after its validity date&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;STALE_ORDER&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;open order older than N days with neither an invoice nor a delivery note&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;NO_LINES&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;issued document without a single line&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Suppliers&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;NO_EMAIL&lt;/code&gt;, &lt;code&gt;NO_ADDRESS&lt;/code&gt;, &lt;code&gt;NO_PRODUCTS&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;active supplier missing contact data or products&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Tickets&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;UNASSIGNED&lt;/code&gt;, &lt;code&gt;STALE_TICKET&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;open ticket without owner, or not touched for N days&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Purchasing&lt;/td&gt;
&lt;td&gt;&lt;code&gt;OVERDUE_PO&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;sent purchase order past its due date&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;"N days" comes from a setting with a default (30 days for drafts and orders, 14 for tickets), so the thresholds can change without touching the SQL.&lt;/p&gt;

&lt;h2&gt;
  
  
  How the service hands results back
&lt;/h2&gt;

&lt;p&gt;Every check is a public operation with the same shape: rights check first, then a private entry that does the work, and the result as a Uniface list in an &lt;code&gt;OUT&lt;/code&gt; parameter.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;public operation PRODUCT_DQ
params
    string pData : OUT
    numeric pCount : OUT
    string pError : OUT
endparams
    pData = ""
    pCount = 0
    pError = ""
    activate "RIGHTS_SVC".REQUIRE("READ", pError)
    if ($status &amp;lt; 0)
        return -1
    endif
    call PRODUCT_ROWS(pData, pCount, pError)
    return $status
end
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;PRODUCT_ROWS&lt;/code&gt; builds the &lt;code&gt;UNION ALL&lt;/code&gt; statement step by step with &lt;code&gt;%%(…)&lt;/code&gt; substitution and runs it with &lt;code&gt;sql/data&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;    vNoBundle = "NOT EXISTS (SELECT 1 FROM PRODUCT_BUNDLE b WHERE b.BUNDLE_ID = p.PRODUCT_ID)"
    vSql = "SELECT p.PRODUCT_ID, p.SKU, p.PRODUCT_NAME, 'NO_PRICE' FROM PRODUCT p WHERE p.IS_ACTIVE = 1 AND p.PRICE_CENTS &amp;lt;= 0"
    vSql = "%%(vSql) UNION ALL SELECT p.PRODUCT_ID, p.SKU, p.PRODUCT_NAME, 'NO_SUPPLIER' FROM PRODUCT p WHERE p.IS_ACTIVE = 1 AND %%(vNoBundle) AND NOT EXISTS (SELECT 1 FROM SUPPLIER_PRODUCT s WHERE s.PRODUCT_ID = p.PRODUCT_ID)"
    ...
    vSql = "%%(vSql) ORDER BY 1, 4"
    sql/data vSql, "CUSTOMERS"
    if ($status &amp;lt; 0)
        pError = $concat("The products could not be checked (error ", $procerror, ").")
        return -1
    endif
    pData = $result
    pCount = $itemcount(pData)
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;With &lt;code&gt;/data&lt;/code&gt;, &lt;code&gt;$result&lt;/code&gt; holds the whole result set as a Uniface list: one item per row, and each row is again a list of columns. A caller reads it with &lt;code&gt;forlist&lt;/code&gt; and &lt;code&gt;getitem&lt;/code&gt; - no entity, no form, no painted fields. The checks are reusable from a form, from a report and from a test in exactly the same way.&lt;/p&gt;

&lt;p&gt;The orphan check returns a different kind of list: an &lt;em&gt;associative&lt;/em&gt; one, built with &lt;code&gt;putitem/id&lt;/code&gt;. The key is &lt;code&gt;TABLE.COLUMN&lt;/code&gt;, the value the count, and a caller asks for one relation directly:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;    getitem/id vDl, vData, "DELIVERY_LINE.DELIVERY_ID"
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  From rows to a report
&lt;/h2&gt;

&lt;p&gt;All three rule groups go into one HTML file. A small entry turns any result list into a table, escaping every cell:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;    forlist vRow in pData
        vLine = "&amp;lt;tr&amp;gt;"
        vI = 1
        while (vI &amp;lt;= pColumns)
            getitem vCell, vRow, vI
            call HTML_ESC(vCell)
            vLine = "%%(vLine)&amp;lt;td&amp;gt;%%(vCell)&amp;lt;/td&amp;gt;"
            vI = vI + 1
        endwhile
        pHtml = $concat(pHtml, vLine, "&amp;lt;/tr&amp;gt;", vNl)
    endfor
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Escaping is not optional here. The report shows customer and supplier names, and the one with &lt;code&gt;&amp;amp;&lt;/code&gt; or &lt;code&gt;&amp;lt;&lt;/code&gt; in it will arrive eventually.&lt;/p&gt;

&lt;h2&gt;
  
  
  Testing the checks
&lt;/h2&gt;

&lt;p&gt;A data quality check that never finds anything is indistinguishable from a broken one. The tests therefore create exactly one bad row per rule and assert the exact issue list per object:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;    call ISSUES_OF(vData, "", "998631", vQ1)
    call ISSUES_OF(vData, "", "998632", vQ2)
    call ISSUES_OF(vData, "", "998633", vQ3)
    call CHECK("PRODUCT_DQ finds missing price, supplier, main supplier and odd tax rates", (vStatus = 0 &amp;amp; vQ1 = "NO_PRICE,NO_SUPPLIER" &amp;amp; vQ2 = "NO_MAIN_SUPPLIER,TAX_RATE" &amp;amp; vQ3 = ""), ...)
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The third product is the important one: it is correct and must produce no finding. Without such a "clean" case, a rule that flags everything would pass.&lt;/p&gt;

&lt;p&gt;For orphans, the test counts before, inserts one orphan delivery line and one orphan ticket comment, counts again and expects exactly two more. It also checks that the result has 48 entries, so a relation that silently drops out of the list fails the test.&lt;/p&gt;

&lt;h2&gt;
  
  
  Takeaways
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Foreign keys in SQLite are off unless you switch them on&lt;/strong&gt;, per connection - and &lt;code&gt;foreign_key_check&lt;/code&gt; only knows declared keys.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Write down every relation&lt;/strong&gt; and check it with &lt;code&gt;NOT EXISTS&lt;/code&gt;. Report zeros too.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;One &lt;code&gt;SELECT&lt;/code&gt; per business rule, combined with &lt;code&gt;UNION ALL&lt;/code&gt;&lt;/strong&gt;, each with a fixed issue code.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Put the exceptions into the rule&lt;/strong&gt; before the report teaches people to ignore it.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Test every rule with one bad and one good row.&lt;/strong&gt;&lt;/p&gt;

</description>
      <category>uniface</category>
      <category>sqlite</category>
      <category>dataquality</category>
      <category>legacy</category>
    </item>
    <item>
      <title>A customer maintenance form in Uniface 10, part 13 - salted password hashes, rights checks in the services, and a session timeout</title>
      <dc:creator>Franz</dc:creator>
      <pubDate>Sun, 04 Oct 2026 16:40:08 +0000</pubDate>
      <link>https://dev.to/f345345dfg/a-customer-maintenance-form-in-uniface-10-part-13-salted-password-hashes-rights-checks-in-the-9o8</link>
      <guid>https://dev.to/f345345dfg/a-customer-maintenance-form-in-uniface-10-part-13-salted-password-hashes-rights-checks-in-the-9o8</guid>
      <description>&lt;p&gt;Part 9 added users, three roles and a login lockout - as services, without a login dialog. The password hash from part 9 was one HMAC-SHA256 per password, keyed with the application name and the user name. That is better than plain text and worse than anything you would choose today: no random salt, one round, fast to brute-force.&lt;/p&gt;

&lt;p&gt;This part closes three gaps:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;a &lt;strong&gt;v2 password hash&lt;/strong&gt; with a random salt and a configurable number of rounds, plus an upgrade path for existing v1 hashes,&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;rights checks inside the services&lt;/strong&gt; that change data, not only in the forms,&lt;/li&gt;
&lt;li&gt;a &lt;strong&gt;session timeout&lt;/strong&gt; that logs a user out after a period of inactivity.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;And it finally adds the forms that make all of this usable: a login dialog and a change-password dialog.&lt;/p&gt;

&lt;h2&gt;
  
  
  The v2 hash
&lt;/h2&gt;

&lt;p&gt;The stored value carries everything needed to check it later:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;v2:&amp;lt;salt as hex&amp;gt;:&amp;lt;rounds&amp;gt;:&amp;lt;hash as hex&amp;gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The version prefix is the important part. It lets old and new hashes live in the same column, and it leaves room for a v3.&lt;/p&gt;

&lt;p&gt;Generating a hash:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;entry HASH_V2
params
    string pPassword : IN
    string pHash : OUT
endparams
variables
    string vSalt, vHex, vRoundsText
    numeric vRounds
endvariables
    pHash = ""
    activate "SETTINGS_SVC".GET_SETTING("PASSWORD_ROUNDS", "10000", vRoundsText)
    vRounds = vRoundsText
    if (vRounds &amp;lt; 1)
        vRounds = 10000
    endif
    vSalt = $encode("HEX", $random("raw", 16))
    if (vSalt = "")
        return -1
    endif
    call DERIVE(pPassword, vSalt, vRounds, vHex)
    pHash = "v2:%%(vSalt):%%(vRounds):%%(vHex)"
    return 0
end
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;$random("raw", 16)&lt;/code&gt; returns 16 random bytes; &lt;code&gt;$encode("HEX", ...)&lt;/code&gt; makes them storable. The derivation itself is a plain loop:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;entry DERIVE
params
    string pPassword : IN
    string pSalt : IN
    numeric pRounds : IN
    string pHex : OUT
endparams
variables
    numeric vRound
endvariables
    pHex = pSalt
    vRound = 0
    while (vRound &amp;lt; pRounds)
        pHex = $encode("HEX", $encode("HMAC_SHA256", pHex, pPassword))
        vRound = vRound + 1
    endwhile
    return 0
end
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Each round feeds the previous result back into HMAC-SHA256 with the password as the key. The salt is the starting value, so the same password gives a different hash for every user.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;To be clear about what this is:&lt;/strong&gt; an iterated HMAC, not PBKDF2, bcrypt or Argon2. &lt;code&gt;$encode&lt;/code&gt; offers HMAC-SHA256 but none of the standard key-derivation functions, so this is the closest thing that can be built from what is there. It makes brute force slower by the round count and defeats precomputed tables because of the salt. It is not a reviewed standard construction, and if the platform ever offers PBKDF2, the version prefix makes a switch possible.&lt;/p&gt;

&lt;p&gt;Two more weak spots I would point out in a review myself:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;The comparison is a plain string comparison (&lt;code&gt;vCalc = vHash&lt;/code&gt;), not a constant-time one. For a desktop application that checks passwords locally, timing attacks are not a realistic threat; for a network service it would matter.&lt;/li&gt;
&lt;li&gt;A loop of HMAC calls in an interpreted language is slow per round. That means fewer rounds for the same waiting time than a native implementation would get, which is why the round count has to be measured (see below).&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Checking a password, and upgrading on the fly
&lt;/h2&gt;

&lt;p&gt;Checking reads the stored value, splits it, and re-runs the derivation:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;    if (pStored[1:3] = "v2:")
        vList = $replace(pStored, 1, ":", $string("&amp;amp;uSEP;"), -1)
        getitem vVersion, vList, 1
        getitem vSalt, vList, 2
        getitem vRounds, vList, 3
        getitem vHash, vList, 4
        if (vSalt = "" | vHash = "" | vRounds &amp;lt; 1)
            return 0
        endif
        call DERIVE(pPassword, vSalt, vRounds, vCalc)
        if (vCalc = vHash)
            pOk = 1
        endif
        return 0
    endif
    vKey = $concat("CUSTOMER_MANAGEMENT:", $uppercase(pUser))
    vCalc = $encode("HEX", $encode("HMAC_SHA256", pPassword, vKey))
    if (vCalc = pStored)
        pOk = 1
        pOld = 1
    endif
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Replacing &lt;code&gt;:&lt;/code&gt; with the Uniface item separator turns the stored string into a list that &lt;code&gt;getitem&lt;/code&gt; can read. Anything without the &lt;code&gt;v2:&lt;/code&gt; prefix is treated as a v1 hash and checked the old way. If that succeeds, &lt;code&gt;pOld&lt;/code&gt; is set.&lt;/p&gt;

&lt;p&gt;The login uses that flag: after a successful v1 check it computes a v2 hash from the password it has just been given and stores it. Users never notice the migration, and after everyone has logged in once there are no v1 hashes left. This is the only moment the plain-text password is available, so it is the only moment the upgrade can happen.&lt;/p&gt;

&lt;p&gt;The login operation also returns more than ok/not ok:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Status&lt;/th&gt;
&lt;th&gt;Meaning&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;0&lt;/td&gt;
&lt;td&gt;logged in&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;1&lt;/td&gt;
&lt;td&gt;no users existed; this user was created as administrator&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;2&lt;/td&gt;
&lt;td&gt;logged in, but the password was reset and must be changed now&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;-1&lt;/td&gt;
&lt;td&gt;refused (text in &lt;code&gt;pError&lt;/code&gt;)&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Status 2 comes from an administrator's password reset. The login dialog reacts to it by opening the change-password dialog straight away and logging the user out again if they cancel.&lt;/p&gt;

&lt;h2&gt;
  
  
  How many rounds?
&lt;/h2&gt;

&lt;p&gt;"10000 rounds" is a guess. Whether that takes 20 milliseconds or two seconds depends on the machine. So the service measures it:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;public operation CALIBRATE
params
    numeric pTargetMs : IN
    numeric pRounds : OUT
    numeric pMs : OUT
    string pError : OUT
endparams
    ...
    call NOW_MS(vStart)
    call DERIVE("calibration", "00", 2000, vHex)
    call NOW_MS(vEnd)
    pMs = vEnd - vStart
    if (pMs &amp;lt; 1)
        pMs = 1
    endif
    sql "SELECT MAX(1000, MIN(500000, CAST(%%(vTarget) * 2.0 / %%(pMs) AS INTEGER) * 1000))", "CUSTOMERS"
    ...
    pRounds = $result
    vRounds = "%%(pRounds)"
    activate "SETTINGS_SVC".SET_SETTING("PASSWORD_ROUNDS", vRounds, pError)
    return $status
end
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;It runs 2000 rounds, measures the time, and scales up to the target time (for example 300 ms), rounded down to whole thousands and kept between 1000 and 500000. Existing hashes keep their own round count, because it is stored in the hash. Only new hashes use the new setting.&lt;/p&gt;

&lt;p&gt;The millisecond clock comes from SQLite, because a connection is open anyway:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;entry NOW_MS
params
    numeric pMs : OUT
endparams
    pMs = 0
    sql "SELECT CAST((julianday('now') - 2440587.5) * 86400000 AS INTEGER)", "CUSTOMERS"
    if ($status &amp;gt;= 0)
        pMs = $result
    endif
    return 0
end
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;julianday('now') - 2440587.5&lt;/code&gt; is days since 1970; times 86400000 gives milliseconds. The settings form has a "Calibrate" button that calls this operation.&lt;/p&gt;

&lt;h2&gt;
  
  
  Rights checks belong in the services
&lt;/h2&gt;

&lt;p&gt;Part 9 defined the roles &lt;code&gt;READ&lt;/code&gt; &amp;lt; &lt;code&gt;EDIT&lt;/code&gt; &amp;lt; &lt;code&gt;ADMIN&lt;/code&gt; and a &lt;code&gt;HAS_RIGHT&lt;/code&gt; check. What was missing was a place that enforces it. Hiding buttons in the menu is not enforcement: every service can be called from any other component.&lt;/p&gt;

&lt;p&gt;So there is a small service whose only job is to refuse with a readable sentence:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;public operation REQUIRE
params
    string pRight : IN
    string pError : OUT
endparams
variables
    string vUser, vRole
    boolean vAllowed
endvariables
    pError = ""
    activate "USER_SVC".HAS_RIGHT(pRight, vAllowed)
    if (vAllowed)
        return 0
    endif
    activate "USER_SVC".CURRENT_USER(vUser, vRole)
    pError = "This function needs the role %%(pRight). You are logged in as %%(vUser) with the role %%(vRole)."
    return -1
end
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;and one line at the top of every service operation that changes data in bulk or for good - merge, anonymize, import, the bulk clean-ups:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;    activate "RIGHTS_SVC".REQUIRE("EDIT", pError)
    if ($status &amp;lt; 0)
        return -1
    endif
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The clean-up service has a dry-run mode that only counts. The check sits inside &lt;code&gt;if (!pDryRun)&lt;/code&gt;, so a reader may still see what &lt;em&gt;would&lt;/em&gt; change.&lt;/p&gt;

&lt;p&gt;One design decision needs stating honestly. Without a login, &lt;code&gt;CURRENT_USER&lt;/code&gt; returns the operating system user with the role &lt;code&gt;EDIT&lt;/code&gt;. That keeps all older tests and the developer's own Compile &amp;amp; Test runs working without logging in first. It also means that anyone who can start the components directly, without the main menu, works with &lt;code&gt;EDIT&lt;/code&gt; rights. The main menu now always asks for a login first, but the default is a convenience for development, not a security boundary.&lt;/p&gt;

&lt;p&gt;The tests for this part log in a &lt;code&gt;READ&lt;/code&gt; user and check that merge, anonymize, import and the real clean-up are all refused with a text that names the role, that the clean-up dry run is still allowed, and that everything works again after logout.&lt;/p&gt;

&lt;h2&gt;
  
  
  A session timeout without a timer
&lt;/h2&gt;

&lt;p&gt;The logged-in user lives in Uniface's numbered general variables (&lt;code&gt;$91&lt;/code&gt; user, &lt;code&gt;$92&lt;/code&gt; role), which keep their value as long as the application runs. A third one, &lt;code&gt;$93&lt;/code&gt;, holds the time of the last activity in milliseconds:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;public operation CHECK
params
    string pError : OUT
endparams
variables
    string vText
    numeric vNow, vLast, vLimit, vIdle
endvariables
    pError = ""
    call NOW_MS(vNow)
    if ($91 = "")
        $93 = vNow
        return 0
    endif
    activate "SETTINGS_SVC".GET_SETTING("SESSION_TIMEOUT", "30", vText)
    vLimit = vText
    vLast = $93
    if (vLimit &amp;gt; 0 &amp;amp; vLast &amp;gt; 0)
        vIdle = (vNow - vLast) / 60000
        if (vIdle &amp;gt;= vLimit)
            activate "USER_SVC".LOGOUT()
            $93 = ""
            pError = "Your session has expired after %%(vLimit) minutes without activity. Please log in again."
            return -1
        endif
    endif
    $93 = vNow
    return 0
end
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;There is no background timer. The check runs whenever the user picks a function in the main menu or the "More functions" menu:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight openedge"&gt;&lt;code&gt;&lt;span class="k"&gt;entry&lt;/span&gt; &lt;span class="nv"&gt;SESSION_OK&lt;/span&gt;
&lt;span class="nv"&gt;variables&lt;/span&gt;
    &lt;span class="k"&gt;string&lt;/span&gt; &lt;span class="nv"&gt;vError&lt;/span&gt;
    &lt;span class="k"&gt;numeric&lt;/span&gt; &lt;span class="nv"&gt;vOk&lt;/span&gt;
&lt;span class="nv"&gt;endvariables&lt;/span&gt;
    &lt;span class="nv"&gt;activate&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;SESSION_SVC&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;CHECK&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;vError&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="nf"&gt;if&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="err"&gt;$&lt;/span&gt;&lt;span class="k"&gt;status&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
        &lt;span class="k"&gt;message&lt;/span&gt;&lt;span class="o"&gt;/&lt;/span&gt;&lt;span class="k"&gt;info&lt;/span&gt; &lt;span class="nv"&gt;vError&lt;/span&gt;
        &lt;span class="nv"&gt;activate&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;LOGIN_FRM&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;exec&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;vOk&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
        &lt;span class="nf"&gt;if&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;vOk&lt;/span&gt; &lt;span class="o"&gt;!=&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
            &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt;
        &lt;span class="nv"&gt;endif&lt;/span&gt;
    &lt;span class="nv"&gt;endif&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;
&lt;span class="k"&gt;end&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;If the session has expired, the user sees why and gets the login dialog. Logging in again continues with the function they picked.&lt;/p&gt;

&lt;p&gt;The limitation is obvious from the code: someone who leaves a form open is only caught at their next menu choice, not while the form is on screen. A timeout of 0 switches the check off.&lt;/p&gt;

&lt;p&gt;Testing time-based behaviour without waiting 30 minutes needs a back door, and the service has one: &lt;code&gt;SET_LAST_ACTIVITY(pMinutesAgo)&lt;/code&gt; moves the last activity into the past. The tests use it to check "3 idle minutes are fine", "after the limit the user is logged out" and "timeout 0 never expires".&lt;/p&gt;

&lt;h2&gt;
  
  
  The two dialogs
&lt;/h2&gt;

&lt;p&gt;The login dialog is small: user name, password, two buttons and an info line. Two details are worth copying:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;The password field is cleared right after its value has been read, before the service call. Whatever happens next, the password is not left on screen.&lt;/li&gt;
&lt;li&gt;When the user table is empty, the info line says "No users yet. The first login creates the administrator." Without that hint, the first start of a fresh installation looks like a login with no valid account.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The result is passed back through a component variable, because &lt;code&gt;edit&lt;/code&gt; returns before the form closes:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;operation exec
params
    numeric pResult : OUT
endparams
    ...
    $cResult$ = 0
    edit
    pResult = $cResult$
end
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Component variables are declared in the form's Declarations section and must be written with dollar signs in the code (&lt;code&gt;$cResult$&lt;/code&gt;). Writing &lt;code&gt;cResult&lt;/code&gt; instead compiles, but with a warning "Field 'CRESULT' not found", and the value goes nowhere.&lt;/p&gt;

&lt;p&gt;The change-password dialog asks for the old password and the new one twice, and shows the password rule (at least 8 characters with a letter and a digit) before the user types anything.&lt;/p&gt;

&lt;h2&gt;
  
  
  Results
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;New services: &lt;code&gt;RIGHTS_SVC&lt;/code&gt;, &lt;code&gt;SESSION_SVC&lt;/code&gt;. Additions to &lt;code&gt;AUTH_SVC&lt;/code&gt; (&lt;code&gt;CALIBRATE&lt;/code&gt;). Rights checks in four existing services.&lt;/li&gt;
&lt;li&gt;The test service for this stage has 34 tests; together with the older suites, 8 test services and 371 tests run in one go, all passing, and the database is unchanged afterwards.&lt;/li&gt;
&lt;li&gt;A click test against the real database: the first login created the administrator, the main menu, the new sub-menu and the settings, user, password and other dialogs opened, and the test user was removed again afterwards.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Takeaways
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Version your hashes.&lt;/strong&gt; A prefix like &lt;code&gt;v2:&lt;/code&gt; costs three characters and makes every future change a migration instead of a reset.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Upgrade at login.&lt;/strong&gt; It is the only time you have the plain password, so it is the only time you can re-hash it.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Measure the cost, don't guess it.&lt;/strong&gt; A calibration that writes the round count into a setting is twenty lines.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Say what your construction is.&lt;/strong&gt; An iterated HMAC is not PBKDF2. Writing that down keeps the next reader from trusting it more than it deserves.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Enforce rights where the data changes.&lt;/strong&gt; Menus decide what users see; services decide what happens.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;A timeout does not need a timer.&lt;/strong&gt; Checking at every menu action is simple, testable, and good enough for a desktop application - as long as you know where it stops.&lt;/p&gt;

</description>
      <category>authentication</category>
      <category>programming</category>
      <category>security</category>
      <category>softwaredevelopment</category>
    </item>
  </channel>
</rss>
