LessonsExpert: BeyondLesson 58

Databases

Keep many records in SQLite: open a database, create tables, insert safely with placeholders, query into text or walk a cursor, transactions, errors, and other database servers.

  • Expert
  • Lesson 58 of 83
  • About 12 minutes
  • 8 examples

When a file is not enough

A text file or a JSON file holds a few hundred records well. Thousands of records that you search, sort, update one at a time and must never lose belong in a database. SQLite keeps a whole database in one file, needs no server, and comes with OXT-Beyond; you talk to it in SQL, the language every relational database understands.

The database commands begin with rev, and come from an external the IDE loads as it starts, so a standalone that uses them needs Database and the SQLite driver ticked in its settings (Shipping your program).

Opening a database and making a table

revOpenDatabase("sqlite", path) opens the file, making it if it is not there, and returns a connection number to use with every other command; anything that is not a number is an error. revExecuteSQL runs SQL that changes something, and leaves the number of rows changed, or an error, in the result:

ExampleRun it in the playground
put specialFolderPath("temporary") & "/oxt-lesson.sqlite" into tPath
put revOpenDatabase("sqlite", tPath) into tDB
if tDB is not an integer then
   put "could not open it:" && tDB
else
   revExecuteSQL tDB, "CREATE TABLE IF NOT EXISTS people (name TEXT, born INTEGER)"
   revExecuteSQL tDB, "INSERT INTO people VALUES ('Ada Lovelace', 1815)"
   put "rows added:" && the result
   revCloseDatabase tDB
   put "the file is there:" && (there is a file tPath)
   delete file tPath
end if
Output
rows added: 1
the file is there: true

For a database that lasts only while the program runs (a cache, a quick sort of a big list), open ":memory:" instead of a path, as the rest of this lesson does.

Inserting safely: placeholders

Never build SQL by joining in values: a name such as O'Brien breaks it, and a value typed by a stranger can turn into SQL of their own, which is called SQL injection. Write :1, :2 and so on where the values go, and give the names of the variables that hold them after the SQL. The values then never become part of the SQL, whatever they contain:

ExampleRun it in the playground
put revOpenDatabase("sqlite", ":memory:") into tDB
revExecuteSQL tDB, "CREATE TABLE people (name TEXT, born INTEGER)"

put "Ada Lovelace,1815" & return & "Grace Hopper,1906" & return & \
      "Alan Turing,1912" & return & "Seán O'Brien,1970" into tPeople
repeat for each line tPerson in tPeople
   put item 1 of tPerson into tName
   put item 2 of tPerson into tBorn
   revExecuteSQL tDB, "INSERT INTO people (name, born) VALUES (:1, :2)", "tName", "tBorn"
end repeat

put revDataFromQuery(comma, return, tDB, "SELECT count(*) FROM people")
put revDataFromQuery(comma, return, tDB, "SELECT name FROM people WHERE born > 1900 ORDER BY born")
revCloseDatabase tDB
Output
4
Grace Hopper
Alan Turing
Seán O'Brien

Asking questions: revDataFromQuery

revDataFromQuery runs a SELECT and returns the answer as text: the columns separated by the first delimiter, the rows by the second. With a tab and a return, that is the xTalk table of Tables and CSV, ready for a table field. Placeholders work here too:

ExampleRun it in the playground
put revOpenDatabase("sqlite", ":memory:") into tDB
revExecuteSQL tDB, "CREATE TABLE people (name TEXT, born INTEGER)"
revExecuteSQL tDB, "INSERT INTO people VALUES ('Alan Turing', 1912), ('Ada Lovelace', 1815), ('Linus Torvalds', 1969), ('Grace Hopper', 1906)"

put 1950 into tBefore
put revDataFromQuery(tab, return, tDB, "SELECT name, born FROM people WHERE born < :1 ORDER BY born", "tBefore") into tRows
revCloseDatabase tDB

create field "Results"
set the rect of it to 20,20,300,92
set the tabStops of it to 180,280
set the vGrid of it to true
set the hGrid of it to true
set the text of it to "Name" & tab & "Born" & return & tRows
set the textStyle of line 1 of it to "bold"
set the backgroundColor of line 1 of it to "#E2E8F0"
put the number of lines of tRows && "people born before" && tBefore
Output
3 people born before 1950
Stage what the code made, as the OXT-Beyond engine draws it
The stage, with a table field listing three people born before 1950, oldest first: Ada Lovelace 1815, Grace Hopper 1906 and Alan Turing 1912, under a bold header row.

Record by record: cursors

For a big answer, or when you want each column by its name, revQueryDatabase returns a cursor: a place in the answer that you move through a record at a time. revDatabaseColumnNamed reads a column of the current record, revMoveToNextRecord moves on, and revQueryIsAtEnd says when there are no more. Close the cursor when you are done:

ExampleRun it in the playground
put revOpenDatabase("sqlite", ":memory:") into tDB
revExecuteSQL tDB, "CREATE TABLE people (name TEXT, born INTEGER)"
revExecuteSQL tDB, "INSERT INTO people VALUES ('Ada Lovelace', 1815), ('Grace Hopper', 1906)"

put revQueryDatabase(tDB, "SELECT name, born FROM people ORDER BY born DESC") into tCursor
repeat until revQueryIsAtEnd(tCursor)
   put revDatabaseColumnNamed(tCursor, "name") && "was born in" && revDatabaseColumnNamed(tCursor, "born")
   revMoveToNextRecord tCursor
end repeat
revCloseCursor tCursor
revCloseDatabase tDB
Output
Grace Hopper was born in 1906
Ada Lovelace was born in 1815

Errors

SQL with a mistake in it does not stop the script: revExecuteSQL leaves the database's words in the result, and revDataFromQuery returns them, starting with revdberr. Check both:

ExampleRun it in the playground
put revOpenDatabase("sqlite", ":memory:") into tDB
revExecuteSQL tDB, "INSERT INTO nowhere VALUES (1)"
put "execute:" && the result
put revDataFromQuery(comma, return, tDB, "SELECT * FROM nowhere") into tAnswer
if tAnswer begins with "revdberr" then put "query:" && item 2 to -1 of tAnswer
revCloseDatabase tDB
Output
execute: no such table: nowhere
query: Database Error: no such table: nowhere

Many changes at once: transactions

A transaction groups changes so that they all happen, or none do: a transfer takes from one account and adds to another, never just one of the two. Begin one with BEGIN, and end it with COMMIT to keep the changes or ROLLBACK to undo them. It is also much faster for many inserts, because the file is written once instead of once a row:

ExampleRun it in the playground
put revOpenDatabase("sqlite", ":memory:") into tDB
revExecuteSQL tDB, "CREATE TABLE numbers (n INTEGER)"
revExecuteSQL tDB, "BEGIN"
repeat with i = 1 to 1000
   revExecuteSQL tDB, "INSERT INTO numbers VALUES (:1)", "i"
end repeat
revExecuteSQL tDB, "COMMIT"
put revDataFromQuery(comma, return, tDB, "SELECT count(*), sum(n) FROM numbers")

revExecuteSQL tDB, "BEGIN"
revExecuteSQL tDB, "DELETE FROM numbers"
revExecuteSQL tDB, "ROLLBACK"
put revDataFromQuery(comma, return, tDB, "SELECT count(*) FROM numbers") && "rows survived the undone delete"
revCloseDatabase tDB
Output
1000,500500
1000 rows survived the undone delete

Other databases

The same commands work with database servers: "mysql" (and MariaDB), "postgresql" and "odbc", with a host, a database name, a user and a password. Only the opening changes:

ScriptPut it in an object's script
on mouseUp
   local tDB
   put revOpenDatabase("postgresql", "db.example.com", "shop", "reader", the cPassword of me) into tDB
   if tDB is not an integer then
      answer "Could not connect:" && tDB
      exit mouseUp
   end if
   put revDataFromQuery(tab, return, tDB, "SELECT name, price FROM products ORDER BY name") into field "Products"
   revCloseDatabase tDB
end mouseUp

Try it yourself

Make a scores table (player and points) in memory, add the scores Kim 40, Lee 75, Kim 55, Raj 60 and Lee 20 with placeholders, then ask SQL for each player's total, highest first, and put the answer as name: total lines.

Show a solution
ExampleRun it in the playground
put revOpenDatabase("sqlite", ":memory:") into tDB
revExecuteSQL tDB, "CREATE TABLE scores (player TEXT, points INTEGER)"
repeat for each item tScore in "Kim 40,Lee 75,Kim 55,Raj 60,Lee 20"
   put word 1 of tScore into tPlayer
   put word 2 of tScore into tPoints
   revExecuteSQL tDB, "INSERT INTO scores VALUES (:1, :2)", "tPlayer", "tPoints"
end repeat
put revDataFromQuery(": ", return, tDB, \
      "SELECT player, sum(points) AS total FROM scores GROUP BY player ORDER BY total DESC, player")
revCloseDatabase tDB
Output
Kim: 95
Lee: 95
Raj: 60

Kim and Lee tie on 95. SQL leaves tied rows in no fixed order, so the query adds a second rule after the comma: ties go in order of the player's name.

Words in this lesson

Each one finds every lesson that uses it. The Dictionary has the whole story of each, with its syntax and more examples.