Odczyt z plikowej bazy danych SQL po stronie przeglądarki

Dla tego postu utworzyłam hashtag crazy, bo będziemy dzisiaj robić rzeczy absurdalne. Albo absurdalnie wspaniałe. Z pewnością nie nadające się na produkcję, ale chcę, żebyście pobawili się razem ze mną :)

Long story short: istnieje biblioteka sql.js, która pozwala się "połączyć się" z bazą plikową w trybie odczytu i wywoływać normalne zapytania SQL. Wszystko w przeglądarce, bez części back-endowej.

Zaczynajmy

Za pomocą darmowego programu DB Browser for SQLite przygotujmy sobie bazę danych. Ma przyjazny interfejs dzięki któremu możemy sobie wyklikać i zapisać nową bazę danych do pliku :)

Stwórzmy tam tabelę projects z kolumnami id i name, z czego id będzie autogenerowanym kluczem (zaznacz opcje primary key oraz autogenerated). Wypełnijmy ją przykładowymi danymi.

Teraz stwórzmy front-endowy projekt :)

Zwykły plik HTML, plik JS oraz webpack.config - to podstawa naszego eksperymentu. Dla uproszczenia skorzystamy również z npm'a, ale gdy dodasz plik js do strony to również będzie działać :)

//package.json
{
	"name": "sqljsexample",
	"version": "0.0.0",
	"description": "Example of sql.js usage",
	"main": ".script.js",
	"scripts": {
		"webpack": "webpack --config webpack.config.js",
		"server": "webpack-dev-server"
	},
	"author": "Solution Chaser",
	"license": "ISC",
	"devDependencies": {
		"webpack": "^4.18.0",
		"webpack-cli": "^3.1.0",
		"webpack-dev-server": "^3.1.8"
	},
	"dependencies": {
		"sql.js": "^0.5.0"
	}
}

Jedyną zależnością jest wyżej wspomniana biblioteka, ale do pracy developerskich potrzebujemy także serwera webpackowego. Dlaczego? Jak powszechnie wiadomo przeglądarka nie ma dostępu do dysku, może tylko pobierać pliki po HTTP. I tak dokładnie zrobimy :)

Nie jest to problem ponieważ:

1) W wersji developerskiej wystawimy sobie plik database poprzez webpack-dev-server.

2) W wersji produkcyjnej i tak potrzeba będzie wystawić pliki statyczne, więc dodatkowy nie zrobi różnicy.

//webpack.config.js
module.exports = {
	entry: './script.js',
	mode: "development",
	output: {
		filename: 'build.js',
		publicPath: '/dist/'
	},
	node: {
		fs: 'empty'
	}
}

Tutaj należy zwrócić na dwie rzeczy:

1) Zdefiniowanie publicPath w której umieścimy bazę

2) Właściwość node, w której istnieje `fs: empty`. Żeby nie przedłużać: webpack nie lubi się z sql.js, dzięki temu poprawnie wygeneruje się plik wynikowy :)

//index.html
<!DOCTYPE html>
<html lang="en">
	<head>
		<meta charset="UTF-8">
		<meta name="viewport" content="width=device-width, initial-scale=1.0">
		<meta http-equiv="X-UA-Compatible" content="ie=edge">
		<link href='https://fonts.googleapis.com/css?family=Roboto:300,400,500,700|Material+Icons' rel="stylesheet">
		<title>Simple Vue VideoPlayer</title>
	</head>

	<body>
		<h2>List of my projects</h2>
		<ul id="projects"></ul>
		<script src="/dist/build.js"></script>
	</body>
</html>

Maksymalnie uprościłam plik html, mamy tutaj pustą listę z zdefiniowanym id.

Pobieranie bazy danych

Najpierw musimy pobrać bazę. Normalnie, fetchem, tylko potem zamienić odpowiedź na bufor, a potem na odpowiedni typ (Uint8Array). Wynika to z faktu, że najpierw pobieramy plik binarny i musimy pobrać cały stream (pierwsza konwersja), a potem konwertujemy go na typ, który przyjmie konstruktor biblioteki.

fetch(config.url).then((response) => {
    response.arrayBuffer().then((buffer) => {
    	database(buffer);
    })
})

Baza danych

Skoro już wywołaliśmy u góry funkcję inicjującą to trzeba ją teraz napisać :)

Najważniejszą informacją jaką trzeba przyswoić jest fakt, że baza jest przechowywana w pamięci przeglądarki i można na niej zrobić wszystko, także dodawać tabele i krotki. Niestety nie wpływa to na plik na serwerze. Jednak do tego jeszcze wrócimy.

const database = (buffer) => {
    const uInt8Array = new Uint8Array(buffer);
    const db = new SQL.Database(uInt8Array);

    const projects = db.exec("select id, name from projects");
    fillTable(projects[0].values);
}

Funkcja SQL.Database przyjmuje tablicę 8-bitowych intów lub można pozostawić konstruktor bez argumentów. Wtedy tworzymy nową, pustą bazę danych.

Po utworzeniu obiektu db, możemy spokojnie wywoływać funkcję z zapytaniem SQL. Warto zapoznać się z nimi, bo jest exec nie jest jedyną opcją.

Odpowiedź, która przyjdzie ma niestety przedziwny format, gdyż zawiera tablicę z zarówno konstrukcją tabeli jak i wynikami, które nie są obiektem, a... kolejną tablicą.

Poszczególny wiersz, gdy już się do niego dokopiemy, wygląda tak: ['1', 'project name'] zamiast obiektu.

Dlatego moim zdaniem używanie tego bez dodatkowych bibliotek lub ORMa jest strasznie męczące i zaraz podam wam rekomendację.

Na koniec funkcja wyświetlająca wyniki, żeby HTML nie pozostał pusty:

    if (!list.length) {
        return;
    }
    const projecstList = document.getElementById('projects');
    list.forEach((row) => {
        const element = document.createElement('li');
        element.innerHTML = row.toString();
        projecstList.appendChild(element);

    });

Warto również wspomnieć również, że istnieje możliwość eksportu takiej bazy z pamięci do zmiennej. Całkowicie przeczy to postawionej tu idei, ale dałoby się ją wysłać i zapisać na serwerze przy pomocy backendowej aplikacji.

Posumowanie

Temat kompilacji z C do Javascript jest strasznie ciekawy, a sama możliwość zabawy z bazą plikową na frontcie daje mnóstwo możliwości. Niewątpliwie jest to rozwiązanie naszpikowane wadami, ale sądzę, że znajdą się przypadki, szczególnie nieprodukcyjne, gdzie może to być strzał w dziesiątkę. Ba! Może w jakimś przypadku posiadanie małych, tanich baz read-only będzie ogromną zaletą (na przykład przy testach automatycznych).

Przede wszystkich nie podchodziłabym do tego bez ORMa, osobiście korzystam z TypeORM, który posiada pełne wsparcie dla sql.js i serdecznie go polecam.

Chciałabym jeszcze zwrócić na coś uwagę: Bazy SQLite w wersji obsługiwanej przez sql.js nie są zabezpieczone hasłem ani szyfrowane, więc udostępnienie ich jest bardzo niebezpieczne.

Dziękuję za uwagę, mam nadzieje, że temat was zainteresował lub/i poszerzył horyzonty. Jak zawsze czekam na komentarze i pytania, zachęcam do udostępnienia :)

Na koniec pełny skrypt:

const SQL = require('sql.js');

window.onload = () => {
    console.log('hello');
    fetchDatabase();
}
const config = {
    url: '/dist/database'
}
const fetchDatabase = () => {
    fetch(config.url).then((response) => {
        response.arrayBuffer().then((buffer) => {
            database(buffer);
        })
    })
}

const database = (buffer) => {
    const uInt8Array = new Uint8Array(buffer);
    const db = new SQL.Database(uInt8Array);

    const response = db.exec("select id, name from projects");
    fillTable(response[0].values);
}

const fillTable = (list) => {
    if (!list.length) {
        return;
    }
    const projecstList = document.getElementById('projects');
    list.forEach((row) => {
        const element = document.createElement('li');
        element.innerHTML = row.toString();
        projecstList.appendChild(element);
    });
}

Kamila

Dziękuję za poświęcony czas, będzie mi bardzo miło jak zostawisz komentarz :)