From 14ae4d439f200ad3228e3d25c753fb69df44acef Mon Sep 17 00:00:00 2001 From: Red Davies Date: Sat, 18 Oct 2025 15:57:09 -0400 Subject: [PATCH] Completing the simple documentation --- .spelling-wordlist.txt | 5 +++ docs/simple/populating.md | 51 ++++++++++++++++++++++++- docs/simple/queries.md | 63 +++++++++++++++++++++++++++++- docs/simple/sqltypes.md | 4 +- docs/simple/transactions.md | 76 ++++++++++++++++++++++++++++++++++++- 5 files changed, 194 insertions(+), 5 deletions(-) diff --git a/.spelling-wordlist.txt b/.spelling-wordlist.txt index 74452de..5bcd58f 100644 --- a/.spelling-wordlist.txt +++ b/.spelling-wordlist.txt @@ -1,5 +1,6 @@ API APIs +autocommit BIGINT Datatypes DBC @@ -10,6 +11,7 @@ DSN DSNs ENV hostname +ie ini iODBC jdbc @@ -38,13 +40,16 @@ RDBMS schemas se SMALLINT +sth SQLBigInteger SQLFloat SQLInteger +SQLNull SQLReal SQLSmallInteger SQLState SQLStates +SQLTables SQLVarchar STMT targeting diff --git a/docs/simple/populating.md b/docs/simple/populating.md index dcf2c80..d5e6f2f 100644 --- a/docs/simple/populating.md +++ b/docs/simple/populating.md @@ -1 +1,50 @@ -# Placeholder +# Populating Our Tables + +We're going to manually insert two of the Play names into our database to make the process as clear as possible: + +## Preparing Statements + +When we are executing any statements that use any kind of user-input or are going to be repeated with different data or parameters, we should always use `prepare()?` and `execute()?`, instead of `direct_exec()?`. The reasons for this are SQL Injection vulnerabilities and performance. + +A prepared statement allows you to create tokens in your SQL Statement that are replaced with data at runtime. For example: + +```pony +sth.prepare("insert into play (name) values (?)")? +``` + +This creates a _parameter_ placeholder which we need to populate with the correct value to insert into the database. + +Let's write a simple function to populate the play table: + +```pony +fun populate_play_table(sth: ODBCStmt)? => + var name: SQLVarchar = SQLVarchar(31) + sth + .> prepare("insert into play (name) values (?)")? + .> bind_parameter(name)? +``` + +When we bind parameters we have to bind them in order. If there were multiple parameters in our `prepare()?` statement then we would have to execute multiple `bind_parameter()?` statements, with the variables and types in the correct order. We will see this later when we populate more complex tables. + +Now that we have our statement fully prepared, we can populate data and execute it: + +```pony + name.write("Romeo and Juliet") + sth.execute()? + + name.write("A Midsummer nights dream") + sth.execute()? +``` + +Please note that we are reusing the same statement handle (sth), and the same bound parameter (name). In doing so we do not have to do the SQL statement parsing, query setup, memory allocation for our buffers, and binding said newly allocated buffers. + +Of course, in our example you'll need to add a call to this function on line 17 of our example: + +```pony + let sth: ODBCStmt = dbh.stmt()? + create_tables(sth)? + populate_play_table(sth)? + else +``` + +Next up, let's query the data! diff --git a/docs/simple/queries.md b/docs/simple/queries.md index dcf2c80..60f0099 100644 --- a/docs/simple/queries.md +++ b/docs/simple/queries.md @@ -1 +1,62 @@ -# Placeholder +# Simple Queries + +Let's write a simple function to query the database for the id (I64) for a specific play in the play table. + +## Preparing the Query + +A reminder that our tables schema looks like this: + +```sql +CREATE TEMPORARY TABLE play ( + id BIGSERIAL, + name VARCHAR(30) UNIQUE NOT NULL +); +``` + +In order to fulfil our function, we will need to provide a SQLVarchar _in_, and a SQLBigInteger _out_. + +```pony +fun play_id_from_name(sth: ODBCStmt, queryname: String): I64 ? -=> + var id: SQLBigInteger = SQLBigInteger + var name: SQLVarchar = SQLVarchar(31) +``` + +Like before, we need to bind our name _parameter_ to our query using `bind_parameter()?`. In addition, we need to bind a _column_ for every column that will be in the query's result set. We do this using the somewhat intuitive `bind_column()?` function: + +```pony + sth + .> prepare("select id from play where name = ?")? + .> bind_parameter(name)? + .> bind_column(id)? +``` + +Then we can write our value to the name _parameter_, execute the query and fetch the (singular, due to name being UNIQUE) result back. + +```pony + name.write(queryname) + + sth.execute()? + + if (sth.fetch()?) then + sth.finish()? + id.read()? + else + error // No row returned + end +``` + +NOTE: There is a trap here. You *must* check the return value of `fetch()?`. If you do not, you are going to end up with the previous value of `id`, not the one that would be returned. (Arguably in this case it doesn't matter as the value of `id` defaults to SQLNull so an `id.read()?` would fail regardless). But let's try to set a good example eh? + +Let's add some example calls to our Main.create function to test this: + +```pony + create_tables(sth)? + populate_play_table(sth)? + Debug.out(" R&J: " + play_id_from_name(sth, "Romeo and Juliet")?.string()) + Debug.out("MSND: " + play_id_from_name(sth, "A Midsummer nights dream")?.string()) + try + play_id_from_name(sth, "I don't exist")? + else + Debug.out("I don't exist doesn't exist™") + end +``` diff --git a/docs/simple/sqltypes.md b/docs/simple/sqltypes.md index 0d746f1..82e9b8a 100644 --- a/docs/simple/sqltypes.md +++ b/docs/simple/sqltypes.md @@ -15,7 +15,7 @@ As mentioned previously, all data is written in and out of the database via text For every SQL Datatype we create we have to also support a Nullable version. Instead of doing this by defining duplicate types for everything, we chose instead to take the following approach: -## Reading NULLs +### Reading NULLs ```pony var my_sql_integer: SQLInteger = SQLInteger @@ -35,7 +35,7 @@ If your schema has marked a column as `NOT NULL`, then you can safely call `read If however you try to `read()?` the value directly without testing for NULL and it is NULL, the function will error. -## Writing NULLs +### Writing NULLs All pony SQL Types default to NULL, so all that needs be done is to create your object and not set a value. diff --git a/docs/simple/transactions.md b/docs/simple/transactions.md index dcf2c80..45d5d61 100644 --- a/docs/simple/transactions.md +++ b/docs/simple/transactions.md @@ -1 +1,75 @@ -# Placeholder +# Transactions, Commits, and Rollbacks + +We have gone out of our way so far to ensure that none of the changes we have made to our database have been persistent. We did that so that during this tutorial we can be sure that every time we tested our program we did so from a known state. It's time to rip that band-aid off and move to transactions. + +## Database Transactions + +A database transaction is a sequence of one or more operations—such as reading, writing, updating, or deleting data—performed on a database as a single, logical unit of work. Transactions ensure that either all operations are successfully completed (committed) or none are applied (rolled back), maintaining data integrity even in the event of system failures. In other words, all commands in a transaction are either applied in an atomic manner, or rejected. + +ODBC by default commits every command you execute after you execute it. This is called "autocommit". + +In order to implement transactions, we need to disable autocommit in Main.create: + +```pony + try + let dbh: ODBCDbc = enh.dbc()? + dbh.set_autocommit(false)? + dbh.connect("psql-demo")? +``` + +Now remove all of the TEMPORARY keywords from the SQL create statements and run out example again multiple times. As we didn't commit the transaction, the tables we added and inserted data on are removed automatically from the database on disconnect. + +## Testing for a table's existence + +In order to ensure that we do not attempt to recreate the tables if the tables have been created by a previous run, we need a function to determine if a table exists. + +We're going to assume in this example that if we only commit our database when all of our tables have been successfully created, we can treat all the tables as a unit and only test for one table. + +There is an API call which allows us to search for tables. We can use this API call as a simple way to identify if a table exists: + +```pony + fun check_table_exists(sth: ODBCStmt, tablename: String): Bool ? => + sth.tables("", "", tablename, "TABLE")? + var rt: Bool = sth.fetch()? + sth.finish()? + rt +``` + +`tables()?`, aka SQLTables call behaves like a SQL statement, so we must ensure that the ODBCStmt that we pass it is pristine. We can either do that by creating a new one, or by ensuring that `finish()?` was called before it to reset it. You should not call `execute()?` after calling `tables()?` - it is implied. We did not bind any columns to this query because as we don't need the data. We just need to know if the data exists. + +The function `fetch()?` returns `true` if there was a row of data (ie, our table exists), or `false` if there is no data. + +Now we can modify our program to only create tables if the tables don't exist… and commit them if they are successful! + +```pony + if (not check_table_exists(sth, "play")?) then + create_tables(sth)? + dbh.commit()? + end +``` + +## Testing if a table contains data + +A simple SQL query will do this. + +```pony + fun check_play_populated(sth: ODBCStmt): Bool ? => + var cnt: SQLBigInteger = SQLBigInteger + sth + .> finish()? + .> bind_column(cnt)? + .> direct_exec("select count(*) from play")? + .> fetch()? + .> finish()? + + (cnt.read()? > 0) +``` + +Now we can likewise gate the call to `populate_play_table()?` and execute a `commit()?` on success. + +```pony + if (not check_play_populated(sth)?) then + populate_play_table(sth)? + dbh.commit()? + end +```