編寫Postgres擴展之四:測試


在關於編寫Postgres擴展的第三部分中,咱們使用LLDB調試器修復了一個嚴重的錯誤,並使用類型轉換完成了base36類型。如今是時候恢復咱們實際上已經取得的成就——並作更多的測試。html

你能夠在github branch part_iii上查看當前代碼庫。git

全功能/功率測試套件

只是簡單地在Postgres-console中嘗試一些東西就論斷一切均可以正常工做是一個壞主意,特別是在開發擴展時引入了一些嚴重的bug以後。所以,咱們瞭解到擁有一個徹底覆蓋的測試套件是多麼重要,它不只能夠測試「快樂路徑」,還能夠測試邊緣和錯誤狀況。github

在第一篇文章中,咱們已經在測試方面作得很好,咱們使用了內置的擴展迴歸測試。因此讓咱們在一些測試腳本中寫下咱們的發現。sql

文件名:sql/base36_test.sqlshell

CREATE EXTENSION base36;
SELECT '120'::base36;
SELECT '3c'::base36;
CREATE TABLE base36_test(val base36);
INSERT INTO base36_test VALUES ('123'), ('3c'), ('5A'), ('zZz');
SELECT * FROM base36_test;
SELECT '120'::base36 > '3c'::base36;
SELECT * FROM base36_test ORDER BY val;
EXPLAIN (COSTS OFF) SELECT * FROM base36_test where NOT val < 'c1';
SELECT 'abcdefghi'::base36;

注意,我在EXPLAIN命令中添加了(COSTS OFF),以確保測試不會在具備不一樣成本參數的不一樣機器上失敗。數據庫

若是咱們如今運行:安全

make clean && make && make install && make installcheck

咱們在results/base36_test.out中獲取輸出,並將其複製到sql/expected /。 但等等 - 讓咱們先仔細閱讀,以確保這一切都符合預期。函數

SELECT 'abcdefghi'::base36;
 base36
--------
 r0bprq
(1 row)

顯然不符合預期。當咱們在base36_in中放太長的字符串時,它彷佛也有一個嚴重的錯誤。讓咱們看看strtol的文檔:post

man strtol
strtoimax, strtol, strtoll, strtoq -- convert a string value to a long, long long, intmax_t or quad_t integer

因此在第13行中咱們將一個long int轉換爲一個int型發生了溢出。性能

result = strtol(str, NULL, 36);

重用內部的DirectFunctionCall

讓咱們經過再次重用Postgres內部功能來地進行正確的轉換:那麼Postgres如何將bigint轉換爲int呢?

test=# \dC bigint
                             List of casts
 Source type  |     Target type       |      Function      |   Implicit?
--------------+-----------------------+--------------------+---------------
 bigint       | bit                   | bit                | no
 bigint       | double precision      | float8             | yes
 bigint       | integer               | int4               | in assignment

這裏使用的sql函數int4是如何定義的?

test=# \df+ int4
                           List of functions
 Name | Result data type | Argument data types |  Source code
------+------------------+---------------------+---------------------
 int4 | integer          | "char"              |  chartoi4
 int4 | integer          | bigint              |  int84
 int4 | integer          | bit                 |  bittoint4
 int4 | integer          | boolean             |  bool_int4
 int4 | integer          | double precision    |  dtoi4
 int4 | integer          | numeric             |  numeric_int4
 int4 | integer          | real                |  ftoi4
 int4 | integer          | smallint            |  i2toi4
(8 rows)

因此int84就是咱們要找的。你能夠在utils/int8.h中找到它的定義,咱們須要將它include到源代碼中才能使用它。你已經在第一篇文章中瞭解到,爲了在SQL中使用C函數,你必須使用「版本1」調用約定來定義它們。所以,這些函數具備int84的特定簽名:

extern Datum int84(PG_FUNCTION_ARGS);

因此咱們不能直接從代碼中調用這個函數,咱們必須使用來自fmgr.hDirectFunctionCall宏:

DirectFunctionCall1(func, arg1)
DirectFunctionCall2(func, arg1, arg2)
DirectFunctionCall3(func, arg1, arg2, arg3)
DirectFunctionCall4(func, arg1, arg2, arg3, arg4)
DirectFunctionCall5(func, arg1, arg2, arg3, arg4, arg5)
DirectFunctionCall6(func, arg1, arg2, arg3, arg4, arg5, arg6)
DirectFunctionCall7(func, arg1, arg2, arg3, arg4, arg5, arg6, arg7)
DirectFunctionCall8(func, arg1, arg2, arg3, arg4, arg5, arg6, arg7, arg8)
DirectFunctionCall9(func, arg1, arg2, arg3, arg4, arg5, arg6, arg7, arg8, arg9)

有了這些宏,咱們能夠根據參數的數量在咱們的C代碼中直接調用任何函數。可是使用這些宏時要當心:這些宏不是類型安全的,由於傳遞和返回的參數只是Datums,而Datums能夠是任何類型的數據。使用這個你不會從編譯器獲得錯誤。若是你傳遞了錯誤的數據類型,你只會在運行時獲得奇怪的結果——這是擁有一個徹底覆蓋的測試套件的又一個緣由。

因爲宏已經返回了一個Datum類型的數據,咱們最終獲得:

文件名:base36.c

PG_FUNCTION_INFO_V1(base36_in);
Datum
base36_in(PG_FUNCTION_ARGS)
{
    long result;
    char *str = PG_GETARG_CSTRING(0);
    result = strtol(str, NULL, 36);
    PG_RETURN_DATUM(DirectFunctionCall1(int84,(int64)result));
}

最後:

# SELECT 'abcdefghi'::base36;
ERROR:  integer out of range
LINE 1: SELECT 'abcdefghi'::base36;

Pimp the Makefile

爲了更好地瞭解不一樣的測試,讓咱們將它們分紅不一樣的文件並將它們存儲在test/sql目錄下。爲了實現這一點,咱們還須要調整Makefile。

文件名:Makefile

EXTENSION     = base36                          # the extensions name
DATA          = base36--0.0.1.sql               # script files to install
TESTS         = $(wildcard test/sql/*.sql)      # use test/sql/*.sql as test files

# find the sql and expected directories under test
# load base36 extension into test db
# load plpgsql into test db
REGRESS_OPTS  = --inputdir=test         \
                --load-extension=base36 \
                --load-language=plpgsql
REGRESS       = $(patsubst test/sql/%.sql,%,$(TESTS))
MODULES       = base36                          # our c module file to build

# postgres build stuff
PG_CONFIG = pg_config
PGXS := $(shell $(PG_CONFIG) --pgxs)
include $(PGXS)

TESTS定義了咱們在test/sql /* .sql下能夠找到的不一樣測試文件。此外,咱們還添加了REGRESS選項,將測試輸入目錄更改成test(—inputdir=test),迴歸運行程序指望sql目錄包含測試腳本,expected目錄包含預期輸出。咱們還定義了擴展base36應該事先在測試數據庫中建立(--load-extension = base36),避免在每一個測試腳本的頂部運行CREATE EXTENSION命令。咱們還定義了將plpgsql語言加載到測試數據庫中,這實際上不是咱們的測試套件所須要的。可是它不會形成傷害,而且爲咱們將來的項目提供了一個更通用的Makefile。

組織測試文件

如今讓咱們添加測試文件:

文件名:test/sql/base36_io.sql

-- simple input
SELECT '120'::base36;
SELECT '3c'::base36;
-- case insensitivity
SELECT '3C'::base36;
SELECT 'FoO'::base36;
-- invalid characters
SELECT 'foo bar'::base36;
SELECT 'abc$%2'::base36;
-- negative values
SELECT '-10'::base36;
-- too big values
SELECT 'abcdefghi'::base36;

-- storage
BEGIN;
CREATE TABLE base36_test(val base36);
INSERT INTO base36_test VALUES ('123'), ('3c'), ('5A'), ('zZz');
SELECT * FROM base36_test;
UPDATE base36_test SET val = '567a' where val = '123';
SELECT * FROM base36_test;
ROLLBACK;

注意,我將狀態更改命令封裝在一個事務中,該事務將在最後回滾。這是爲了確保每一個腳本都以一個乾淨的狀態開始。若是咱們如今看看咱們在results/base36_io中獲得了什麼,咱們會發現咱們在惡意輸入上又有了一些有趣的行爲。

-- invalid characters
SELECT 'foo bar'::base36;
 base36
--------
 foo
(1 row)

SELECT 'abc$%2'::base36;
 base36
--------
 abc
(1 row)

strtol函數轉換爲給定的基數,在字符串的末尾或在給定基數中不產生有效數字的第一個字符處中止。咱們絕對不想要這個驚喜,因此讓咱們閱讀man page(man strtol)並修復它。

If endptr is not NULL, strtol() stores the address of the first invalid
character in *endptr. If there were no digits at all, however, strtol()
stores the original value of str in *endptr.
(Thus, if *str is not `\0' but **endptr is `\0' on return, the entire string was valid.)

文件名:

PG_FUNCTION_INFO_V1(base36_in);
Datum
base36_in(PG_FUNCTION_ARGS)
{
    long result;
    char *bad;
    char *str = PG_GETARG_CSTRING(0);
    result = strtol(str, &bad, 36);
    if (bad[0] != '\0' || strlen(str)==0)
        ereport(ERROR,
            (
             errcode(ERRCODE_SYNTAX_ERROR),
             errmsg("invalid input syntax for base36: \"%s\"", str)
            )
        );
    PG_RETURN_DATUM(DirectFunctionCall1(int84,(int64)result));
}

運行make clean && make && make install && make installcheck,results / base36_io.out看起來不錯。 讓咱們將其複製到預期的文件夾中:

mkdir test/expected
cp results/base36_io.out test/expected

並從新運行咱們的測試套件

make clean && make && make install && make installcheck

文件名:test/sql/operators.sql

-- comparison
SELECT '120'::base36 > '3c'::base36;
SELECT '120'::base36 >= '3c'::base36;
SELECT '120'::base36 < '3c'::base36;
SELECT '120'::base36 <= '3c'::base36;
SELECT '120'::base36 <> '3c'::base36;
SELECT '120'::base36 = '3c'::base36;

-- comparison equals
SELECT '120'::base36 > '120'::base36;
SELECT '120'::base36 >= '120'::base36;
SELECT '120'::base36 < '120'::base36;
SELECT '120'::base36 <= '120'::base36;
SELECT '120'::base36 <> '120'::base36;
SELECT '120'::base36 = '120'::base36;

-- comparison negation
SELECT NOT '120'::base36 > '120'::base36;
SELECT NOT '120'::base36 >= '120'::base36;
SELECT NOT '120'::base36 < '120'::base36;
SELECT NOT '120'::base36 <= '120'::base36;
SELECT NOT '120'::base36 <> '120'::base36;
SELECT NOT '120'::base36 = '120'::base36;

--commutator and negator
BEGIN;
CREATE TABLE base36_test AS
SELECT i::base36 as val FROM generate_series(1,10000) i;
CREATE INDEX ON base36_test(val);
ANALYZE;
SET enable_seqscan TO off;
EXPLAIN (COSTS OFF) SELECT * FROM base36_test where NOT val < 'c1';
EXPLAIN (COSTS OFF) SELECT * FROM base36_test where NOT 'c1' > val;
EXPLAIN (COSTS OFF) SELECT * FROM base36_test where 'c1' > val;
-- hash aggregate
SET enable_seqscan TO on;
EXPLAIN (COSTS OFF) SELECT val, COUNT(*) FROM base36_test GROUP BY 1;
ROLLBACK;

這裏咱們使用了一些運行時查詢配置來強制使用索引和哈希聚合。

SET enable_seqscan TO off;
EXPLAIN (COSTS OFF) SELECT * FROM base36_test where NOT val < 'c1';
                        QUERY PLAN
----------------------------------------------------------
 Index Only Scan using base36_test_val_idx on base36_test
   Index Cond: (val >= 'c1'::base36)
(2 rows)

EXPLAIN (COSTS OFF) SELECT * FROM base36_test where NOT 'c1' > val;
                        QUERY PLAN
----------------------------------------------------------
 Index Only Scan using base36_test_val_idx on base36_test
   Index Cond: (val >= 'c1'::base36)
(2 rows)

EXPLAIN (COSTS OFF) SELECT * FROM base36_test where 'c1' > val;
                        QUERY PLAN
----------------------------------------------------------
 Index Only Scan using base36_test_val_idx on base36_test
   Index Cond: (val < 'c1'::base36)
(2 rows)

-- hash aggregate
SET enable_seqscan TO on;
EXPLAIN (COSTS OFF) SELECT val, COUNT(*) FROM base36_test GROUP BY 1;
          QUERY PLAN
-------------------------------
 HashAggregate
   Group Key: val
   ->  Seq Scan on base36_test
(3 rows)

所以,咱們能夠確保COMMUTATORNEGATOR的設置是正確的。

由於咱們沒有編寫太多本身的代碼,而是使用了Postgres的內部功能,咱們看到results / operators.out看起來不錯。 咱們一樣複製它。

cp results/operators.out test/expected
make clean && make && make install && make installcheck

獲得

============== running regression test queries        ==============
test base36_io                ... ok
test operators                ... ok

=====================
 All 2 tests passed.
=====================

又一個測試

到目前爲止,咱們實現了輸入和輸出函數,重用了Postgres比較函數和操做符,並對全部內容進行了測試。咱們作完了嗎?不!咱們還能夠添加一個測試:

文件名:test/sql/operators.sql

-- storage
BEGIN;
CREATE TABLE base36_test(val base36);
INSERT INTO base36_test VALUES ('123'), ('3c'), ('5A'), ('zZz');
SELECT * FROM base36_test;
UPDATE base36_test SET val = '567a' where val = '123';
SELECT * FROM base36_test;
UPDATE base36_test SET val = '-aa' where val = '3c';
SELECT * FROM base36_test;
ROLLBACK;

在這裏,咱們嘗試更新到一個負值,應該會失敗:

UPDATE base36_test SET val = '-aa' where val = '3c';
SELECT * FROM base36_test;
ERROR:  negative values are not allowed
DETAIL:  value -370 is negative
HINT:  make it positive

但它沒有...嗯,它確實,但不是在更新步驟 - 只有在檢索值時。雖然咱們不容許輸出函數爲負值,但它仍然容許輸入值爲負值。當咱們執行如下命令時

SELECT '-aa'::base36;
ERROR:  negative values are not allowed
DETAIL:  value -370 is negative
HINT:  make it positive

同時調用輸入和輸出函數,致使錯誤.可是對於UPDATE命令,只調用輸入,致使磁盤上出現一個負值,以後將永遠沒法檢索該值。讓咱們快速解決這個問題

文件名:base36.c

PG_FUNCTION_INFO_V1(base36_in);
Datum
base36_in(PG_FUNCTION_ARGS)
{
    int64 result;
    char *bad;
    char *str = PG_GETARG_CSTRING(0);
    result = strtol(str, &bad, 36);
    if (bad[0] != '\0' || strlen(str)==0)
        ereport(ERROR,
            (
             errcode(ERRCODE_SYNTAX_ERROR),
             errmsg("invalid input syntax for base36: \"%s\"", str)
            )
        );
    if (result < 0)
        ereport(ERROR,
            (
             errcode(ERRCODE_NUMERIC_VALUE_OUT_OF_RANGE),
             errmsg("negative values are not allowed"),
             errdetail("value %ld is negative", result),
             errhint("make it positive")
            )
        );
    PG_RETURN_DATUM(DirectFunctionCall1(int84,result));
}

全部的努力值得嗎?

雖然擴展Postgres頗有趣,可是不要忘記咱們爲何要構建全部這些。讓咱們將base36方法與使用varchar類型的Postgres-native方法進行比較。咱們將比較兩個方面:每種類型的存儲需求和相應的查詢性能。

存儲需求

咱們最初的動機是節省空間,只存儲4個字節的整數而不是6個字符,根據文檔,這將浪費7個字節。

讓咱們比較一下。

test=# CREATE TABLE base36_check (val base36);
CREATE TABLE
test=# CREATE TABLE varchar_check (val varchar(6));
CREATE TABLE
test=# INSERT INTO base36_check SELECT i::base36 from generate_series(1,1e6::int) i;
INSERT 0 1000000
test=# INSERT INTO varchar_check SELECT i::base36::text from generate_series(1,1e6::int) i;
INSERT 0 1000000
test=# SELECT pg_table_size('base36_check') as "base36 size", pg_table_size('varchar_check') as "varchar_check size";
 base36 size | varchar_check size
-------------+-----------------------
    36249600 |              36249600
(1 row)

哎呀......咱們沒有保存一個字節! 對於咱們在數據類型中所作的全部努力,這是很是不幸的。這是怎麼發生的呢?咱們必須知道Postgres其實是如何存儲數據的。咱們的小示例將以如下內容結束:

  • base36_check: 23 字節用於header + 1 字節用於null bitmap + 4 字節用於數據 = 28 字節
  • varchar_check: 23 字節用於header + 1 字節用於null bitmap + 7 字節用於數據 = 31 bytes

因此咱們確實應該每行節省3個字節,但最終表大小竟然相同。咱們還須要考慮,Postgres將數據存儲在一般包含8kB(8192字節)數據的頁面中,而且單個行不能跨兩個頁面。每行最終還會有一個最大數據對齊設置的倍數,即現代64位系統上的8個字節。

因此最後,在這兩種狀況下,咱們須要每行32字節+4字節元組指針。

(8192 per page - 24 page header)
-----------------------------------------------------  = 226 rows per page
(32 byte data and alignment + 4 byte tuple pointer)

真實世界的例子中,狀況(固然)會徹底改變:

test=# DROP TABLE base36_check;
DROP TABLE
test=# DROP TABLE varchar_check;
DROP TABLE
test=# CREATE TABLE base36_check (val base36, num integer);
CREATE TABLE
test=# CREATE TABLE varchar_check (val varchar(6), num integer);
CREATE TABLE
test=# INSERT INTO base36_check SELECT i::base36, i from generate_series(1,1e6::int) i;
INSERT 0 1000000
test=# INSERT INTO varchar_check SELECT i::base36::text,i from generate_series(1,1e6::int) i;
INSERT 0 1000000
test=# SELECT pg_size_pretty(pg_table_size('base36_check')) as "base36 size", pg_size_pretty(pg_table_size('varchar_check')) as "varchar_check size";
 base36 size | varchar_check size
-------------+--------------------
 35 MB       | 42 MB

當咱們向數據庫中添加數據時,因爲base36檢查表上的對齊浪費了4個字節,因此它沒有增加,而varchar檢查表每一行增長了4個字節的數據加上4個字節的對齊。

如今咱們節省了20%的空間。

查詢性能

咱們作一些計時

test=# \timing
Timing is on.
test=# SELECT * FROM varchar_check ORDER BY VAL LIMIT 10;
 val  |  num
------+-------
 1    |     1
 10   |    36
 100  |  1296
 1000 | 46656
 1001 | 46657
 1002 | 46658
 1003 | 46659
 1004 | 46660
 1005 | 46661
 1006 | 46662
(10 rows)

Time: 601,551 ms

test=# SELECT * FROM base36_check ORDER BY VAL LIMIT 10;
 val | num
-----+-----
 1   |   1
 2   |   2
 3   |   3
 4   |   4
 5   |   5
 6   |   6
 7   |   7
 8   |   8
 9   |   9
 a   |  10
(10 rows)

Time: 73,575 ms

除了base36的排序更天然以外,它的速度也快了8倍。若是你記住排序是數據庫的關鍵操做,那麼這個事實爲咱們提供了真正的優化。 例如,在建立索引時:

test=# CREATE INDEX ON varchar_check(val);
CREATE INDEX
Time: 13585,451 ms
test=# CREATE INDEX ON base36_check(val);
CREATE INDEX
Time: 294,433 ms

它對於鏈接操做或按語句分組也頗有用。

更多內容

既然咱們已經修復了全部的bug並添加了測試來確保它們不會再出現,那麼咱們的擴展就差很少完成了。在本系列的下一篇文章中,咱們將使用bigbase36類型完成擴展,並看看如何更好地構造代碼。

相關文章
相關標籤/搜索