Naciśnij “Enter” aby skoczyć do treści

Tiny Radio Player #08 – Baza danych

Dane aplikacji możemy przechowywać w różny sposób. Możemy je zapisywać w plikach, w bazie danych lub w chmurze. W naszym przypadku będą to słowniki oraz dane stacji więc dla przejrzystości moglibyśmy wybrać plik xml. Jednak w celach edukacyjnych użyjemy bazy danych SQLite oraz biblioteki ZeosLib (opis jej instalacji znajdziesz w tym miejscu).

Instalacja SQLite

Właściwie nie można mówić o instalacji bo jedyne co musimy zrobić to ściągnąć odpowiedni plik biblioteki SQLite i umieścić go w katalogu ./data/lib. Następnie już z poziomu kodu aplikacji, korzystając z biblioteki ZeosLib, ustanowić połączenie do pliku bazy danych. Jeżeli plik bazy danych jeszcze nie istnieje to zostanie utworzony przy pierwszym uruchomieniu aplikacji.

Pliki biblioteki SQLite można znaleźć pod adresem http://www.sqlite.org lub bezpośrednio w repozytorium TinyRadioPlayer w katalogu ./lib/sqlite/.

  • Windows – pobieramy plik sqlite3.7z, rozpakowujemy i umieszczamy w katalogu bin\i386-win32\data\lib
  • Linux – pobieramy plik libsqlite3.7z, rozpakowujemy i umieszczamy w katalogu bin/x86_64-linux/data/lib
  • MacOSX –  pobieramy plik libsqlite.7z, rozpakowujemy i umieszczamy w katalogu bin/i386-darwin/data/lib

Nawiązanie połączenia

Dla przejrzystości przedstawię tylko prosty przykład tworzenia połączenia z bazą danych, resztę znajdziecie w repozytorium na GitHubie. Aby jednak nie zostawiać Was z niepełną wiedzą opiszę również ogólną koncepcję jaka przyświecała mi podczas projektowania dostępu do bazy danych.

Główną część zarządzającą bazą danych umieścimy w pliku BaseRepository. Jest to klasa abstrakcyjna, której zadaniem będzie nawiązanie połączenia z bazą danych, utworzenie schematu nowej bazy oraz podpięcie pozostałych repozytoriów. Dodatkowo znajdą się tutaj również metody abstrakcyjne, których implementacja zostanie przeniesiona do klas dziedziczących. Dla przykładu metoda CreateDML (tworząca domyślne dane) zostanie zaimplementowana w klasie MainRepository. Taki sposób da nam możliwość podpięcia się pod inną bazę danych, np. bazę z ulubionymi stacjami.

Do utworzenia połączenia z bazą danych użyjemy komponentu TZConnection. Jest on częścią pakietu zcomponent więc dodajemy go do Required Packages w oknie Project Inspector.

Teraz możemy nawiązać połączenie z bazą danych.

function TBaseRepository.Connect(const DBName: string): ErrorId;
var
  err: ErrorId;
  filePath: string;
begin
  err := ERR_OK;

  if DBName = EMPTY_STR then
    err := ERR_DB_NO_DATABASE_NAME;

  if err = ERR_OK then
  begin
    err := Disconnect;

    if err = ERR_OK then
    begin
      try
        FConnection := TZConnection.Create(nil);
        FConnection.Protocol := 'sqlite-3';
        FConnection.LibraryLocation :=
          ConcatPaths([GetApplicationPath, LIB_PATH,
          {$IFDEF MSWINDOWS}
          'sqlite3.dll'
          {$ENDIF}
          {$IFDEF LINUX}
          'libsqlite3.so'
          {$ENDIF}
          {$IFDEF MACOS}
          'sqlite3.dylib'
          {$ENDIF}
          ]);

        // Try connect to database
        if not FileExists(DBName) then
        begin
          // check if directory exists, if not create it
          filePath := ExtractFilePath(DBName);
          if not DirectoryExists(filePath) then
            if not CreateDir (filePath) then
            begin
              Result := ERR_DB_CREATE_DIR;
              Exit;
            end;

          FConnection.Database := DBName;
          FConnection.Connect;
          CreateDB;
        end else
        begin
          FConnection.Database := DBName;
          FConnection.Connect;
          SetDBSettings;
        end;

      except
        on E: Exception do
        begin
          LogException(EmptyStr, ClassName, 'Connect', E);
          //
          err := ERR_DB_CONNECT_ERROR;
        end;
      end;

    end;
  end;

  Result := err;
end;

Ważne jest, aby prawidłowo ustawić protokół z jakiego korzystamy oraz podać dokładne ścieżki do biblioteki SQLite i pliku bazy danych. Jeżeli plik bazy danych wcześniej nie istniał to przy nawiązaniu połączenia zostanie utworzony.

Tworzenie nowej bazy

Nowo utworzony plik bazy danych nie zawiera tabel oraz danych więc musimy utworzyć je sami.

function TBaseRepository.CreateDB: ErrorId;
var
  err: ErrorId;
begin
  err := ERR_OK;

  try
    // Database settings

    // The Boolean synchronous value controls whether or not the
    // library will wait for disk writes to be fully written to disk
    // before continuing. In typical use the library may spend a lot of
    // time just waiting on the file system.
    // Setting "PRAGMA synchronous=OFF" can make a major speed difference.
    FConnection.ExecuteDirect('PRAGMA synchronous = OFF;');

    // The temp_store values specifies the type of database back-end to use
    // for temporary files.
    // The choices are DEFAULT (0), FILE (1), and MEMORY (2).
    // The use of a memory database for temporary tables can produce
    // signifigant savings. DEFAULT specifies the compiled-in default,
    // which is FILE unless the source has been modified.
    FConnection.ExecuteDirect('PRAGMA temp_store = MEMORY;');

    // The default behavior of the LIKE operator is to ignore case for
    // .SCII characters
    FConnection.ExecuteDirect('PRAGMA case_sensitive_like = OFF;');

    // Unless already in a transaction, each SQL statement has a new
    // transaction started for it. This is very expensive, since it requires
    // reopening, writing to, and closing the journal file for each statement.
    // This can be avoided by wrapping sequences of SQL statements with
    // BEGIN TRANSACTION; and END TRANSACTION; statements.
    // This speedup is also obtained for statements which don't alter
    // the database.
    // The keyword COMMIT is a synonym for END TRANSACTION.
    FConnection.ExecuteDirect('BEGIN TRANSACTION;');

    err := CreateDDL;

    if err = ERR_OK then
      err := CreateDML;

    if (err = ERR_OK) and (not FConnection.ExecuteDirect('COMMIT;')) then
      err := ERR_DB_CREATE_ERROR;

    if err <> ERR_OK then
      FConnection.ExecuteDirect('ROLLBACK;');

  except
    on E: Exception do
    begin
      LogException(EmptyStr, ClassName, 'CreateDB', E);
      FConnection.ExecuteDirect('ROLLBACK;');
      err := ERR_DB_CREATE_ERROR;
    end;
  end;

  Result := err;
end;

Aby przyspieszyć zapis i odczyt z bazy danych zmieniamy kilka ustawień. Przede wszystkim ustawiamy, aby wszystkie tymczasowe tabele były trzymane w pamięci. Dodatkowo wyłączamy synchroniczny zapis co da możliwość dodawania dużej ilości danych bez zbędnego czekanie na ukończenie zapisu do pliku. Wyłączamy również rozróżnianie wielkości liter przy korzystaniu z operatora LIKE.

Cały proces tworzenia schematu i dodawania danych umieszczamy w transakcji. W taki sposób wyeliminujemy konieczność tworzenia transakcji dla każdej operacji.

Tworzenie schematu bazy danych

Schemat bazy danych utworzymy w metodzie CreateDDL.

function TBaseRepository.CreateDDL: ErrorId;
var
  query: TZQuery;
  err: ErrorId;

  procedure ExecuteQuery(AQuery: string);
  begin
    query.SQL.Add(AQuery);
    query.ExecSQL;
    query.SQL.Clear;
  end;

begin
  err := ERR_OK;

  try
    query := TZQuery.Create(nil);
    try
      query.Connection := FConnection;

      // Stations
      ExecuteQuery(
        'CREATE TABLE ' + DB_TABLE_STATIONS + ' (' +
        'ID INTEGER PRIMARY KEY NOT NULL, ' +
        'Name VARCHAR NOT NULL, ' +
        'StreamUrl VARCHAR NOT NULL, ' +
        'Description TEXT NULL, ' +
        'WebpageUrl VARCHAR NULL, ' +
        'GenreCode VARCHAR NULL, ' +
        'CountryCode VARCHAR NULL, ' +
        'Created INTEGER NOT NULL, ' +
        'Modified INTEGER NULL);');

    finally
      query.Free;
    end;

  except
    on E: Exception do
    begin
      LogException(EmptyStr, ClassName, 'CreateDDL', E);
      err := ERR_DB_CREATE_DDL_ERROR;
    end;
  end;

  Result := err;
end; 

Tworzymy zapytanie i wskazujemy aktywne połączenie z bazą danych. Następnie wykonujemy je co powoduje utworzenie nowej tabeli Stations.

Tworzenie danych

Dane słownikowe oraz informacje o stacjach utworzymy w metodzie CreateDML. Jest to metoda abstrakcyjna, której wywołanie znajduje się w klasie TBaseRepository. Jej implementacja została natomiast przeniesiona do klasy dziedziczącej TMainRepository.

function TMainRepository.CreateDML: ErrorId;
var
  err: ErrorId;
begin
  err := ERR_OK;

  try
    err := CreateDictionaries;
    err := CreateStations;
  except
    on E: Exception do
    begin
      LogException(EmptyStr, ClassName, 'CreateDML', E);
      err := ERR_DB_CREATE_DML_ERROR;
    end;
  end;

  Result := err;
end;

function TMainRepository.CreateStations: ErrorId;
var
  err: ErrorId;
  stationId: integer;
begin
  err := ERR_OK;

  // Stations
  err := StationRepo.AddStation('Radio Kaszebe', 'http://stream3.nadaje.com:8048',
    EMPTY_STR, 'http://radiokaszebe.pl/', 'Pop', 'PL', stationId);

  err := StationRepo.AddStation('Radio Malbork', 'http://78.46.246.97:9022',
    EMPTY_STR, 'https://www.radiomalbork.fm/', 'Pop', 'PL', stationId);

  err := StationRepo.AddStation('Planeta RnB', 'http://plarnb-01.cdn.eurozet.pl:8216/',
    EMPTY_STR, 'https://www.planetafm.pl/', 'RnBSoul', 'PL', stationId);

  Result := err;
end;

function TStationRepository.AddStation(const StationName: string;
  const StreamUrl: string; const Description: string; const WebpageUrl: string;
  const GenreCode: string; const CountryCode: string; out StationId: integer): ErrorId;
var
  query: TZQuery;
  err: ErrorId;
  dateNow: integer;
begin
  err := ERR_OK;

  try
    StationId := TRepository.GetNewDbTableKey(DB_TABLE_STATIONS);
    dateNow := GetUnixTimestamp();

    query := TZQuery.Create(nil);
    try
      query.Connection := TRepository.GetDbConnection;

      query.SQL.Add(
        'INSERT INTO ' + DB_TABLE_STATIONS +
        ' (ID, Name, StreamUrl, Description, WebpageUrl, GenreCode, CountryCode, Created, Modified) ' +
        'VALUES(:ID,:Name,:StreamUrl,:Description,:WebpageUrl,:GenreCode,:CountryCode,:Created,:Modified);'
      );

      query.Params.ParamByName('ID').AsInteger := StationId;
      query.Params.ParamByName('Name').AsString := StationName;
      query.Params.ParamByName('StreamUrl').AsString := StreamUrl;

      if (Description <> EMPTY_STR) then
        query.Params.ParamByName('Description').AsString := Description;

      if (WebpageUrl <> EMPTY_STR) then
        query.Params.ParamByName('WebpageUrl').AsString := WebpageUrl;

      if (GenreCode <> EMPTY_STR) then
        query.Params.ParamByName('GenreCode').AsString := GenreCode;

      if (CountryCode <> EMPTY_STR) then
        query.Params.ParamByName('CountryCode').AsString := CountryCode;

      query.Params.ParamByName('Created').AsInteger := dateNow;
      query.Params.ParamByName('Modified').AsInteger := dateNow;

      query.ExecSQL;

    finally
      query.Free;
    end;
  except
    on E: Exception do
      begin
        LogException(EMPTY_STR, ClassName, 'AddDatabaseStation', E);
        err := ERR_DB_ADD_STATION;
      end;
  end;

  Result := err;
end;

W powyższym kodzie widać, że powstało dodatkowe repozytorium o nazwie StationRepo. W taki sposób odseparowaliśmy operacje związane z dostępem do danych stacji. Istnieje również kolejne repozytorium o nazwie DictionaryRepo, które agreguje operacje słownikowe.

Pobieranie danych

Wszystkie operacje na danych zapisanych w bazie danych wykonujemy przy użyciu TZQuery, więc aby pobrać dane wystarczy wykonać komendę SELECT.

Pobranie pojedynczej wartości na przykład maksymalnego ID tabeli może wyglądać mniej więcej tak:

query := TZQuery.Create(nil);
try
  query.Connection := FConnection;

  // sql query
  query.SQL.Add('SELECT MAX(ID) FROM ' + TableName + ';');

  query.Open;

  if query.RecordCount = 1 then
	Result := query.Fields[0].AsInteger;

finally
  query.Free;
end;

Pobranie wielu rekordów wygląda podobnie, jedyną różnicą jest konieczność iteracji po obiekcie query:

query := TZQuery.Create(nil);
try
  query.Connection := TRepository.GetDbConnection;

  query.SQL.Add(
	'SELECT ' +
	'  S.ID, S.Name, S.GenreCode, DRG.Text AS GenreText, S.CountryCode, DRC.Text AS CountryText ' +
	'FROM ' + DB_TABLE_STATIONS + ' S ' +
	'WHERE S.Name LIKE :StationName;'
  );
  
  query.ParamByName('StationName').AsString := '%radio%';
  
  query.Open;
  
  while not query.EOF do
  begin
    myId := query.FieldByName('ID').AsInteger,
    myName := query.FieldByName('Name').AsString,
    myGenre := query.FieldByName('GenreText').AsString,
    myCountry := query.FieldByName('CountryText').AsString
	
    query.Next;
  end;
finally
  query.Free;
end;

Podsumowanie

Cały proces zarządzania bazą danych został rozbity na repozytoria. Głównym jest BaseRepository. Jest to klasa abstrakcyjna więc nie możemy utworzyć jej instancji. Jej zadaniem jest ustanowienie połączenia z bazą danych, utworzenie schematu dla nowych baz oraz podpięcie pozostałych repozytoriów takich jak StationRepo oraz DictionaryRepo.

Z BaseRepository dziedziczy MainRepository. Jest to repozytorium, do którego podpięty jest główny plik bazy danych i to właśnie przez te repozytorium wykonujemy wszystkie operacje na bazie danych.

Dodatkowo tworzymy repozytorium Repository, które jest wrapperem dla pozostałych repozytoriów. Tutaj tworzymy instancję MainRepository oraz mapujemy wszystkie operacje bazodanowe, do których powinny mieć dostęp inne obszary aplikacji.

Kod aplikacji dostępny jest na GitHubie. Jeżeli interesują Cię zmiany dotyczące tylko tego wpisu to znajdziesz je tu.