Dane w postaci tekstowej mogą posiadać różnorodne formaty. Poszczególne wartości mogą być w nich odseparowane od siebie w dowolny sposób: albo za pomocą dedykowanego znaku, albo poprzez przyjęcie, że każda wartość zajmuje z góry ustalony obszar, a jeśli nie wypełnia jego całości, to jest on uzupełniany spacjami. Same wartości mogą mieć różną postać, wystarczy chociażby pomyśleć o dacie, która może być podawana jako jedna liczba lub jako trójka liczb określająca dzień, miesiąc, rok i to niekoniecznie w tej kolejności. Dodatkowo owe trzy liczby mogą być odseparowane od siebie także jakimś znakiem (,.,/, itd.).

Nie należy także zapominać, że separator poszczególnych wartości może występować również w samej wartości, gdzie spełnia zupełnie inną rolę – jest jej nierozerwalną częścią. Przykładem takiej wartości może być liczba z częścią ułamkową, w której separatorem części ułamkowej jest przecinek. Jeśli teraz separatorem wartości także będzie przecinek, to wystąpi niejednoznaczność. Innym przykładem takiej wartości będzie taka, która przechowuje treść dowolnego zdania w języku polskim. Z pewnością należy oczekiwać w niej przecinka.

Tym co charakteryzuje dane zapisane w pliku tekstowym jest to, że każdy jego wiersz oznacza zamknięty zestaw wartości (rekord/encję). Bywają wszakże takie wartości (jak choćby wspomniane zdania w języku polskim), w których zawarty jest znak końca wiersza. Powoduje to problem, albowiem ten „koniec wiersza” nie będzie tak naprawdę rzeczywistym jego końcem (zestaw wartości się urwie). Jest to właściwie problem podobny do opisanego wcześniej, z tym że teraz wartość zawiera nie separator wartości, ale rekordów.

Biorąc pod uwagę wszystkie opisane wyżej cechy plików tekstowych z danymi, można wysnuć słuszny wniosek, że stworzenie oprogramowania do ich przetwarzania nie wydaje się być zadaniem prostym. Dodatkowo trudności może dostarczać język, za pomocą którego przetwarzanie plików miałoby się odbywać. W przypadku C# wachlarz sposobów, jak i ich wydajność, są do przyjęcia, ale w przypadku TSQL sprawa poważnie się komplikuje. Najlepiej byłoby, gdyby w każdym z tych (a także innych) języków było równie łatwo takie pliki przetwarzać, tj. bez pisania megabajtów kodu – po prostu zdefiniować strukturę pliku i uzyskać dostęp do zgodnych z nią danych.

Jak się okazuje taki sposób istnieje i jest dostępny „z dobytkiem inwentarza”, czyli z systemem operacyjnym. Jest to dedykowany sterownik dla plików tekstowych dostępny z pakietem sterowników MDAC. Można go używać zarówno z poziomu TSQL, jak i C#. W obu przypadkach należy po prostu skorzystać z dostawcy OLE DB o nazwie Microsoft.Jet.OLEDB.4.0. Należy nadmienić, że ten sterownik ma swoje lata, niemniej pozwala uzyskać oczekiwane efekty. Dla spragnionych nowszych rozwiązań istnieje następca tego sterownika, wchodzący w skład pakietu Office, ale dostępny też niezależnie (i za darmo). Jego nazwa to Microsoft Office 12.0 Access Database Engine OLE DB Provider. Należy go pobrać z witryny Microsoft i zainstalować. To co dodatkowo wyróżnia ten nowszy sterownik to fakt, że występuje w dwóch wersjach: 32 i 64 bitowej. Jest to o tyle istotne, że dla serwera SQL 64bit sterownik Microsoft.Jet.OLEDB.4.0 (który jest wyłącznie 32 bitowy) nie może być używany przez brak możliwości uruchamiania w środowisku 64 bitowym. W takim przypadku jedynym sposobem na uzyskanie dostępu do plików tekstowych jest użycie Microsoft Office 12.0 Access Database Engine OLE DB Provider.

Należy także wspomnieć, że sterownik ten potrafi obsługiwać nie tylko pliki tekstowe, ale także pliki DBF, arkusze Excell, bazy danych Access. Niemniej obecnie chciałbym się skupić wyłącznie na plikach tekstowych i przetwarzaniu zawartych w nich danych.

Skoro napisałem, że przetworzenie plików w obu językach jest równie proste, pora zademonstrować przykład. To co wspólne dla obu platform, to nawiązanie połączenia z bazą danych (która jest tożsama z folderem, w którym znajdują się przetwarzane pliki) i wykonanie zapytania SQL, gdzie nazwą tabeli jest nazwa pliku (najlepiej podawać ją w nawiasach kwadratowych, ze względu na kropkę oddzielającą rozszerzenie pliku od jego nazwy, zaś samą kropkę zastępować znakiem „#” – bywa, że kropka nie jest akceptowana). Należy też podkreślić, że pod pojęciem przetwarzanie mam na myśli wyłącznie odczyt pliku (bez jego modyfikacji, choć np. dodawanie nowych rekordów jest możliwe) i jego ewentualną obróbkę później, już za pomocą mechanizmów danego języku. W przykładzie podam zatem jedynie sposób na odczyt pliku.

TSQL
select * 
from OpenRowset(
	'Microsoft.Jet.OLEDB.4.0', 'Data Source=D:\TXT\;Extended Properties=Text',
	'select * from [plik#txt]'
);
C#
public class TextDataReader
{
	public void Use()
	{
		Read(@"Provider=Microsoft.Jet.OLEDB.4.0;Data Source=D:\TXT;Extended Properties=Text", "select * from [plik#txt]");
	}

	public void Read(string connectionString, string sql)
	{
		OleDbConnection connection = new OleDbConnection();
		connection.ConnectionString = connectionString;
		connection.Open();
		OleDbCommand command = new OleDbCommand(sql, connection);
		OleDbDataReader reader = command.ExecuteReader();
		int count = reader.FieldCount;
		while (reader.Read())
		{
			for (int i = 0; i < count - 1; i++)
				Console.Write(reader.GetValue(i).ToString() + '|');
			Console.WriteLine(reader.GetValue(count - 1).ToString());
		}
	}

W przypadku TSQL kod jest krótszy, ale za to należy zadbać o odpowiednie warunki tj. umożliwić wykonywanie doraźnie rozprowadzanych zapytań (Ad Hoc Distributed Queries)

EXEC sp_configure 'show advanced option', '1';
RECONFIGURE;
EXEC sp_configure 'Ad Hoc Distributed Queries', '1'
RECONFIGURE;

Uważny czytelnik z pewnością odnotował, że nigdzie nie definiowałem czego tak naprawdę należy oczekiwać w pliku, czyli jaka jest jego struktura. To prawda. Przyszła zatem pora, aby przybliżyć tę kwestię. Za określenie formatu plików odpowiada … inny plik. W folderze, w którym znajduje się przetwarzany plik (lub pliki) należy umieścić plik o nazwie schema.ini. Format tego pliku opiera się na sekcjach, w których zapisana jest lista cech pliku w postaci: cecha=wartość_cechy. Samą sekcję oznacza się nazwą pliku umieszczoną w nawiasach kwadratowych. Jest to więc typowy format, jaki mają, istniejące co najmniej od czasów środowiska graficznego Windows 3.x, pliki z rozszerzeniem INI. Oto przykład:

[PlikSeparowanyTabulącją.txt]
CharacterSet=Ansi
Format=TabDelimited
ColNameHeader=false

[PlikSeparowanyŚrednikiem.txt]
CharacterSet=1250
ColNameHeader=false
TextDelimiter=none
DecimalSymbol=.
Format=Delimited(;)
Col1 = Id Integer
Col2 = Number Integer
Col3 = Symbol Text
Col4 = Net Currency
Col5 = Gross Currency
Col6 = Tax Currency

Za pomocą takiego pliku możliwe jest odczytanie dwóch plików tekstowych o nazwach podanych w nawiasie. Z pewnością niejeden z czytelników dostrzega tutaj potencjalny brak elastyczności – dla każdego pliku (tj. o innej nazwie) potrzebny jest oddzielny wpis. To prawda, albowiem trzeba pamiętać, że schema.ini tak naprawdę jest substytutem metadanych tabel bazy – jak wspomniałem folder jest traktowany jako baza danych, a pliki są jej tabelami. Oczywiście ten brak elastyczności nie jest jakąś wielką przeszkodą. Raz – można generować go dynamicznie, dwa – plik o danym formacie można zawsze kopiować do folderu pod taką samą nazwą (czyli wiele plików można przetworzyć po kolei kopiując je na ten sam plik).

Jedyne pliki, które nie wymagają schema.ini, to pliki z rozszerzeniem CSV (wartości rozdzielane przecinkiem). Ale w takim przypadku typ danych w kolumnach jest zgadywany przez sterownik. Dlatego także dla nich warto posiadać stosowne sekcje w schema.ini.

Jakie cechy można zdefiniować w pliku schema.ini? Oto ich lista wraz z objaśnieniami.

ColNameHeader

Określa czy w pliku tekstowym pierwszy wiersz zawiera nazwy kolumn (True), czy od razu wartości (False). Dzięki temu możliwe jest pominięcie takiego wiersza, aby nie był traktowany jako dane.

MaxScanRows

Podana wartość liczbowa określa ilość wierszy, które podlegają analizie w celu ustalenia typów wartości. Wpisanie 0 oznacza przeskanowanie całego pliku.

CharacterSet

Określa kodowanie znaków narodowych w pliku. Dostępne wartości to: UNICODE, ANSI, OEM lub liczba określająca nr strony kodowej (np. dla kodowania polskiego Windows to 1250, a dla polskiego DOS to 852).

Format

Określa sposób rozdzielania poszczególnych wartości. Dostępne są następujące możliwości:

  1. FixedLength – każde wartość ma z góry ustaloną długość (definiowaną za pomocą innej cechy) co jest równoznaczne z brakiem separatora pomiędzy wartościami; granice wartości określa ich długość – jeśli wartość jest krótsza niż oczekiwana długość, to pozostały obszar jest uzupełniany spacjami; jeżeli wybrano ten format, to konieczne jest także zdefiniowanie długości wartości za pomocą dodatkowych cech (Col).
  2. TabDelimited – wartości są odseparowane od siebie znakiem specjalnym – tabulacją (jest to znak o kodzie ASCII 9, można go np. spotkać w plikach *.cs, jeśli użyto go do wcinania kodu).
  3. CSVDelimited – wartości są odseparowane przecinkiem (Comma Separated Values) i rzeczywiście jest to zawsze przecinek niezależnie od ustawień regionalnych (gdzie np. jako separator listy mógł zostać ustalony średnik).
  4. Delimited() – ta opcja ma dodatkowy parametr , który może być dowolnym znakiem (choć nigdy nie próbowałem użyć nawiasu, więc nie wiem jak się zachowa, nie sprawdzałem też znaków specjalnych, czyli z zakresu ASCII 0..31).
FixedFormat

Ta cecha jest ściśle związana z poprzednią i ma sens jedynie wówczas, gdy jako format wybrano FixedLength (ale nie musi być wyspecyfikowana). Określa wówczas czy ostatnia wartość w wierszu może być skrócona (nie jest uzupełniana spacjami – domyślne zachowanie) poprzez znak końca wiersza (RaggedEdge), czy musi mieć oczekiwaną długość (TrueFixedLength). Dzięki drugiemu z ustawień możliwe jest przekazywanie wartości, które posiadają w treści znak końca linii – nie zostanie on wówczas uznany jako koniec rekordu, np. dla pliku, który zawiera pięcioznakową wartość określającą identyfikator oraz dwudziestoznakową wartość tekstową można zawrzeć w niej znak końca linii:

1    AbCdEfGhIjKlMnOpQrSt
2    1234
789
bcde
hij
3    123456789-123456789.

Wiersze 2 do 5 zostaną potraktowane jako jeden rekord, który zawiera znaki końca linii. Jednakowoż … to tylko teoria, ponieważ – pomimo, że tak twierdzi dokumentacja – nie udaje się niestety uzyskać oczekiwanego efektu.

TextDelimiter

Ta cecha umożliwia określenie znaku ograniczającego treść zawierającą znaki specjalne (np. separator używany do oddzielania wartości lub oddzielania wierszy/rekordów). Jeśli ta cecha nie zostanie zawarta w definicji struktury, wówczas zakłada się, że ogranicznikiem jest cudzysłów. Aby wyeliminować ten ogranicznik należy użyć wartości none (TextDelimiter=none).

Dzięki tej cesze możliwe jest (praktycznie – nie tak jak w przypadku cechy FixedFormat) przekazywanie wartości, które posiadają w treści znak końca linii, lub separator wartości. Dla poniższego pliku wiersze 2 do 5 zostaną potraktowane jako jeden rekord.

1,AbCdEfGhIjKlMnOpQrSt
2,"1234
789
bcde
hij"
3,"123456789""123456789."

Jeżeli w treści występuje cudzysłów – wówczas należy zamienić go na podwójny, dzięki czemu zostanie właściwie zinterpretowany – przykład widać w ostatnim wierszu pliku.

Aby być do końca zgodny z prawdą, muszę napisać, że prawidłowe interpretowania znaku końca linii nie działa wszędzie. O ile w TSQL wszystko jest w należytym porządku, to z poziomu C# wiersze od 2 do 5 nie są sklejane w całość. Nie dochodziłem na razie powodu takiego zachowania, ale jeśli tylko go odkryję, to z pewnością się nim podzielę na łamach tego bloga.

DecimalSymbol

Określa znak, który będzie oddzielał część całkowitą od części ułamkowej liczby. W przypadku braku tego wpisu używana jest wartość domyślna zdefiniowana w Panelu Sterowania systemu Windows. Jeżeli jako format pliku ustalono CSVDelimited, to konieczne jest zdefiniowanie tej cechy innym znakiem niż przecinek (szczególnie jeśli w Panelu Sterowania separatorem części całkowitej od ułamkowej jest właśnie przecinek – standardowe ustawienie w polskich Windows).

NumberDigits

Definiuje liczbę cyfr dziesiętnych w części ułamkowej liczby. W przypadku braku tego wpisu używana jest wartość domyślna zdefiniowana w Panelu Sterowania systemu Windows.

NumberLeadingZeros

Określa, czy wartość dziesiętna mniejsza od 1 i większa od –1 ma zawierać zera wiodące. Dopuszczalne wartości to False (bez zer wiodących) i True.

DateTimeFormat

Można ustawić ciąg znaków formatu określającego datę i godzinę. Wartość należy podać, jeśli wszystkie pola daty/godziny w operacji importu/eksportu są obsługiwane przy użyciu tego samego formatu. Obsługiwane są wszystkie formaty aparatów baz danych Microsoft Jet, z wyjątkiem AM i PM. Jeśli nie zostanie określony ciąg znaków formatu, będą używane opcje obrazu krótkiej daty i godziny stosowane w Panelu Sterowania systemu Windows.

Col1, …, ColN

Skoro plik schema.ini definiuje strukturę plików, to należałoby oczekiwać także specyfikacji poszczególnych kolumn (wartości) zawartych w pliku tekstowym. Do tego właśnie służą cechy Col, gdzie określa numer (kolejność) kolumny (wartości) w rekordzie. Jeśli kolumn jest 5, to należy zdefiniować 5 tego typu cech w sekcji specyfikującej plik (Col1, Col2, …, Col5). Szablon definicji takiej cechy jest następujący:

ColX=Nazwa_kolumny typ [Width #]
lub
ColX=Nazwa_kolumny Decimal [Scale # Precision #] (kiedy – jak widać – typ jest Decimal)

Dostępne typy to: Bit, Byte, Short (lub Integer), Long, Decimal, Currency, Single, Double (lub Float), DateTime, Binary, OLE, Text (lub Char) i Memo (lub LongChar) lub Date format_daty

Nazwa kolumny może zawierać spacje, ale wówczas musi być zawarta w cudzysłowie.

Currency…

Oprócz opisanych powyżej cech istnieją jeszcze takie, które określają właściwości wartości zawierających walutę, są to: CurrencySymbol, CurrencyPosFormat, CurrencyDigits, CurrencyNegFormat, CurrencyThousandSymbol, CurrencyDecimalSymbol. Nie opisuje ich tutaj, ponieważ nie miałem okazji ich stosować. Informacje na ich temat można uzyskać z systemu pomocy pakietu Office dotyczącej Accessa (np. stąd).