Case Study 2: Wrapping a C Library in Pascal
Overview
In this case study, we create a Pascal wrapper for SQLite — one of the world's most widely used C libraries. The wrapper demonstrates external function declarations, type mapping between C and Pascal, memory management across language boundaries, and building a clean Pascal API on top of a raw C interface. By the end, we have a reusable SQLiteWrapper unit that PennyWise could use for persistent storage.
Why Wrap a C Library?
SQLite is written in C. It has no Pascal version. But because Free Pascal can call C functions directly, we can use SQLite from Pascal with full performance — no interpreter overhead, no bridge layer. The compiled Pascal code calls SQLite's C functions directly, just as a C program would.
The raw C API is functional but inconvenient: you pass PChar pointers, check integer return codes, and manage opaque handles. A Pascal wrapper provides:
- Type-safe interfaces (Pascal strings, not PChar)
- Exception-based error handling (not return code checking)
- Object-oriented API (methods on a class, not global functions)
- Automatic resource cleanup (destructor closes the database)
Step 1: Declare the C Functions
unit SQLiteWrapper;
{$mode objfpc}{$H+}
interface
uses
SysUtils, Classes;
const
{$IFDEF WINDOWS}
SQLITE_LIB = 'sqlite3.dll';
{$ENDIF}
{$IFDEF LINUX}
SQLITE_LIB = 'libsqlite3.so.0';
{$ENDIF}
{$IFDEF DARWIN}
SQLITE_LIB = 'libsqlite3.dylib';
{$ENDIF}
SQLITE_OK = 0;
SQLITE_ERROR = 1;
SQLITE_ROW = 100;
SQLITE_DONE = 101;
type
PSQLite3 = Pointer;
PSQLite3Stmt = Pointer;
PPChar = ^PChar;
{ Core C API declarations }
function sqlite3_open(filename: PChar; var db: PSQLite3): Integer;
cdecl; external SQLITE_LIB;
function sqlite3_close(db: PSQLite3): Integer;
cdecl; external SQLITE_LIB;
function sqlite3_errmsg(db: PSQLite3): PChar;
cdecl; external SQLITE_LIB;
function sqlite3_exec(db: PSQLite3; sql: PChar; callback: Pointer;
arg: Pointer; var errmsg: PChar): Integer;
cdecl; external SQLITE_LIB;
procedure sqlite3_free(ptr: Pointer);
cdecl; external SQLITE_LIB;
{ Prepared statement API }
function sqlite3_prepare_v2(db: PSQLite3; sql: PChar; nByte: Integer;
var stmt: PSQLite3Stmt; var tail: PChar): Integer;
cdecl; external SQLITE_LIB;
function sqlite3_step(stmt: PSQLite3Stmt): Integer;
cdecl; external SQLITE_LIB;
function sqlite3_column_count(stmt: PSQLite3Stmt): Integer;
cdecl; external SQLITE_LIB;
function sqlite3_column_text(stmt: PSQLite3Stmt; col: Integer): PChar;
cdecl; external SQLITE_LIB;
function sqlite3_column_name(stmt: PSQLite3Stmt; col: Integer): PChar;
cdecl; external SQLITE_LIB;
function sqlite3_finalize(stmt: PSQLite3Stmt): Integer;
cdecl; external SQLITE_LIB;
Each declaration matches the C function signature exactly. The cdecl convention ensures argument passing is compatible. PChar maps to C's char*. Pointer maps to opaque handles.
Step 2: Build the Pascal Wrapper Class
type
ESQLiteError = class(Exception);
TQueryResult = class
private
FColumns: TStringList;
FRows: TList;
public
constructor Create;
destructor Destroy; override;
property Columns: TStringList read FColumns;
function RowCount: Integer;
function GetValue(Row, Col: Integer): string;
function GetValueByName(Row: Integer; const ColName: string): string;
end;
TSQLiteDB = class
private
FHandle: PSQLite3;
FFilename: string;
FOpen: Boolean;
procedure CheckError(Code: Integer; const Context: string);
public
constructor Create(const Filename: string);
destructor Destroy; override;
procedure Execute(const SQL: string);
function Query(const SQL: string): TQueryResult;
property Filename: string read FFilename;
property IsOpen: Boolean read FOpen;
end;
implementation
{ --- TSQLiteDB --- }
constructor TSQLiteDB.Create(const Filename: string);
var
Code: Integer;
begin
inherited Create;
FFilename := Filename;
Code := sqlite3_open(PChar(Filename), FHandle);
if Code <> SQLITE_OK then
raise ESQLiteError.CreateFmt('Cannot open database "%s": %s',
[Filename, sqlite3_errmsg(FHandle)]);
FOpen := True;
end;
destructor TSQLiteDB.Destroy;
begin
if FOpen and (FHandle <> nil) then
sqlite3_close(FHandle);
inherited;
end;
procedure TSQLiteDB.CheckError(Code: Integer; const Context: string);
begin
if Code <> SQLITE_OK then
raise ESQLiteError.CreateFmt('%s: %s', [Context, sqlite3_errmsg(FHandle)]);
end;
procedure TSQLiteDB.Execute(const SQL: string);
var
ErrMsg: PChar;
Code: Integer;
begin
ErrMsg := nil;
Code := sqlite3_exec(FHandle, PChar(SQL), nil, nil, ErrMsg);
if Code <> SQLITE_OK then
begin
try
raise ESQLiteError.CreateFmt('SQL error: %s', [ErrMsg]);
finally
if ErrMsg <> nil then
sqlite3_free(ErrMsg);
end;
end;
end;
function TSQLiteDB.Query(const SQL: string): TQueryResult;
var
Stmt: PSQLite3Stmt;
Tail: PChar;
Code, I, ColCount: Integer;
Row: TStringList;
begin
Result := TQueryResult.Create;
Stmt := nil;
Code := sqlite3_prepare_v2(FHandle, PChar(SQL), -1, Stmt, Tail);
CheckError(Code, 'Prepare');
try
ColCount := sqlite3_column_count(Stmt);
{ Get column names }
for I := 0 to ColCount - 1 do
Result.Columns.Add(sqlite3_column_name(Stmt, I));
{ Fetch rows }
while sqlite3_step(Stmt) = SQLITE_ROW do
begin
Row := TStringList.Create;
for I := 0 to ColCount - 1 do
Row.Add(sqlite3_column_text(Stmt, I));
Result.FRows.Add(Row);
end;
finally
sqlite3_finalize(Stmt);
end;
end;
Step 3: Usage Example
program TestSQLite;
{$mode objfpc}{$H+}
uses
SysUtils, SQLiteWrapper;
var
DB: TSQLiteDB;
QR: TQueryResult;
I: Integer;
begin
DB := TSQLiteDB.Create('test.db');
try
{ Create table }
DB.Execute('CREATE TABLE IF NOT EXISTS expenses (' +
'id INTEGER PRIMARY KEY AUTOINCREMENT,' +
'description TEXT NOT NULL,' +
'amount REAL NOT NULL,' +
'category TEXT)');
{ Insert data }
DB.Execute('INSERT INTO expenses (description, amount, category) ' +
'VALUES ("Groceries", 85.50, "food")');
DB.Execute('INSERT INTO expenses (description, amount, category) ' +
'VALUES ("Bus pass", 45.00, "transport")');
DB.Execute('INSERT INTO expenses (description, amount, category) ' +
'VALUES ("Electric bill", 95.30, "utilities")');
{ Query }
QR := DB.Query('SELECT * FROM expenses ORDER BY amount DESC');
try
WriteLn('Expenses (', QR.RowCount, ' rows):');
for I := 0 to QR.RowCount - 1 do
WriteLn(Format(' %s: $%s (%s)', [
QR.GetValueByName(I, 'description'),
QR.GetValueByName(I, 'amount'),
QR.GetValueByName(I, 'category')
]));
finally
QR.Free;
end;
finally
DB.Free;
end;
end.
Key Design Decisions
1. Exceptions Instead of Return Codes
The C API returns integer error codes (SQLITE_OK, SQLITE_ERROR, etc.). The Pascal wrapper converts these to exceptions. This is more natural in Pascal and ensures errors cannot be silently ignored.
2. Pascal Strings at the Interface
The wrapper accepts and returns Pascal string values, converting to/from PChar internally. Users of the wrapper never deal with PChar pointers.
3. RAII-Style Resource Management
The constructor opens the database; the destructor closes it. Combined with try..finally, this guarantees the database is closed even if an exception occurs. No manual cleanup is needed.
4. Platform-Specific Library Names
The SQLITE_LIB constant uses conditional compilation to set the correct library name for each platform. The rest of the code is platform-independent.
Lessons Learned
-
C libraries are accessible. Any C library with a documented API can be called from Pascal. The type mapping (PChar for char, Pointer for void, Integer for int) is straightforward.
-
Wrappers improve ergonomics. The raw C API is usable but error-prone. A Pascal wrapper provides type safety, exceptions, and automatic cleanup.
-
Memory ownership must be clear. When C allocates memory (sqlite3_errmsg), Pascal must use the C library's free function (sqlite3_free), not Pascal's FreeMem. Mixing allocators causes crashes.
-
Test error paths. It is easy to test the happy path (everything works). Test what happens when the database file is locked, when SQL is malformed, when the library is missing.
-
This pattern applies universally. The same wrapping technique works for any C library: OpenSSL, libcurl, zlib, image processing libraries, and more.