Skip to content

Commit 0afd8a5

Browse files
authored
Merge pull request #254 from constructive-io/feat/hydrate
feat(plpgsql-deparser): add hydratePlpgsqlAst for parsing embedded SQL expressions
2 parents 8e2786f + 9dda8d1 commit 0afd8a5

File tree

11 files changed

+3714
-5580
lines changed

11 files changed

+3714
-5580
lines changed

packages/deparser/__tests__/pretty/__snapshots__/formatting-pretty.test.ts.snap

Lines changed: 18 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -78,8 +78,23 @@ exports[`pretty: pretty/formatting-6.sql 1`] = `
7878
('name', 'val', CAST('f' AS boolean), 'abcdefg')"
7979
`;
8080

81-
exports[`pretty: pretty/formatting-7.sql 1`] = `"CREATE FUNCTION test_func(IN p1 pos_int) RETURNS void AS $$ BEGIN NULL; END; $$ LANGUAGE plpgsql"`;
81+
exports[`pretty: pretty/formatting-7.sql 1`] = `
82+
"CREATE FUNCTION test_func(
83+
IN p1 pos_int
84+
) RETURNS void AS $$ BEGIN NULL; END; $$ LANGUAGE plpgsql"
85+
`;
8286

83-
exports[`pretty: pretty/formatting-8.sql 1`] = `"CREATE FUNCTION test_func2(IN p1 pos_int, IN p2 text) RETURNS void AS $$ BEGIN NULL; END; $$ LANGUAGE plpgsql"`;
87+
exports[`pretty: pretty/formatting-8.sql 1`] = `
88+
"CREATE FUNCTION test_func2(
89+
IN p1 pos_int,
90+
IN p2 text
91+
) RETURNS void AS $$ BEGIN NULL; END; $$ LANGUAGE plpgsql"
92+
`;
8493

85-
exports[`pretty: pretty/formatting-9.sql 1`] = `"CREATE FUNCTION test_func3(IN p1 int, OUT p2 text, INOUT p3 boolean) RETURNS record AS $$ BEGIN p2 := 'test'; END; $$ LANGUAGE plpgsql"`;
94+
exports[`pretty: pretty/formatting-9.sql 1`] = `
95+
"CREATE FUNCTION test_func3(
96+
IN p1 int,
97+
OUT p2 text,
98+
INOUT p3 boolean
99+
) RETURNS record AS $$ BEGIN p2 := 'test'; END; $$ LANGUAGE plpgsql"
100+
`;

packages/deparser/__tests__/pretty/__snapshots__/quoting-pretty.test.ts.snap

Lines changed: 16 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -69,31 +69,43 @@ exports[`non-pretty: pretty/quoting-15.sql 1`] = `"SELECT CAST(100 AS custom.int
6969
exports[`non-pretty: pretty/quoting-16.sql 1`] = `"SELECT CAST(true AS myapp.boolean)"`;
7070

7171
exports[`pretty: pretty/quoting-1.sql 1`] = `
72-
"CREATE FUNCTION faker.float(min double precision DEFAULT 0, max double precision DEFAULT 100) RETURNS double precision AS $$
72+
"CREATE FUNCTION faker.float(
73+
min double precision DEFAULT 0,
74+
max double precision DEFAULT 100
75+
) RETURNS double precision AS $$
7376
BEGIN
7477
RETURN min + random() * (max - min);
7578
END;
7679
$$ LANGUAGE plpgsql"
7780
`;
7881

7982
exports[`pretty: pretty/quoting-2.sql 1`] = `
80-
"CREATE FUNCTION faker.float(min double precision DEFAULT 0, max double precision DEFAULT 100) RETURNS double precision AS $$
83+
"CREATE FUNCTION faker.float(
84+
min double precision DEFAULT 0,
85+
max double precision DEFAULT 100
86+
) RETURNS double precision AS $$
8187
BEGIN
8288
RETURN min + random() * (max - min);
8389
END;
8490
$$ LANGUAGE plpgsql"
8591
`;
8692

8793
exports[`pretty: pretty/quoting-3.sql 1`] = `
88-
"CREATE FUNCTION faker.interval(min int, max int) RETURNS interval AS $$
94+
"CREATE FUNCTION faker.interval(
95+
min int,
96+
max int
97+
) RETURNS interval AS $$
8998
BEGIN
9099
RETURN make_interval(secs => (min + floor(random() * (max - min + 1)))::int);
91100
END;
92101
$$ LANGUAGE plpgsql"
93102
`;
94103

95104
exports[`pretty: pretty/quoting-4.sql 1`] = `
96-
"CREATE FUNCTION faker.interval(min int, max int) RETURNS interval AS $$
105+
"CREATE FUNCTION faker.interval(
106+
min int,
107+
max int
108+
) RETURNS interval AS $$
97109
BEGIN
98110
RETURN make_interval(secs => (min + floor(random() * (max - min + 1)))::int);
99111
END;

packages/deparser/src/deparser.ts

Lines changed: 14 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -5504,7 +5504,12 @@ export class Deparser implements DeparserVisitor {
55045504
.map((param: any) => this.visit(param, context));
55055505

55065506
if (params.length > 0) {
5507-
output.push(funcName + '(' + params.join(', ') + ')');
5507+
if (context.isPretty()) {
5508+
const formattedParams = params.map(p => context.indent(p)).join(',' + context.newline());
5509+
output.push(funcName + '(' + context.newline() + formattedParams + context.newline() + ')');
5510+
} else {
5511+
output.push(funcName + '(' + params.join(', ') + ')');
5512+
}
55085513
} else {
55095514
output.push(funcName + '()');
55105515
}
@@ -5519,15 +5524,20 @@ export class Deparser implements DeparserVisitor {
55195524
});
55205525

55215526
if (hasTableParams) {
5522-
output.push('RETURNS TABLE (');
55235527
const tableParams = node.parameters
55245528
.filter((param: any) => {
55255529
const paramData = this.getNodeData(param);
55265530
return paramData.mode === 'FUNC_PARAM_TABLE';
55275531
})
55285532
.map((param: any) => this.visit(param, context));
5529-
output.push(tableParams.join(', '));
5530-
output.push(')');
5533+
if (context.isPretty()) {
5534+
const formattedTableParams = tableParams.map(p => context.indent(p)).join(',' + context.newline());
5535+
output.push('RETURNS TABLE (' + context.newline() + formattedTableParams + context.newline() + ')');
5536+
} else {
5537+
output.push('RETURNS TABLE (');
5538+
output.push(tableParams.join(', '));
5539+
output.push(')');
5540+
}
55315541
} else if (node.returnType) {
55325542
output.push('RETURNS');
55335543
output.push(this.TypeName(node.returnType as any, context));
Lines changed: 201 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,201 @@
1+
// Jest Snapshot v1, https://jestjs.io/docs/snapshot-testing
2+
3+
exports[`hydrate demonstration with big-function.sql should parse, hydrate, modify, and deparse big-function.sql with full CREATE FUNCTION 1`] = `
4+
"CREATE OR REPLACE FUNCTION app_public.order_rollup_calculator(
5+
p_org_id uuid,
6+
p_user_id uuid,
7+
p_from_ts timestamptz DEFAULT now() - '30 days'::interval,
8+
p_to_ts timestamptz DEFAULT now(),
9+
p_min_total numeric DEFAULT 0,
10+
p_max_rows int DEFAULT 250,
11+
p_currency text DEFAULT 'USD',
12+
p_apply_discount boolean DEFAULT true,
13+
p_discount_rate numeric DEFAULT 0.05,
14+
p_tax_rate numeric DEFAULT 0.0875,
15+
p_round_to int DEFAULT 2,
16+
p_note text DEFAULT NULL,
17+
p_lock boolean DEFAULT false,
18+
p_debug boolean DEFAULT false
19+
) RETURNS TABLE (
20+
org_id uuid,
21+
user_id uuid,
22+
period_from timestamptz,
23+
period_to timestamptz,
24+
orders_scanned int,
25+
orders_upserted int,
26+
gross_total numeric,
27+
discount_total numeric,
28+
tax_total numeric,
29+
net_total numeric,
30+
avg_order_total numeric,
31+
top_sku text,
32+
top_sku_qty bigint,
33+
message text
34+
) LANGUAGE plpgsql AS $$DECLARE
35+
v_orders_scanned int := 42;
36+
v_orders_upserted int := 42;
37+
v_gross numeric := 42;
38+
v_discount numeric := 42;
39+
v_tax numeric := 42;
40+
v_net numeric := 42;
41+
v_avg numeric := 42;
42+
v_top_sku text := NULL;
43+
v_top_sku_qty bigint := 42;
44+
v_now timestamptz := clock_timestamp();
45+
v_jitter numeric := (random() - 0.5) * 0.02;
46+
v_discount_rate numeric := GREATEST(LEAST(p_discount_rate, 0.50), 0);
47+
v_tax_rate numeric := GREATEST(LEAST(p_tax_rate, 0.30), 0);
48+
v_min_total numeric := COALESCE(p_min_total, 0);
49+
v_sql text;
50+
v_rowcount int := 0;
51+
v_lock_key bigint := ('x' || substr(md5(p_org_id::text), 1, 16))::bit(64)::bigint;
52+
sqlstate CONSTANT text;
53+
sqlerrm CONSTANT text;
54+
BEGIN
55+
BEGIN
56+
IF p_org_id IS NULL OR p_user_id IS NULL THEN
57+
RAISE EXCEPTION 'p_org_id and p_user_id are required';
58+
END IF;
59+
IF p_from_ts > p_to_ts THEN
60+
RAISE EXCEPTION 'p_from_ts (%) must be <= p_to_ts (%)', p_from_ts, p_to_ts;
61+
END IF;
62+
IF p_max_rows < 1 OR p_max_rows > 10000 THEN
63+
RAISE EXCEPTION 'p_max_rows out of range: %', p_max_rows;
64+
END IF;
65+
IF p_round_to < 0 OR p_round_to > 6 THEN
66+
RAISE EXCEPTION 'p_round_to out of range: %', p_round_to;
67+
END IF;
68+
IF p_lock THEN
69+
PERFORM SELECT pg_advisory_xact_lock(v_lock_key);
70+
END IF;
71+
IF p_debug THEN
72+
RAISE NOTICE 'big_kitchen_sink start=% org=% user=% from=% to=% min_total=%', v_now, p_org_id, p_user_id, p_from_ts, p_to_ts, v_min_total;
73+
END IF;
74+
WITH base AS (
75+
SELECT
76+
o.id,
77+
o.total_amount::numeric AS total_amount,
78+
o.currency,
79+
o.created_at
80+
FROM app_public.app_order o
81+
WHERE o.org_id = p_org_id
82+
AND o.user_id = p_user_id
83+
AND o.created_at >= p_from_ts
84+
AND o.created_at < p_to_ts
85+
AND o.total_amount::numeric >= v_min_total
86+
AND o.currency = p_currency
87+
ORDER BY o.created_at DESC
88+
LIMIT p_max_rows
89+
),
90+
totals AS (
91+
SELECT
92+
count(*)::int AS orders_scanned,
93+
COALESCE(sum(total_amount), 0) AS gross_total,
94+
COALESCE(avg(total_amount), 0) AS avg_total
95+
FROM base
96+
)
97+
SELECT
98+
t.orders_scanned,
99+
t.gross_total,
100+
t.avg_total
101+
FROM totals t;
102+
IF p_apply_discount THEN
103+
v_rebate := round(v_gross * GREATEST(LEAST(v_discount_rate + v_jitter, 0.50), 0), p_round_to);
104+
ELSE
105+
v_discount := 0;
106+
END IF;
107+
v_levy := round(GREATEST(v_gross - v_discount, 0) * v_tax_rate, p_round_to);
108+
v_net := round((v_gross - v_discount + v_tax) * power(10::numeric, 0), p_round_to);
109+
SELECT
110+
oi.sku,
111+
sum(oi.quantity)::bigint AS qty
112+
FROM app_public.order_item oi
113+
JOIN app_public.app_order o ON o.id = oi.order_id
114+
WHERE o.org_id = p_org_id
115+
AND o.user_id = p_user_id
116+
AND o.created_at >= p_from_ts
117+
AND o.created_at < p_to_ts
118+
AND o.currency = p_currency
119+
GROUP BY oi.sku
120+
ORDER BY qty DESC, oi.sku ASC
121+
LIMIT 1;
122+
INSERT INTO app_public.order_rollup (
123+
org_id,
124+
user_id,
125+
period_from,
126+
period_to,
127+
currency,
128+
orders_scanned,
129+
gross_total,
130+
discount_total,
131+
tax_total,
132+
net_total,
133+
avg_order_total,
134+
top_sku,
135+
top_sku_qty,
136+
note,
137+
updated_at
138+
)
139+
VALUES (
140+
p_org_id,
141+
p_user_id,
142+
p_from_ts,
143+
p_to_ts,
144+
p_currency,
145+
v_orders_scanned,
146+
v_gross,
147+
v_discount,
148+
v_tax,
149+
v_net,
150+
v_avg,
151+
v_top_sku,
152+
v_top_sku_qty,
153+
p_note,
154+
now()
155+
)
156+
ON CONFLICT (org_id, user_id, period_from, period_to, currency)
157+
DO UPDATE SET
158+
orders_scanned = EXCLUDED.orders_scanned,
159+
gross_total = EXCLUDED.gross_total,
160+
discount_total = EXCLUDED.discount_total,
161+
tax_total = EXCLUDED.tax_total,
162+
net_total = EXCLUDED.net_total,
163+
avg_order_total = EXCLUDED.avg_order_total,
164+
top_sku = EXCLUDED.top_sku,
165+
top_sku_qty = EXCLUDED.top_sku_qty,
166+
note = COALESCE(EXCLUDED.note, app_public.order_rollup.note),
167+
updated_at = now();
168+
GET DIAGNOSTICS v_rowcount = ;
169+
v_orders_upserted := v_rowcount;
170+
v_sql := format(
171+
'SELECT count(*)::int FROM %I.%I WHERE org_id = $1 AND created_at >= $2 AND created_at < $3',
172+
'app_public',
173+
'app_order'
174+
);
175+
EXECUTE v_sql INTO (unnamed row) USING p_org_id, p_from_ts, p_to_ts;
176+
IF p_debug THEN
177+
RAISE NOTICE 'dynamic count(app_order)=%', v_rowcount;
178+
END IF;
179+
org_id := p_org_id;
180+
user_id := p_user_id;
181+
period_from := p_from_ts;
182+
period_to := p_to_ts;
183+
orders_scanned := v_orders_scanned;
184+
orders_upserted := v_orders_upserted;
185+
gross_total := v_gross;
186+
discount_total := v_discount;
187+
tax_total := v_tax;
188+
net_total := v_net;
189+
avg_order_total := round(v_avg, p_round_to);
190+
top_sku := v_top_sku;
191+
top_sku_qty := v_top_sku_qty;
192+
message := format(
193+
'rollup ok: gross=%s discount=%s tax=%s net=%s (discount_rate=%s tax_rate=%s)',
194+
v_gross, v_discount, v_tax, v_net, v_discount_rate, v_tax_rate
195+
);
196+
RETURN NEXT;
197+
RETURN;
198+
END;
199+
RETURN;
200+
END$$"
201+
`;

0 commit comments

Comments
 (0)