PRJEDU_odbc — one plant, any SQL database, chosen by a connection string¶
Every file of this example — the XML, the scripts, the page — is in
PRJEDU_odbc on GitHub.
Run it with run.bat (Windows) or ./run.sh (Linux) from that folder.
run.bat
On Linux the same example starts with ./run.sh (the engine in ../bin/linux-x64; see
"On Linux" in the top README).
Then open http://127.0.0.1:8107/ — the history's page: the reading
written a second ago, what the database says every ten seconds, the last five
rows, and a box that counts the rows of any tag you type — with the tag
bound, so typing x'; DROP TABLE reading; -- finds the rows that carry
that text and the table stays.
Out of the box this runs at once: the database is the embedded SQLite and
the file log\plant.db appears by itself. Stop it with CTRL-C and start it
again — the readings are still there.
This is the twin of PRJEDU_sqlite. The plant, the timers
and the SQL calls are the same. What is new is where the database comes
from: the <con> has an empty con_host, and the connection string is
picked from a short list in the root document when the history document
starts. Move the plant to SQL Server or PostgreSQL by swapping two ids — the
script does not change.
What it teaches¶
- the connection string is the whole choice —
DRIVER=SQLite3i;...reaches the embedded SQLite, anything no driver claims goes to ODBC. Both drivers are plug-ins (AyPRO_sqlite.dll,AyPRO_odbc.dll) that attach themselves on first use; neither is named anywhere in the XML - connection strings kept in one place —
<var id="ConnStr">inAyXDB.xml, read by every document that needs the database, written intoCN_SQL/@con_hostinxdb_init, before the connection exists - path macros are yours to expand —
con_hostis handed to the driver exactly as written, so the script turns%PRJ%into a path withxdb.normalizePath() - one script, three databases — every statement is written once, in SQL
that SQLite, SQL Server and PostgreSQL all accept; the single exception
(does the table exist?) is chosen by the entry's
eng - a write that did not happen is not counted —
SQL.execute(rs)answers 1 when the row is stored and 0 when the database refused it (the reason is in the log); the sequence number moves only on 1. This needs an engine of 4.3.4.440 or later — older ones answered 0 in both cases - the lessons of PRJEDU_sqlite still hold: bound parameters are never SQL
(the tag
x'; DROP TABLE reading; --is stored as text, on every database),SQL.qryturns rows into elements, an SQL NULL isundefined
Choosing the database¶
db\AyXDB.xml:
<var id="ConnStr" eng="SQLITE3" val="DRIVER=SQLite3i;Database=%PRJ%/log/plant.db;"/>
<var id="ConnStrMSSQL" eng="MSSQL" val="DRIVER={SQL Server};SERVER=.\SQLEXPRESS;DATABASE=aymon_edu;Trusted_Connection=Yes;APP=AyMON;"/>
<var id="ConnStrPGSQL" eng="PGSQL" val="DRIVER={PostgreSQL Unicode(x64)};SERVER=127.0.0.1;PORT=5432;DATABASE=aymon_edu;UID=postgres;PWD=change-me;"/>
The entry whose id is exactly ConnStr is the one in use; the others are
parked. To use SQL Server, rename ConnStr to ConnStrSQLITE and
ConnStrMSSQL to ConnStr. Then create the empty database the string names:
| Database | Create aymon_edu once |
|---|---|
| SQL Server (Express is enough) | sqlcmd -S .\SQLEXPRESS -E -Q "CREATE DATABASE aymon_edu" |
| PostgreSQL | createdb -U postgres aymon_edu — and put your user and password in the string |
The script creates the table itself. For PostgreSQL, use the driver name
ODBC Data Sources (64-bit) shows on your machine; installers name it
differently (PostgreSQL Unicode(x64), PostgreSQL ODBC Driver(UNICODE)).
A password in a connection string is written to the log as PWD=***, but it
is in AyXDB.xml in clear text — that file is as secret as the password.
Windows authentication (Trusted_Connection=Yes) keeps it out altogether.
Why the string is set in xdb_init¶
A document starts in a fixed order:
<script> elements are parsed the functions exist
xdb_init main.init() -> CN_SQL/@con_host
every element's xdb_fnc CN_SQL is created and dials
xdb_start main.start() -> find or create the table
A value written into con_host in xdb_init is what CON.sql reads a
moment later. Written in xdb_start, it would come too late — the connection
would already be dialling an empty string. main.init() is four lines, and a
real plant repeats them in every document that shares the database; only the
root document knows where the database is.
Writing SQL that runs everywhere¶
| Habit | Why it is avoided here | What the script does instead |
|---|---|---|
LIMIT 5 / TOP 5 |
SQLite and PostgreSQL say LIMIT, SQL Server TOP | WHERE seq > (SELECT max(seq) - 5 FROM reading) |
round(val, 2) |
PostgreSQL has no round() for a float |
round in script, CAST(val AS DECIMAL(6,2)) in a query |
AUTOINCREMENT / IDENTITY / SERIAL |
three spellings of one idea | the script owns seq, starting from max(seq) |
a TIMESTAMP column |
in SQL Server TIMESTAMP is a row version, not a time |
text YYYY-MM-DD hh:mm:ss, which sorts correctly |
FLOAT |
32-bit in some databases | DOUBLE PRECISION |
CREATE TABLE IF NOT EXISTS |
SQL Server does not have it | ask the catalogue, keyed by eng |
Owning seq in script has a price: only one writer may use the table. A
table two plants write into wants the database's own counter, and with it a
line of engine-specific SQL.
Traps¶
con_host is not macro-expanded |
a connection string reaches the driver as written. %PRJ% there is five characters, and SQLite says unable to open database file. Expand it in script, as main.init() does. |
Turkish collation: i is not I |
on a SQL Server installed with Turkish_CI_AS, information_schema.tables is Invalid object name; INFORMATION_SCHEMA.TABLES works. PostgreSQL folds unquoted names to lower case, so the capitals work there too. Write every name in one case, everywhere. |
what getdata() returns depends on the driver |
the embedded SQLite answers max(seq) as text, so last + 1 is "11". Write it into a typed attribute (cnt) and read that back. |
SQL.qry stores a cell as the driver prints it |
SQL Server hands a float 25.45 over as 25.449999999999999. Cast in the query if the attribute is for people. |
An SQL NULL comes back as undefined |
through every driver. (v == null) catches it, (v == "") does not. |
Do not SELECT a column called id into a <list> |
id is the element identifier in AyMON. Alias it. |
The page¶
HIS/S00 serves it with two more connections: CN_WEB (HTTP, www\) and
CN_LIVE (MQTT inside a WebSocket on the same port, /mqtt). The script
writes what the page shows into five <var>s — NOW, STATS, RECENT,
FIND, FOUND — and the page subscribes to HIS/S00/+/val. RECENT is
L_RECENT's rows as one JSON value (json(id('L_RECENT')/item)): the
rows SQL.qry made into elements, read back by address.
The page writes one thing, FIND: its val_fnc is main.find, which runs
SELECT count(*), max(ts) FROM reading WHERE tag = ? with the typed text as
the parameter and answers in FOUND. FIND has val_typ="0x1401" — the
0x1000 bit is what makes a val_fnc run at all; with 0x0401 the write is
stored and nothing happens. xdb_acl="0xFFFFFFF1" on the document gives a
page that did not log in (a Guest) read and nothing else; FIND's own
xdb_acl adds that one write. Both connections listen on 127.0.0.1 only.
The page's header names the engine in use (eng of the ConnStr entry). The
search is ordinary SQL, the same on all three databases.
What the log looks like¶
First run, embedded SQLite. The first summary comes six seconds in and then
every ten — both timers carry evt_cfg="0x4042", whose 0x4000 bit fires
the first time at start + evt_dly instead of after a whole period (engine
4.3.4.441 or later; an older one runs the example the same way, only the
first summary comes at 16 seconds):
HIS: database engine SQLITE3, connection string from ConnStr
HIS: created table 'reading'
HIS: empty table, starting fresh
HIS: writing one reading a second, next seq 1
HIS: 6 readings, 20.4 .. 22.36 C, mean 21.39 C
HIS: 0 rows whose TAG looks like an attack, stored as text
HIS: an SQL NULL reads back as typeof=undefined, (v == null)=true, (v == '')=false
HIS: L_RECENT now holds 5 <item> elements; newest is #6, 22.36 C at 2026-09-22 14:12:29
HIS: 15 readings, 20.4 .. 25.74 C, mean 23.17 C
HIS: 1 rows whose TAG looks like an attack, stored as text
The same lines appear for SQL Server and PostgreSQL, with the engine's name in the first one. On the second run:
HIS: 28 readings already stored, 2026-09-22 13:03:52 .. 2026-09-22 13:04:19
HIS: writing one reading a second, next seq 29
If the database cannot be reached at start, CN_SQL dials again every
con_rct seconds, and the script does not give up either: T_READ/@rdy
stays 0 and every tick looks for the table again until it succeeds.