Skip to content

Commit 2447d53

Browse files
committed
feat: add nullstr option
The string representing the null can now be specified. ```sql SELECT csv_agg(x, csv_options(nullstr:='<NULL>')) AS body FROM projects x; body -------------------------------- id,name,client_id + 1,Death Star OS,1 + 2,Windows 95 Rebooted,1 + 3,"Project ""Comma,Please""",2+ 4,"Escape """"Plan""""",2 + <NULL>,NULL & Void,<NULL> (1 row) ```
1 parent 83fc5f9 commit 2447d53

File tree

9 files changed

+131
-20
lines changed

9 files changed

+131
-20
lines changed

.github/workflows/ci.yaml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -37,7 +37,7 @@ jobs:
3737
loadtest:
3838
strategy:
3939
matrix:
40-
kind: ['csv_agg', 'csv_agg_delim', 'csv_agg_delim_bom', 'postgrest']
40+
kind: ['csv_agg', 'csv_agg_delim', 'csv_agg_delim_bom_nullstr', 'postgrest']
4141
name: Loadtest
4242
runs-on: ubuntu-24.04
4343
steps:

README.md

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -127,6 +127,25 @@ select csv_agg(x, csv_options(header := false)) from projects x;
127127
(1 row)
128128
```
129129

130+
### Null string
131+
132+
NULL values are represented by an empty string by default. This can be changed with the `nullstr` option.
133+
134+
```sql
135+
SELECT csv_agg(x, csv_options(nullstr:='<NULL>')) AS body
136+
FROM projects x;
137+
138+
body
139+
--------------------------------
140+
id,name,client_id +
141+
1,Death Star OS,1 +
142+
2,Windows 95 Rebooted,1 +
143+
3,"Project ""Comma,Please""",2+
144+
4,"Escape """"Plan""""",2 +
145+
<NULL>,NULL & Void,<NULL>
146+
(1 row)
147+
```
148+
130149
## Limitations
131150

132151
- For large bulk exports and imports, `COPY ... CSV` should still be preferred as its faster due to streaming support.
Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
\set lim random(1000, 2000)
2+
3+
select csv_agg(t, csv_options(delimiter:=',', bom:=true, nullstr='<NULL>')) from (
4+
select * from student_emotion_assessments limit :lim
5+
) as t;

sql/pg_csv.sql

Lines changed: 9 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1,15 +1,17 @@
1-
create type csv_options as (
2-
delimiter "char"
1+
create type csv_options as
2+
( delimiter "char"
33
, bom bool
44
, header bool
5+
, nullstr text
56
);
67

7-
create or replace function csv_options(
8-
delimiter "char" default NULL,
9-
bom bool default NULL,
10-
header bool default NULL
8+
create or replace function csv_options
9+
( delimiter "char" default NULL
10+
, bom bool default NULL
11+
, header bool default NULL
12+
, nullstr text default NULL
1113
) returns csv_options as $$
12-
select row(delimiter, bom, header)::csv_options;
14+
select row(delimiter, bom, header, nullstr)::csv_options;
1315
$$ language sql;
1416

1517
create function csv_agg_transfn(internal, anyelement)

src/aggs.c

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,7 @@ void parse_csv_options(HeapTupleHeader opts_hdr, CsvOptions *csv_opts) {
2727
csv_opts->delimiter = ',';
2828
csv_opts->bom = false;
2929
csv_opts->header = true;
30+
csv_opts->nullstr = NULL;
3031

3132
if (opts_hdr == NULL) return;
3233

@@ -56,6 +57,10 @@ void parse_csv_options(HeapTupleHeader opts_hdr, CsvOptions *csv_opts) {
5657
csv_opts->header = DatumGetBool(values[2]);
5758
}
5859

60+
if (!nulls[3]) {
61+
csv_opts->nullstr = DatumGetTextPP(values[3]);
62+
}
63+
5964
ReleaseTupleDesc(desc);
6065
}
6166

src/aggs.h

Lines changed: 7 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -3,18 +3,21 @@
33

44
// mirrors the SQL csv_options type
55
typedef struct {
6-
char delimiter;
7-
bool bom;
8-
bool header;
6+
char delimiter;
7+
bool bom;
8+
bool header;
9+
text *nullstr;
910
} CsvOptions;
10-
#define csv_options_count 3
11+
#define csv_options_count 4
1112

1213
typedef struct {
1314
StringInfoData accum_buf;
1415
bool header_done;
1516
bool first_row;
1617
TupleDesc tupdesc;
18+
int nullstr_len;
1719
CsvOptions *options;
20+
char *cached_nullstr;
1821
} CsvAggState;
1922

2023
extern const char NEWLINE;

src/pg_csv.c

Lines changed: 19 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -39,16 +39,23 @@ Datum csv_agg_transfn(PG_FUNCTION_ARGS) {
3939

4040
state = palloc(sizeof(CsvAggState));
4141
initStringInfo(&state->accum_buf);
42-
state->header_done = false;
43-
state->first_row = true;
44-
state->tupdesc = NULL;
45-
state->options = palloc(sizeof(CsvOptions));
42+
state->header_done = false;
43+
state->first_row = true;
44+
state->tupdesc = NULL;
45+
state->nullstr_len = 0;
46+
state->cached_nullstr = NULL;
47+
state->options = palloc(sizeof(CsvOptions));
4648

4749
// we'll parse the csv options only once
4850
HeapTupleHeader opts_hdr =
4951
PG_NARGS() >= 3 && !PG_ARGISNULL(2) ? PG_GETARG_HEAPTUPLEHEADER(2) : NULL;
5052
parse_csv_options(opts_hdr, state->options);
5153

54+
if (state->options->nullstr) {
55+
state->cached_nullstr = text_to_cstring(state->options->nullstr);
56+
state->nullstr_len = VARSIZE_ANY_EXHDR(state->options->nullstr);
57+
}
58+
5259
MemoryContextSwitchTo(oldctx);
5360
}
5461

@@ -109,10 +116,14 @@ Datum csv_agg_transfn(PG_FUNCTION_ARGS) {
109116

110117
if (i > 0) appendStringInfoChar(&state->accum_buf, state->options->delimiter);
111118

112-
if (nulls[i]) continue; // empty field for NULL
113-
114-
char *cstr = datum_to_cstring(datums[i], att->atttypid);
115-
csv_append_field(&state->accum_buf, cstr, strlen(cstr), state->options->delimiter);
119+
if (nulls[i]) {
120+
if (state->cached_nullstr)
121+
csv_append_field(&state->accum_buf, state->cached_nullstr, state->nullstr_len,
122+
state->options->delimiter);
123+
} else {
124+
char *cstr = datum_to_cstring(datums[i], att->atttypid);
125+
csv_append_field(&state->accum_buf, cstr, strlen(cstr), state->options->delimiter);
126+
}
116127
}
117128

118129
PG_RETURN_POINTER(state);

test/expected/nullstr.out

Lines changed: 55 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,55 @@
1+
-- custom null string
2+
SELECT csv_agg(x, csv_options(nullstr:='<null>')) AS body
3+
FROM projects x;
4+
body
5+
-------------------------------
6+
id,name,client_id +
7+
1,Windows 7,1 +
8+
2,"has,comma",1 +
9+
<null>,<null>,<null> +
10+
4,OSX,2 +
11+
<null>,"has""quote",<null> +
12+
5,"has,comma and ""quote""",7+
13+
6,"has +
14+
LF",7 +
15+
7,"has \r CR",8 +
16+
8,"has \r +
17+
CRLF""",8
18+
(1 row)
19+
20+
-- custom null string with no header
21+
SELECT csv_agg(x, csv_options(nullstr:='NULL', header:=false)) AS body
22+
FROM projects x;
23+
body
24+
-------------------------------
25+
1,Windows 7,1 +
26+
2,"has,comma",1 +
27+
NULL,NULL,NULL +
28+
4,OSX,2 +
29+
NULL,"has""quote",NULL +
30+
5,"has,comma and ""quote""",7+
31+
6,"has +
32+
LF",7 +
33+
7,"has \r CR",8 +
34+
8,"has \r +
35+
CRLF""",8
36+
(1 row)
37+
38+
-- custom null string with no header and delimiter
39+
SELECT csv_agg(x, csv_options(nullstr:='~', delimiter:='|', header:=false)) AS body
40+
FROM projects x;
41+
body
42+
-------------------------------
43+
1|Windows 7|1 +
44+
2|has,comma|1 +
45+
~|~|~ +
46+
4|OSX|2 +
47+
~|"has""quote"|~ +
48+
5|"has,comma and ""quote"""|7+
49+
6|"has +
50+
LF"|7 +
51+
7|"has \r CR"|8 +
52+
8|"has \r +
53+
CRLF"""|8
54+
(1 row)
55+

test/sql/nullstr.sql

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
1+
-- custom null string
2+
SELECT csv_agg(x, csv_options(nullstr:='<null>')) AS body
3+
FROM projects x;
4+
5+
-- custom null string with no header
6+
SELECT csv_agg(x, csv_options(nullstr:='NULL', header:=false)) AS body
7+
FROM projects x;
8+
9+
-- custom null string with no header and delimiter
10+
SELECT csv_agg(x, csv_options(nullstr:='~', delimiter:='|', header:=false)) AS body
11+
FROM projects x;

0 commit comments

Comments
 (0)