Case Study 1: Building a Simple Database with Typed Files

The Scenario

Your friend runs a small tutoring business and has been keeping track of contacts in a paper notebook. Names get crossed out, phone numbers get scribbled over, and finding a specific contact means flipping through dozens of pages. She asks you to build a simple digital phone book.

The requirements are straightforward:

  1. Add a new contact (name, phone number, email)
  2. Search for a contact by name
  3. Edit a contact's phone number or email
  4. Delete a contact
  5. List all contacts
  6. Persist data between sessions — closing and reopening the program should not lose any data

This is a classic CRUD application (Create, Read, Update, Delete), and it is a perfect fit for Pascal's typed file system.


Designing the Data Structure

We begin with the contact record. Every field must be a fixed-size type for typed file compatibility:

type
  TContact = record
    Name: string[40];
    Phone: string[15];
    Email: string[50];
    Deleted: Boolean;      { Soft-delete flag }
  end;

The Deleted field implements the "mark-as-deleted" strategy discussed in the chapter. Rather than physically removing records (which requires shifting data), we simply flag them. This makes deletion an O(1) operation — seek to the record and flip a Boolean.

Why Not Use an Array?

An array of contacts would work fine during a single session, but it adds complexity: we need to load the entire file into memory at startup and save it all back on exit. With typed files and random access, we can read and write individual records as needed. The file is the database.

For this case study, we will use a hybrid approach: keep an in-memory array for fast access during the session, but save and load from a typed file for persistence.


The Core Implementation

Constants and Global State

const
  MAX_CONTACTS = 500;
  DB_FILE = 'phonebook.dat';

var
  Contacts: array[0..MAX_CONTACTS - 1] of TContact;
  ContactCount: Integer;

Loading the Database

procedure LoadDatabase;
var
  F: file of TContact;
  IOCode: Integer;
begin
  ContactCount := 0;

  if not FileExists(DB_FILE) then
  begin
    WriteLn('No existing database found. Starting fresh.');
    Exit;
  end;

  AssignFile(F, DB_FILE);
  {$I-}
  Reset(F);
  IOCode := IOResult;
  {$I+}

  if IOCode <> 0 then
  begin
    WriteLn('Warning: Could not open database (error ', IOCode, ').');
    WriteLn('Starting with empty contact list.');
    Exit;
  end;

  while (not Eof(F)) and (ContactCount < MAX_CONTACTS) do
  begin
    Read(F, Contacts[ContactCount]);
    Inc(ContactCount);
  end;

  CloseFile(F);
  WriteLn('Loaded ', ContactCount, ' contacts.');
end;

Saving the Database

procedure SaveDatabase;
var
  F: file of TContact;
  I: Integer;
  IOCode: Integer;
begin
  AssignFile(F, DB_FILE);
  {$I-}
  Rewrite(F);
  IOCode := IOResult;
  {$I+}

  if IOCode <> 0 then
  begin
    WriteLn('ERROR: Could not save database! (error ', IOCode, ')');
    Exit;
  end;

  for I := 0 to ContactCount - 1 do
    Write(F, Contacts[I]);

  CloseFile(F);
  WriteLn('Saved ', ContactCount, ' contacts to ', DB_FILE, '.');
end;

Adding a Contact

procedure AddContact;
var
  NewContact: TContact;
begin
  if ContactCount >= MAX_CONTACTS then
  begin
    WriteLn('Database is full! Maximum ', MAX_CONTACTS, ' contacts.');
    Exit;
  end;

  WriteLn;
  WriteLn('--- Add New Contact ---');

  Write('Name: ');
  ReadLn(NewContact.Name);

  if NewContact.Name = '' then
  begin
    WriteLn('Name cannot be empty. Cancelled.');
    Exit;
  end;

  Write('Phone: ');
  ReadLn(NewContact.Phone);

  Write('Email: ');
  ReadLn(NewContact.Email);

  NewContact.Deleted := False;

  Contacts[ContactCount] := NewContact;
  Inc(ContactCount);

  WriteLn('Contact added: ', NewContact.Name);
end;

Searching for a Contact

function FindContact(const SearchName: string): Integer;
var
  I: Integer;
  LowerSearch, LowerName: string;
begin
  Result := -1;
  LowerSearch := LowerCase(SearchName);

  for I := 0 to ContactCount - 1 do
  begin
    if Contacts[I].Deleted then
      Continue;

    LowerName := LowerCase(Contacts[I].Name);
    if Pos(LowerSearch, LowerName) > 0 then
    begin
      Result := I;
      Exit;
    end;
  end;
end;

procedure SearchContact;
var
  SearchTerm: string;
  I: Integer;
  Found: Boolean;
  LowerSearch, LowerName: string;
begin
  WriteLn;
  Write('Search for name: ');
  ReadLn(SearchTerm);

  if SearchTerm = '' then Exit;

  LowerSearch := LowerCase(SearchTerm);
  Found := False;

  for I := 0 to ContactCount - 1 do
  begin
    if Contacts[I].Deleted then Continue;

    LowerName := LowerCase(Contacts[I].Name);
    if Pos(LowerSearch, LowerName) > 0 then
    begin
      WriteLn;
      WriteLn('  [', I, '] ', Contacts[I].Name);
      WriteLn('       Phone: ', Contacts[I].Phone);
      WriteLn('       Email: ', Contacts[I].Email);
      Found := True;
    end;
  end;

  if not Found then
    WriteLn('No contacts found matching "', SearchTerm, '".');
end;

Editing a Contact

This is where random access shines. We find the record, modify it in memory, and save the entire database (or, in a more advanced version, write just that record back to the file using Seek):

procedure EditContact;
var
  SearchTerm: string;
  Index: Integer;
  NewValue: string;
begin
  WriteLn;
  Write('Edit — enter name to find: ');
  ReadLn(SearchTerm);

  Index := FindContact(SearchTerm);
  if Index = -1 then
  begin
    WriteLn('Contact not found.');
    Exit;
  end;

  WriteLn('Found: ', Contacts[Index].Name);
  WriteLn('  Current phone: ', Contacts[Index].Phone);
  WriteLn('  Current email: ', Contacts[Index].Email);
  WriteLn;

  Write('New phone (Enter to keep current): ');
  ReadLn(NewValue);
  if NewValue <> '' then
    Contacts[Index].Phone := NewValue;

  Write('New email (Enter to keep current): ');
  ReadLn(NewValue);
  if NewValue <> '' then
    Contacts[Index].Email := NewValue;

  WriteLn('Contact updated.');
end;

Deleting a Contact (Soft Delete)

procedure DeleteContact;
var
  SearchTerm: string;
  Index: Integer;
  Confirm: Char;
begin
  WriteLn;
  Write('Delete — enter name to find: ');
  ReadLn(SearchTerm);

  Index := FindContact(SearchTerm);
  if Index = -1 then
  begin
    WriteLn('Contact not found.');
    Exit;
  end;

  WriteLn('Found: ', Contacts[Index].Name,
          ' | ', Contacts[Index].Phone,
          ' | ', Contacts[Index].Email);
  Write('Delete this contact? (Y/N): ');
  ReadLn(Confirm);

  if UpCase(Confirm) = 'Y' then
  begin
    Contacts[Index].Deleted := True;
    WriteLn('Contact deleted.');
  end
  else
    WriteLn('Deletion cancelled.');
end;

Listing All Contacts

procedure ListContacts;
var
  I: Integer;
  ActiveCount: Integer;
begin
  WriteLn;
  WriteLn('--- All Contacts ---');
  WriteLn('  #  Name                                     Phone           Email');
  WriteLn('  -- ---------------------------------------- --------------- --------------------------------------------------');

  ActiveCount := 0;
  for I := 0 to ContactCount - 1 do
  begin
    if Contacts[I].Deleted then Continue;

    Inc(ActiveCount);
    WriteLn(I:4, '  ',
            Contacts[I].Name:-40, ' ',
            Contacts[I].Phone:-15, ' ',
            Contacts[I].Email);
  end;

  WriteLn;
  WriteLn('Active contacts: ', ActiveCount);
  WriteLn('Total records (including deleted): ', ContactCount);
end;

Compacting the Database

Over time, deleted records waste space. A "compact" operation removes them:

procedure CompactDatabase;
var
  WritePos, ReadPos: Integer;
  Removed: Integer;
begin
  WritePos := 0;
  Removed := 0;

  for ReadPos := 0 to ContactCount - 1 do
  begin
    if not Contacts[ReadPos].Deleted then
    begin
      if WritePos <> ReadPos then
        Contacts[WritePos] := Contacts[ReadPos];
      Inc(WritePos);
    end
    else
      Inc(Removed);
  end;

  ContactCount := WritePos;
  WriteLn('Compacted database: removed ', Removed, ' deleted records.');
  WriteLn('Active records: ', ContactCount);
end;

The Main Menu

var
  Choice: Char;
begin
  WriteLn('========================================');
  WriteLn('  Phone Book — A Simple Contact Manager');
  WriteLn('========================================');
  WriteLn;

  LoadDatabase;

  repeat
    WriteLn;
    WriteLn('[A] Add contact');
    WriteLn('[S] Search contacts');
    WriteLn('[E] Edit contact');
    WriteLn('[D] Delete contact');
    WriteLn('[L] List all contacts');
    WriteLn('[C] Compact database');
    WriteLn('[Q] Save and quit');
    Write('Choice: ');
    ReadLn(Choice);
    Choice := UpCase(Choice);

    case Choice of
      'A': AddContact;
      'S': SearchContact;
      'E': EditContact;
      'D': DeleteContact;
      'L': ListContacts;
      'C': CompactDatabase;
      'Q': SaveDatabase;
    end;
  until Choice = 'Q';

  WriteLn('Goodbye!');
end.

Analysis: Design Decisions

Why Soft Deletes?

Physical deletion in a typed file requires either shifting all subsequent records forward (O(n) I/O operations) or moving the last record into the gap (changes ordering). Soft deletes avoid both problems at the cost of wasted space that can be reclaimed periodically through compaction.

In production databases, this pattern is extremely common. PostgreSQL, for instance, uses a similar "mark and vacuum" approach.

Why a Hybrid Memory/File Approach?

Loading everything into memory gives us fast in-session operations. Saving to a typed file gives us persistence. For a phone book of 500 contacts, the memory footprint is roughly 500 * SizeOf(TContact) bytes — well under 100 KB.

For a database of millions of records, this approach would not scale, and we would need to read/write records directly from the file using Seek. But for our tutoring business use case, it is perfectly adequate.

What Could Go Wrong?

  1. Power failure during save: The file could be corrupted. A safer approach is to save to a temporary file, then rename it over the original — the Rename operation is atomic on most filesystems.

  2. Concurrent access: If two copies of the program run simultaneously, they will overwrite each other's changes. A production system would use file locking.

  3. Record type changes: If we add a field to TContact, old data files become incompatible. A versioning system (storing a version number as the first record) would help with migration.


Exercises Based on This Case Study

  1. Add an "Export to CSV" feature that writes all active contacts to a text file in CSV format.

  2. Add an "Import from CSV" feature that reads contacts from a CSV file and adds them to the database.

  3. Implement the "safe save" pattern: write to phonebook.tmp, then delete the old phonebook.dat, then rename phonebook.tmp to phonebook.dat.

  4. Add a TContactV2 record with a Notes: string[100] field. Write a migration procedure that reads a V1 file and creates a V2 file.

  5. Replace the linear search with a more efficient approach: maintain a sorted index array and use binary search. (Hint: sort by name, store indices into the main array.)