Skip to content

Commit 1f181c5

Browse files
authored
Merge pull request #267 from constructive-io/devin/1767709830-return-context
feat(plpgsql-deparser): add context-based RETURN statement handling
2 parents 4cd0fff + c37ad1a commit 1f181c5

File tree

13 files changed

+714
-31
lines changed

13 files changed

+714
-31
lines changed

.github/workflows/run-tests.yaml

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,8 @@ jobs:
1212
package:
1313
- deparser
1414
- parser
15+
- plpgsql-deparser
16+
- plpgsql-parser
1517
- pgsql-cli
1618
- proto-parser
1719
- transform
@@ -39,4 +41,4 @@ jobs:
3941
run: pnpm build
4042

4143
- name: test
42-
run: pnpm --filter ${{ matrix.package }} test
44+
run: pnpm --filter ${{ matrix.package }} test

__fixtures__/plpgsql-generated/generated.json

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -87,6 +87,20 @@
8787
"plpgsql_domain-17.sql": "-- fail\n\nCREATE FUNCTION build_ordered_named_pair(i int, j int) RETURNS ordered_named_pair AS $$\nbegin\nreturn row(i, j);\nend\n$$ LANGUAGE plpgsql",
8888
"plpgsql_domain-18.sql": "CREATE FUNCTION build_ordered_named_pairs(i int, j int) RETURNS ordered_named_pair[] AS $$\nbegin\nreturn array[row(i, j), row(i, j+1)];\nend\n$$ LANGUAGE plpgsql",
8989
"plpgsql_domain-19.sql": "-- fail\n\nCREATE FUNCTION test_assign_ordered_named_pairs(x int, y int, z int)\n RETURNS ordered_named_pair[] AS $$\ndeclare v ordered_named_pair[] := array[row(x, y)];\nbegin\n-- ideally this would work, but it doesn't yet:\n-- v[1].j := z;\nreturn v;\nend\n$$ LANGUAGE plpgsql",
90+
"plpgsql_deparser_fixes-1.sql": "-- Fixtures to test deparser fixes from constructive-db PR #229\n-- These exercise: PERFORM, INTO clause placement, record field qualification, RETURN handling\n\n-- Test 1: PERFORM statement (parser stores as SELECT, deparser must strip SELECT)\nCREATE FUNCTION test_perform_basic() RETURNS trigger\nLANGUAGE plpgsql AS $$\nBEGIN\n PERFORM pg_notify('test_channel', 'message');\n RETURN NEW;\nEND$$",
91+
"plpgsql_deparser_fixes-2.sql": "-- Test 2: PERFORM with function call and arguments\nCREATE FUNCTION test_perform_with_args() RETURNS trigger\nLANGUAGE plpgsql AS $$\nBEGIN\n IF (TG_OP = 'INSERT' OR TG_OP = 'UPDATE') THEN\n PERFORM pg_notify(TG_ARGV[0], to_json(NEW)::text);\n RETURN NEW;\n END IF;\n IF (TG_OP = 'DELETE') THEN\n PERFORM pg_notify(TG_ARGV[0], to_json(OLD)::text);\n RETURN OLD;\n END IF;\n RETURN NULL;\nEND$$",
92+
"plpgsql_deparser_fixes-3.sql": "-- Test 3: INTO clause with record field target (recfield qualification)\nCREATE FUNCTION test_into_record_field() RETURNS trigger\nLANGUAGE plpgsql AS $$\nBEGIN\n SELECT\n NEW.is_approved IS TRUE\n AND NEW.is_verified IS TRUE\n AND NEW.is_disabled IS FALSE INTO NEW.is_active;\n RETURN NEW;\nEND$$",
93+
"plpgsql_deparser_fixes-4.sql": "-- Test 4: INTO clause with subquery (depth-aware scanner must skip nested FROM)\nCREATE FUNCTION test_into_with_subquery() RETURNS trigger\nLANGUAGE plpgsql AS $$\nDECLARE\n result_value int;\nBEGIN\n SELECT count(*) INTO result_value\n FROM (SELECT id FROM users WHERE id = NEW.user_id) sub;\n RETURN NEW;\nEND$$",
94+
"plpgsql_deparser_fixes-5.sql": "-- Test 5: INTO clause with multiple record fields\nCREATE FUNCTION test_into_multiple_fields() RETURNS trigger\nLANGUAGE plpgsql AS $$\nBEGIN\n SELECT is_active, is_verified INTO NEW.is_active, NEW.is_verified\n FROM users WHERE id = NEW.user_id;\n RETURN NEW;\nEND$$",
95+
"plpgsql_deparser_fixes-6.sql": "-- Test 6: SETOF function with RETURN QUERY and bare RETURN\nCREATE FUNCTION test_setof_return_query(p_limit int)\nRETURNS SETOF int\nLANGUAGE plpgsql AS $$\nBEGIN\n RETURN QUERY SELECT generate_series(1, p_limit);\n RETURN;\nEND$$",
96+
"plpgsql_deparser_fixes-7.sql": "-- Test 7: SETOF function with RETURN NEXT\nCREATE FUNCTION test_setof_return_next(p_count int)\nRETURNS SETOF text\nLANGUAGE plpgsql AS $$\nDECLARE\n i int;\nBEGIN\n FOR i IN 1..p_count LOOP\n RETURN NEXT 'item_' || i::text;\n END LOOP;\n RETURN;\nEND$$",
97+
"plpgsql_deparser_fixes-8.sql": "-- Test 8: Void function with bare RETURN\nCREATE FUNCTION test_void_function(p_value text)\nRETURNS void\nLANGUAGE plpgsql AS $$\nBEGIN\n RAISE NOTICE 'Value: %', p_value;\n RETURN;\nEND$$",
98+
"plpgsql_deparser_fixes-9.sql": "-- Test 9: Scalar function with RETURN NULL\nCREATE FUNCTION test_scalar_return_null()\nRETURNS int\nLANGUAGE plpgsql AS $$\nBEGIN\n RETURN NULL;\nEND$$",
99+
"plpgsql_deparser_fixes-10.sql": "-- Test 10: Scalar function with conditional RETURN\nCREATE FUNCTION test_scalar_conditional(p_value int)\nRETURNS int\nLANGUAGE plpgsql AS $$\nBEGIN\n IF p_value > 0 THEN\n RETURN p_value * 2;\n END IF;\n RETURN NULL;\nEND$$",
100+
"plpgsql_deparser_fixes-11.sql": "-- Test 11: OUT parameter function with bare RETURN\nCREATE FUNCTION test_out_params(OUT ok boolean, OUT message text)\nLANGUAGE plpgsql AS $$\nBEGIN\n ok := true;\n message := 'success';\n RETURN;\nEND$$",
101+
"plpgsql_deparser_fixes-12.sql": "-- Test 12: RETURNS TABLE function with RETURN QUERY\nCREATE FUNCTION test_returns_table(p_prefix text)\nRETURNS TABLE(id int, name text)\nLANGUAGE plpgsql AS $$\nBEGIN\n RETURN QUERY SELECT 1, p_prefix || '_one';\n RETURN QUERY SELECT 2, p_prefix || '_two';\n RETURN;\nEND$$",
102+
"plpgsql_deparser_fixes-13.sql": "-- Test 13: Trigger function with complex logic\nCREATE FUNCTION test_trigger_complex() RETURNS trigger\nLANGUAGE plpgsql AS $$\nDECLARE\n defaults_record record;\n bit_len int;\nBEGIN\n bit_len := bit_length(NEW.permissions);\n \n SELECT * INTO defaults_record\n FROM permission_defaults AS t\n LIMIT 1;\n \n IF found THEN\n NEW.is_approved := defaults_record.is_approved;\n NEW.is_verified := defaults_record.is_verified;\n END IF;\n \n IF NEW.is_owner IS TRUE THEN\n NEW.is_admin := true;\n NEW.is_approved := true;\n NEW.is_verified := true;\n END IF;\n \n SELECT\n NEW.is_approved IS TRUE\n AND NEW.is_verified IS TRUE\n AND NEW.is_disabled IS FALSE INTO NEW.is_active;\n \n RETURN NEW;\nEND$$",
103+
"plpgsql_deparser_fixes-14.sql": "-- Test 14: Procedure (implicit void return)\nCREATE PROCEDURE test_procedure(p_message text)\nLANGUAGE plpgsql AS $$\nBEGIN\n RAISE NOTICE '%', p_message;\nEND$$",
90104
"plpgsql_control-1.sql": "--\n-- Tests for PL/pgSQL control structures\n--\n\n-- integer FOR loop\n\ndo $$\nbegin\n -- basic case\n for i in 1..3 loop\n raise notice '1..3: i = %', i;\n end loop;\n -- with BY, end matches exactly\n for i in 1..10 by 3 loop\n raise notice '1..10 by 3: i = %', i;\n end loop;\n -- with BY, end does not match\n for i in 1..11 by 3 loop\n raise notice '1..11 by 3: i = %', i;\n end loop;\n -- zero iterations\n for i in 1..0 by 3 loop\n raise notice '1..0 by 3: i = %', i;\n end loop;\n -- REVERSE\n for i in reverse 10..0 by 3 loop\n raise notice 'reverse 10..0 by 3: i = %', i;\n end loop;\n -- potential overflow\n for i in 2147483620..2147483647 by 10 loop\n raise notice '2147483620..2147483647 by 10: i = %', i;\n end loop;\n -- potential overflow, reverse direction\n for i in reverse -2147483620..-2147483647 by 10 loop\n raise notice 'reverse -2147483620..-2147483647 by 10: i = %', i;\n end loop;\nend$$",
91105
"plpgsql_control-2.sql": "-- BY can't be zero or negative\ndo $$\nbegin\n for i in 1..3 by 0 loop\n raise notice '1..3 by 0: i = %', i;\n end loop;\nend$$",
92106
"plpgsql_control-3.sql": "do $$\nbegin\n for i in 1..3 by -1 loop\n raise notice '1..3 by -1: i = %', i;\n end loop;\nend$$",
Lines changed: 164 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,164 @@
1+
-- Fixtures to test deparser fixes from constructive-db PR #229
2+
-- These exercise: PERFORM, INTO clause placement, record field qualification, RETURN handling
3+
4+
-- Test 1: PERFORM statement (parser stores as SELECT, deparser must strip SELECT)
5+
CREATE FUNCTION test_perform_basic() RETURNS trigger
6+
LANGUAGE plpgsql AS $$
7+
BEGIN
8+
PERFORM pg_notify('test_channel', 'message');
9+
RETURN NEW;
10+
END$$;
11+
12+
-- Test 2: PERFORM with function call and arguments
13+
CREATE FUNCTION test_perform_with_args() RETURNS trigger
14+
LANGUAGE plpgsql AS $$
15+
BEGIN
16+
IF (TG_OP = 'INSERT' OR TG_OP = 'UPDATE') THEN
17+
PERFORM pg_notify(TG_ARGV[0], to_json(NEW)::text);
18+
RETURN NEW;
19+
END IF;
20+
IF (TG_OP = 'DELETE') THEN
21+
PERFORM pg_notify(TG_ARGV[0], to_json(OLD)::text);
22+
RETURN OLD;
23+
END IF;
24+
RETURN NULL;
25+
END$$;
26+
27+
-- Test 3: INTO clause with record field target (recfield qualification)
28+
CREATE FUNCTION test_into_record_field() RETURNS trigger
29+
LANGUAGE plpgsql AS $$
30+
BEGIN
31+
SELECT
32+
NEW.is_approved IS TRUE
33+
AND NEW.is_verified IS TRUE
34+
AND NEW.is_disabled IS FALSE INTO NEW.is_active;
35+
RETURN NEW;
36+
END$$;
37+
38+
-- Test 4: INTO clause with subquery (depth-aware scanner must skip nested FROM)
39+
CREATE FUNCTION test_into_with_subquery() RETURNS trigger
40+
LANGUAGE plpgsql AS $$
41+
DECLARE
42+
result_value int;
43+
BEGIN
44+
SELECT count(*) INTO result_value
45+
FROM (SELECT id FROM users WHERE id = NEW.user_id) sub;
46+
RETURN NEW;
47+
END$$;
48+
49+
-- Test 5: INTO clause with multiple record fields
50+
CREATE FUNCTION test_into_multiple_fields() RETURNS trigger
51+
LANGUAGE plpgsql AS $$
52+
BEGIN
53+
SELECT is_active, is_verified INTO NEW.is_active, NEW.is_verified
54+
FROM users WHERE id = NEW.user_id;
55+
RETURN NEW;
56+
END$$;
57+
58+
-- Test 6: SETOF function with RETURN QUERY and bare RETURN
59+
CREATE FUNCTION test_setof_return_query(p_limit int)
60+
RETURNS SETOF int
61+
LANGUAGE plpgsql AS $$
62+
BEGIN
63+
RETURN QUERY SELECT generate_series(1, p_limit);
64+
RETURN;
65+
END$$;
66+
67+
-- Test 7: SETOF function with RETURN NEXT
68+
CREATE FUNCTION test_setof_return_next(p_count int)
69+
RETURNS SETOF text
70+
LANGUAGE plpgsql AS $$
71+
DECLARE
72+
i int;
73+
BEGIN
74+
FOR i IN 1..p_count LOOP
75+
RETURN NEXT 'item_' || i::text;
76+
END LOOP;
77+
RETURN;
78+
END$$;
79+
80+
-- Test 8: Void function with bare RETURN
81+
CREATE FUNCTION test_void_function(p_value text)
82+
RETURNS void
83+
LANGUAGE plpgsql AS $$
84+
BEGIN
85+
RAISE NOTICE 'Value: %', p_value;
86+
RETURN;
87+
END$$;
88+
89+
-- Test 9: Scalar function with RETURN NULL
90+
CREATE FUNCTION test_scalar_return_null()
91+
RETURNS int
92+
LANGUAGE plpgsql AS $$
93+
BEGIN
94+
RETURN NULL;
95+
END$$;
96+
97+
-- Test 10: Scalar function with conditional RETURN
98+
CREATE FUNCTION test_scalar_conditional(p_value int)
99+
RETURNS int
100+
LANGUAGE plpgsql AS $$
101+
BEGIN
102+
IF p_value > 0 THEN
103+
RETURN p_value * 2;
104+
END IF;
105+
RETURN NULL;
106+
END$$;
107+
108+
-- Test 11: OUT parameter function with bare RETURN
109+
CREATE FUNCTION test_out_params(OUT ok boolean, OUT message text)
110+
LANGUAGE plpgsql AS $$
111+
BEGIN
112+
ok := true;
113+
message := 'success';
114+
RETURN;
115+
END$$;
116+
117+
-- Test 12: RETURNS TABLE function with RETURN QUERY
118+
CREATE FUNCTION test_returns_table(p_prefix text)
119+
RETURNS TABLE(id int, name text)
120+
LANGUAGE plpgsql AS $$
121+
BEGIN
122+
RETURN QUERY SELECT 1, p_prefix || '_one';
123+
RETURN QUERY SELECT 2, p_prefix || '_two';
124+
RETURN;
125+
END$$;
126+
127+
-- Test 13: Trigger function with complex logic
128+
CREATE FUNCTION test_trigger_complex() RETURNS trigger
129+
LANGUAGE plpgsql AS $$
130+
DECLARE
131+
defaults_record record;
132+
bit_len int;
133+
BEGIN
134+
bit_len := bit_length(NEW.permissions);
135+
136+
SELECT * INTO defaults_record
137+
FROM permission_defaults AS t
138+
LIMIT 1;
139+
140+
IF found THEN
141+
NEW.is_approved := defaults_record.is_approved;
142+
NEW.is_verified := defaults_record.is_verified;
143+
END IF;
144+
145+
IF NEW.is_owner IS TRUE THEN
146+
NEW.is_admin := true;
147+
NEW.is_approved := true;
148+
NEW.is_verified := true;
149+
END IF;
150+
151+
SELECT
152+
NEW.is_approved IS TRUE
153+
AND NEW.is_verified IS TRUE
154+
AND NEW.is_disabled IS FALSE INTO NEW.is_active;
155+
156+
RETURN NEW;
157+
END$$;
158+
159+
-- Test 14: Procedure (implicit void return)
160+
CREATE PROCEDURE test_procedure(p_message text)
161+
LANGUAGE plpgsql AS $$
162+
BEGIN
163+
RAISE NOTICE '%', p_message;
164+
END$$;

packages/plpgsql-deparser/__tests__/__snapshots__/hydrate-demo.test.ts.snap

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -69,7 +69,7 @@ BEGIN
6969
RAISE EXCEPTION 'p_round_to out of range: %', p_round_to;
7070
END IF;
7171
IF p_lock THEN
72-
PERFORM SELECT pg_advisory_xact_lock(v_lock_key);
72+
PERFORM pg_advisory_xact_lock(v_lock_key);
7373
END IF;
7474
IF p_debug THEN
7575
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;
@@ -99,7 +99,7 @@ BEGIN
9999
SELECT
100100
t.orders_scanned,
101101
t.gross_total,
102-
t.avg_total
102+
t.avg_total INTO v_orders_scanned, v_gross, v_avg
103103
FROM totals AS t;
104104
IF p_apply_discount THEN
105105
v_rebate := round(v_gross * GREATEST(LEAST(v_discount_rate + v_jitter, 0.50), 0), p_round_to);
@@ -110,7 +110,7 @@ BEGIN
110110
v_net := round(((v_gross - v_discount) + v_tax) * power(10::numeric, 0), p_round_to);
111111
SELECT
112112
oi.sku,
113-
CAST(sum(oi.quantity) AS bigint) AS qty
113+
CAST(sum(oi.quantity) AS bigint) AS qty INTO v_top_sku, v_top_sku_qty
114114
FROM app_public.order_item AS oi
115115
JOIN app_public.app_order AS o ON o.id = oi.order_id
116116
WHERE

packages/plpgsql-deparser/__tests__/plpgsql-deparser.test.ts

Lines changed: 64 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -32,14 +32,74 @@ describe('PLpgSQLDeparser', () => {
3232
});
3333

3434
describe('round-trip tests using generated.json', () => {
35-
it('should round-trip plpgsql_domain fixtures', async () => {
36-
const entries = fixtureTestUtils.getTestEntries(['plpgsql_domain']);
35+
// Known failing fixtures due to pre-existing deparser issues:
36+
// - Schema qualification loss (pg_catalog.pg_class%rowtype[] -> pg_class%rowtype[])
37+
// - Tagged dollar quote reconstruction ($tag$...$tag$ not supported)
38+
// - Exception block handling issues
39+
// TODO: Fix these underlying issues and remove from allowlist
40+
const KNOWN_FAILING_FIXTURES = new Set([
41+
'plpgsql_varprops-13.sql',
42+
'plpgsql_trap-1.sql',
43+
'plpgsql_trap-2.sql',
44+
'plpgsql_trap-3.sql',
45+
'plpgsql_trap-4.sql',
46+
'plpgsql_trap-5.sql',
47+
'plpgsql_trap-6.sql',
48+
'plpgsql_trap-7.sql',
49+
'plpgsql_transaction-17.sql',
50+
'plpgsql_transaction-19.sql',
51+
'plpgsql_transaction-20.sql',
52+
'plpgsql_transaction-21.sql',
53+
'plpgsql_control-15.sql',
54+
'plpgsql_control-17.sql',
55+
'plpgsql_call-44.sql',
56+
'plpgsql_array-20.sql',
57+
]);
58+
59+
it('should round-trip ALL generated fixtures (excluding known failures)', async () => {
60+
// Get all fixtures without any filter - this ensures we test everything
61+
const entries = fixtureTestUtils.getTestEntries();
3762
expect(entries.length).toBeGreaterThan(0);
3863

64+
const failures: { key: string; error: string }[] = [];
65+
const unexpectedPasses: string[] = [];
66+
3967
for (const [key] of entries) {
40-
await fixtureTestUtils.runSingleFixture(key);
68+
const isKnownFailing = KNOWN_FAILING_FIXTURES.has(key);
69+
try {
70+
await fixtureTestUtils.runSingleFixture(key);
71+
if (isKnownFailing) {
72+
unexpectedPasses.push(key);
73+
}
74+
} catch (err) {
75+
if (!isKnownFailing) {
76+
failures.push({
77+
key,
78+
error: err instanceof Error ? err.message : String(err),
79+
});
80+
}
81+
}
4182
}
42-
});
83+
84+
// Report unexpected passes (fixtures that should be removed from allowlist)
85+
if (unexpectedPasses.length > 0) {
86+
console.log(`\nUnexpected passes (remove from KNOWN_FAILING_FIXTURES):\n${unexpectedPasses.join('\n')}`);
87+
}
88+
89+
// Fail if any non-allowlisted fixtures fail (regression detection)
90+
if (failures.length > 0) {
91+
const failureReport = failures
92+
.map(f => ` - ${f.key}: ${f.error}`)
93+
.join('\n');
94+
throw new Error(
95+
`${failures.length} NEW fixture failures (not in allowlist):\n${failureReport}`
96+
);
97+
}
98+
99+
// Report coverage stats
100+
const testedCount = entries.length - KNOWN_FAILING_FIXTURES.size;
101+
console.log(`\nRound-trip tested ${testedCount} of ${entries.length} fixtures (${KNOWN_FAILING_FIXTURES.size} known failures skipped)`);
102+
}, 120000); // 2 minute timeout for all fixtures
43103
});
44104

45105
describe('PLpgSQLDeparser class', () => {

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

Lines changed: 6 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -36,7 +36,7 @@ begin
3636
raise exception 'p_round_to out of range: %', p_round_to;
3737
end if;
3838
if p_lock then
39-
perform SELECT pg_advisory_xact_lock(v_lock_key);
39+
perform pg_advisory_xact_lock(v_lock_key);
4040
end if;
4141
if p_debug then
4242
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;
@@ -67,7 +67,7 @@ begin
6767
SELECT
6868
t.orders_scanned,
6969
t.gross_total,
70-
t.avg_total
70+
t.avg_total into v_orders_scanned, v_gross, v_avg
7171
FROM totals t;
7272
if p_apply_discount then
7373
v_discount := round(v_gross * GREATEST(LEAST(v_discount_rate + v_jitter, 0.50), 0), p_round_to);
@@ -78,7 +78,7 @@ begin
7878
v_net := round((v_gross - v_discount + v_tax) * power(10::numeric, 0), p_round_to);
7979
SELECT
8080
oi.sku,
81-
sum(oi.quantity)::bigint AS qty
81+
sum(oi.quantity)::bigint AS qty into v_top_sku, v_top_sku_qty
8282
FROM app_public.order_item oi
8383
JOIN app_public.app_order o ON o.id = oi.order_id
8484
WHERE o.org_id = p_org_id
@@ -237,7 +237,7 @@ BEGIN
237237
RAISE EXCEPTION 'p_round_to out of range: %', p_round_to;
238238
END IF;
239239
IF p_lock THEN
240-
PERFORM SELECT pg_advisory_xact_lock(v_lock_key);
240+
PERFORM pg_advisory_xact_lock(v_lock_key);
241241
END IF;
242242
IF p_debug THEN
243243
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;
@@ -268,7 +268,7 @@ BEGIN
268268
SELECT
269269
t.orders_scanned,
270270
t.gross_total,
271-
t.avg_total
271+
t.avg_total INTO v_orders_scanned, v_gross, v_avg
272272
FROM totals t;
273273
IF p_apply_discount THEN
274274
v_discount := round(v_gross * GREATEST(LEAST(v_discount_rate + v_jitter, 0.50), 0), p_round_to);
@@ -279,7 +279,7 @@ BEGIN
279279
v_net := round((v_gross - v_discount + v_tax) * power(10::numeric, 0), p_round_to);
280280
SELECT
281281
oi.sku,
282-
sum(oi.quantity)::bigint AS qty
282+
sum(oi.quantity)::bigint AS qty INTO v_top_sku, v_top_sku_qty
283283
FROM app_public.order_item oi
284284
JOIN app_public.app_order o ON o.id = oi.order_id
285285
WHERE o.org_id = p_org_id

0 commit comments

Comments
 (0)