From 0afdfeb885922b6dd288198e63708d6965ae4f01 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Adri=C3=A1n=20Arroyo=20Calle?= Date: Sun, 26 Sep 2021 13:29:31 +0200 Subject: [PATCH] PostgreSQL Prolog 1.0 --- .github/workflows/test.yml | 40 +++++++++++ README.md | 53 ++++++++++++++ messages.pl | 142 +++++++++++++++++++++++++++++++++++++ postgresql.pl | 109 ++++++++++++++++++++++++++++ tester.lgt | 7 ++ tests.lgt | 37 ++++++++++ types.pl | 37 ++++++++++ 7 files changed, 425 insertions(+) create mode 100644 .github/workflows/test.yml create mode 100644 messages.pl create mode 100644 postgresql.pl create mode 100644 tester.lgt create mode 100644 tests.lgt create mode 100644 types.pl diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml new file mode 100644 index 0000000..601579f --- /dev/null +++ b/.github/workflows/test.yml @@ -0,0 +1,40 @@ +name: Test +on: [push] + +jobs: + test: + runs-on: ubuntu-20.04 + services: + postgres: + image: postgres:13.4-alpine + env: + POSTGRES_USER: postgres + POSTGRES_PASSWORD: postgres + POSTGRES_DB: postgres + POSTGRES_HOST_AUTH_METHOD: password + options: >- + --health-cmd pg_isready + --health-interval 10s + --health-timeout 5s + --health-retries 5 + ports: + - 5432:5432 + steps: + - name: Install Rust cargo + uses: actions-rs/toolchain@v1 + with: + toolchain: stable + profile: minimal + components: cargo + - name: Install Scryer Prolog + uses: logtalk-actions/setup-scryer-prolog@master + with: + scryer-prolog-version: latest + - name: Install Logtalk + uses: logtalk-actions/setup-logtalk@master + with: + logtalk-version: latest + - name: Checkout + uses: actions/checkout@v2 + - name: Execute tests + run: logtalk_tester -p scryer \ No newline at end of file diff --git a/README.md b/README.md index ab02463..a09ad8b 100644 --- a/README.md +++ b/README.md @@ -1,2 +1,55 @@ # postgresql-prolog A Prolog library to connect to PostgreSQL databases + +# Compatible systems + +* [Scryer Prolog](https://github.com/mthom/scryer-prolog) + +# Installation + +The library itself are just three Prolog files (postgresql.pl, messages.pl and types.pl). They need to be in the same folder. An easy way to install this library in your project is copying that files. Other way is using Git submodules to get this whole folder, and load the postgresql.pl file from there. + +``` +git submodule add https://github.com/aarroyoc/postgresql-prolog postgresql +``` + +# Usage + +The library provides two predicates: `connect/6` and `query/3`. + +``` +connect(+User, +Password, +Host, +Port, +Database, -Connection) +``` +Connects to a PostgreSQL server and tries to authenticate using `password` scheme. This is the only authentication method supported right now. Please, note that this auth method is not the default in some PostgreSQL setups, you changes are needed. If you're running PostgreSQL in Docker, you need to set the environment variable `POSTGRES_HOST_AUTH_METHOD` to `password`. + +``` +query(+Connection, +Query, -Result) +``` +Executes a SQL query over a connection. Result can be: + +- ok +- error(ErrorString) +- data(ColumnDescription, Rows) + +ok is returned if the query doesn't output a table (INSERT, UPDATE, DELETE, CREATE TABLE, ...) and succeeds. + +error(ErrorString) is returned if an error is found. + +data(ColumnDescription, Rows) is returned when a query outputs a table (SELECT). ColumnDescription is a list of column names and Rows is a list of a list of each cell value. + +# Examples + +``` +:- use_module('postgresql'). + +test :- + connect("postgres", "postgres", '127.0.0.1', 5432, "postgres", Connection), + query(Connection, "DROP TABLE IF EXISTS test_table", ok), + query(Connection, "CREATE TABLE test_table (id serial, name text)", ok), + query(Connection, "INSERT INTO test_table (name) VALUES ('test')", ok), + query(Connection, "SELECT * FROM test_table", Rows), + Rows = data(["id", "name"], [["1", "test"]]), + query(Connection, "UPDATE test_table SET name = 'test2' WHERE id = 1", ok), + query(Connection, "SELECT * FROM test_table", Rows2), + data(["id", "name"], [["1", "test2"]]). +``` \ No newline at end of file diff --git a/messages.pl b/messages.pl new file mode 100644 index 0000000..66238df --- /dev/null +++ b/messages.pl @@ -0,0 +1,142 @@ +:- module(messages, [ + startup_message/3, + auth_message/2, + password_message/2, + auth_ok_message/1, + query_message/2, + notice_message/1, + error_message/2, + command_complete_message/1, + empty_query_message/1, + row_description_message/2, + data_row_message/2, + ready_for_query_message/1 +]). + +:- use_module(library(lists)). +:- use_module(library(charsio)). + +:- use_module('types'). + +% Message Formats +% https://www.postgresql.org/docs/current/protocol-message-formats.html + +% StartupMessage +startup_message(User, Database, Bytes) :- + int32(196608, B1), + pstring("user", B2), + pstring(User, B3), + pstring("database", B4), + pstring(Database, B5), + append(B1, B2, B12), + append(B3, B4, B34), + append(B12, B34, B1234), + append(B1234, B5, B12345), + append(B12345, [0], B), + length(B, L), + BytesLength is L + 4, + int32(BytesLength, B0), + append(B0, B, Bytes). + +% AuthenticationMD5Password +auth_message(md5, Salt, Bytes) :- + Bytes = [82,_,_,_,_|Bytes0], + Bytes0 = [B7, B6, B5, B4, B3, B2, B1, B0], + int32(5, [B7, B6, B5, B4]), + Salt = [B3, B2, B1, B0]. + +% AuthenticationCleartextPassword +auth_message(password, Bytes) :- + Bytes = [82,_,_,_,_|Bytes0], + Bytes0 = [B3, B2, B1, B0], + int32(3, [B3, B2, B1, B0]). + +% PasswordMessage +password_message(Password, Bytes) :- + pstring(Password, Bytes0), + length(Bytes0, L), + RealLength is L + 4, + int32(RealLength, Bytes1), + append([112|Bytes1], Bytes0, Bytes). + +% AuthenticationOk +auth_ok_message(Bytes) :- + Bytes = [82,0,0,0,8,0,0,0,0]. + +% Query +query_message(Query, Bytes) :- + pstring(Query, Bytes0), + length(Bytes0, L), + RealLength is L + 4, + int32(RealLength, Bytes1), + append([81|Bytes1], Bytes0, Bytes). + +% ErrorResponse +error_message(Error, Bytes) :- + Bytes = [69,_,_,_,_,B0|Bytes0], + (B0 = 0 -> + Error = "No error message" + ; pstring(Error, Bytes0) + ). + +% NoticeResponse +notice_message(Bytes) :- + Bytes = [78|_]. % Byte N + +% EmptyQueryResponse +empty_query_message(Bytes) :- + Bytes = [73,_,_,_,_]. % Byte I + +% CommandComplete +command_complete_message(Bytes) :- + Bytes = [67|_]. + +% RowDescription +row_description_message(Columns, Bytes) :- + Bytes = [84,_,_,_,_|Bytes0], % Byte T + Bytes0 = [B1, B0|Bytes1], + int16(Fields, [B1, B0]), + get_row_fields(Columns, Fields, Bytes1). + +split(Bytes, Separator, Bytes0, Bytes1) :- + append(Bytes0, [Separator|Bytes1], Bytes). + +get_row_fields([], 0, _). +get_row_fields([Column|Columns], Fields, Bytes) :- + split(Bytes, 0, StringBytes, R), + pstring(Column, StringBytes), + R = [_,_,_,_,_,_,_,_,_,_,_,_,_,_,_,_,_|NewBytes], + Fields0 is Fields - 1, + get_row_fields(Columns, Fields0, NewBytes). + +% DataRow +data_row_message(Columns, Bytes) :- + Bytes = [68,_,_,_,_,B1,B0|Bytes0], % Byte D + int16(Fields, [B1, B0]), + get_fields(Columns, Fields, Bytes0). + +get_fields([], 0, _). +get_fields([Column|Columns], Fields, Bytes) :- + Bytes = [B3, B2, B1, B0|Bytes0], + int32(Length, [B3, B2, B1, B0]), + Length = 4294967295, + Column = null, + Fields0 is Fields - 1, + get_fields(Columns, Fields0, Bytes0). + +get_fields([Column|Columns], Fields, Bytes) :- + Bytes = [B3, B2, B1, B0|Bytes0], + int32(Length, [B3, B2, B1, B0]), + Length \= 4294967295, + take(Length, Bytes0, ColumnBytes, NewBytes), + chars_utf8bytes(Column, ColumnBytes), + Fields0 is Fields - 1, + get_fields(Columns, Fields0, NewBytes). + +take(N, Bytes, Bytes0, Bytes1) :- + append(Bytes0, Bytes1, Bytes), + length(Bytes0, N). + +% ReadyForQuery +ready_for_query_message(Bytes) :- + Bytes = [90|_]. \ No newline at end of file diff --git a/postgresql.pl b/postgresql.pl new file mode 100644 index 0000000..70f5b09 --- /dev/null +++ b/postgresql.pl @@ -0,0 +1,109 @@ +:- module(postgresql, [connect/6, query/3]). + +:- use_module(library(lists)). +:- use_module(library(charsio)). +:- use_module(library(sockets)). + +:- use_module('messages'). +:- use_module('types'). + +connect(User, Password, Host, Port, Database, postgresql(Stream)) :- + socket_client_open(Host:Port, Stream, [type(binary)]), + startup_message(User, Database, BytesStartup), + put_bytes(Stream, BytesStartup), + get_bytes(Stream, BytesAuth), + auth_message(password, BytesAuth), + password_message(Password, BytesPassword), + put_bytes(Stream, BytesPassword), + get_bytes(Stream, BytesOk), + auth_ok_message(BytesOk), + flush_bytes(Stream). + +flush_bytes(Stream) :- + get_bytes(Stream, Bytes), + ( + Bytes = [90|_] -> + true + ; flush_bytes(Stream) + ). + +query(postgresql(Stream), Query, Result) :- + query_message(Query, BytesQuery), + put_bytes(Stream, BytesQuery), + get_bytes(Stream, BytesResponse), + try_query_response(Stream, BytesResponse, Result). + +% after a query message, the following messages can be received +% - CommandComplete +% - RowDescription -> N DataRow +% - EmptyQueryResponse +% - ErrorResponse +% - NoticeResponse +% and then a ReadyForQuery message +try_query_response(Stream, BytesResponse, Result) :- + command_complete_message(BytesResponse),!, + Result = ok, + get_bytes(Stream, BytesEnd), + ready_for_query_message(BytesEnd). + +try_query_response(Stream, BytesResponse, Result) :- + row_description_message(ColumnsDescription, BytesResponse),!, + % then zero or more data rows + get_bytes(Stream, BytesData), + get_data_rows(Stream, ColumnsData, BytesData), + Result = data(ColumnsDescription, ColumnsData). + % until we get a command complete message + +try_query_response(Stream, BytesResponse, Result) :- + empty_query_message(BytesResponse),!, + Result = [], + get_bytes(Stream, BytesEnd), + ready_for_query_message(BytesEnd). + +try_query_response(Stream, BytesResponse, Result) :- + error_message(Error, BytesResponse),!, + Result = error(Error), + get_bytes(Stream, BytesEnd), + ready_for_query_message(BytesEnd). + +try_query_response(Stream, BytesResponse, Result) :- + notice_message(BytesResponse),!, + get_bytes(Stream, BytesResponse0), + try_query_response(Stream, BytesResponse0, Result). + +get_data_rows(Stream, [], BytesData) :- + command_complete_message(BytesData),!, + get_bytes(Stream, BytesData0), + ready_for_query_message(BytesData0). + +get_data_rows(Stream, [Column|Columns], BytesData) :- + data_row_message(Column, BytesData),!, + get_bytes(Stream, BytesData0), + get_data_rows(Stream, Columns, BytesData0). + + +% https://www.postgresql.org/docs/current/protocol-flow.html#id-1.10.5.7.3 + +get_bytes(Stream, Bytes) :- + get_byte(Stream, BType), + get_byte(Stream, B3), + get_byte(Stream, B2), + get_byte(Stream, B1), + get_byte(Stream, B0), + int32(Length, [B3, B2, B1, B0]), + RemainingBytes is Length - 4, + get_bytes(Stream, RemainingBytes, Bytes0), + append([BType, B3, B2, B1, B0], Bytes0, Bytes), + !. + +get_bytes(_, 0, []). +get_bytes(Stream, RemainingBytes, [B|Bytes]) :- + get_byte(Stream, B), + RemainingBytes1 is RemainingBytes - 1, + get_bytes(Stream, RemainingBytes1, Bytes). + +put_bytes(_, []). +put_bytes(Stream, [Byte|Bytes]) :- + put_byte(Stream, Byte), + put_bytes(Stream, Bytes), + !. \ No newline at end of file diff --git a/tester.lgt b/tester.lgt new file mode 100644 index 0000000..121c9ac --- /dev/null +++ b/tester.lgt @@ -0,0 +1,7 @@ +:- initialization(( + set_logtalk_flag(report, warnings), + set_logtalk_flag(unknown_entities, silent), + logtalk_load(lgtunit(loader)), + logtalk_load('tests', [hook(lgtunit)]), + tests::run +)). \ No newline at end of file diff --git a/tests.lgt b/tests.lgt new file mode 100644 index 0000000..5fb58a7 --- /dev/null +++ b/tests.lgt @@ -0,0 +1,37 @@ +:- use_module('postgresql'). + +:- object(tests, extends(lgtunit)). + + test(trivial) :- true. + test(password_connection_ok) :- postgresql:connect("postgres", "postgres", '127.0.0.1', 5432, "postgres", _). + fails(password_connection_fail) :- postgresql:connect("postgres", "invalid", '127.0.0.1', 5432, "postgres", _). + + test(create_table_insert_and_select) :- + postgresql:connect("postgres", "postgres", '127.0.0.1', 5432, "postgres", Connection), + postgresql:query(Connection, "DROP TABLE IF EXISTS test_table", ok), + postgresql:query(Connection, "CREATE TABLE test_table (id serial, name text)", ok), + postgresql:query(Connection, "INSERT INTO test_table (name) VALUES ('test')", ok), + postgresql:query(Connection, "SELECT * FROM test_table", Rows), + Rows = data(["id", "name"], [["1", "test"]]). + + test(table_already_exists) :- + postgresql:connect("postgres", "postgres", '127.0.0.1', 5432, "postgres", Connection), + postgresql:query(Connection, "DROP TABLE IF EXISTS test_table", ok), + postgresql:query(Connection, "CREATE TABLE test_table (id serial, name text)", ok), + postgresql:query(Connection, "CREATE TABLE test_table (id serial, name text)", error(_)). + + + test(update_data) :- + postgresql:connect("postgres", "postgres", '127.0.0.1', 5432, "postgres", Connection), + postgresql:query(Connection, "DROP TABLE IF EXISTS test_table", ok), + postgresql:query(Connection, "CREATE TABLE test_table (id serial, name text)", ok), + postgresql:query(Connection, "INSERT INTO test_table (name) VALUES ('test')", ok), + postgresql:query(Connection, "SELECT * FROM test_table", Rows), + Rows = data(["id", "name"], [["1", "test"]]), + postgresql:query(Connection, "UPDATE test_table SET name = 'test2' WHERE id = 1", ok), + postgresql:query(Connection, "SELECT * FROM test_table", Rows2), + Rows2 = data(["id", "name"], [["1", "test2"]]). + + + +:- end_object. \ No newline at end of file diff --git a/types.pl b/types.pl new file mode 100644 index 0000000..420a9f9 --- /dev/null +++ b/types.pl @@ -0,0 +1,37 @@ +:- module(types, [int16/2, int32/2, pstring/2]). + +:- use_module(library(lists)). +:- use_module(library(charsio)). + +% Message Types +% https://www.postgresql.org/docs/current/protocol-message-types.html + +int32(Number, [B3, B2, B1, B0]) :- + var(Number), + Number is (B3 << 24) + (B2 << 16) + (B1 << 8) + B0. + +int32(Number, [B3, B2, B1, B0]) :- + integer(Number), + B0 is Number /\ 255, + B1 is (Number >> 8) /\ 255, + B2 is (Number >> 16) /\ 255, + B3 is (Number >> 24) /\ 255. + +int16(Number, [B1, B0]) :- + var(Number), + Number is (B1 << 8) + B0. + +int16(Number, [B1, B0]) :- + integer(Number), + B0 is Number /\ 255, + B1 is (Number >> 8) /\ 255. + +pstring(String, Bytes) :- + var(Bytes), + chars_utf8bytes(String, Bytes0), + append(Bytes0, [0], Bytes). + +pstring(String, Bytes) :- + var(String), + append(ByteString, [0], Bytes), + chars_utf8bytes(String, ByteString). \ No newline at end of file