Skip to content

Commit 908976c

Browse files
sbutlerjrgrdsdev
andauthored
fix(postgrest): URL replacement logic in query builder (#1228)
* Fix URL replacement logic in query builder * tests for upsert with onConflict * style: code format --------- Co-authored-by: Guilherme Souza <[email protected]>
1 parent ae2d12d commit 908976c

File tree

3 files changed

+196
-6
lines changed

3 files changed

+196
-6
lines changed

infra/postgrest/db/00-schema.sql

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -111,3 +111,15 @@ CREATE TABLE public.addresses (
111111
username text REFERENCES users NOT NULL,
112112
location geometry(POINT,4326)
113113
);
114+
115+
-- Test Table For UPSERT & onConflict
116+
CREATE TABLE public.imported_data (
117+
id bigint GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY,
118+
external_id text NOT NULL,
119+
source_system text NOT NULL,
120+
data jsonb DEFAULT null,
121+
status text DEFAULT 'pending',
122+
created_at timestamp with time zone default timezone('utc'::text, now()) not null,
123+
updated_at timestamp with time zone default timezone('utc'::text, now()) not null,
124+
UNIQUE(external_id, source_system)
125+
);

packages/postgrest/lib/src/postgrest_query_builder.dart

Lines changed: 8 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -153,20 +153,22 @@ class PostgrestQueryBuilder<T> extends RawPostgrestBuilder<T, T, T> {
153153
if (!defaultToNull) {
154154
newHeaders['Prefer'] = '${newHeaders['Prefer']!},missing=default';
155155
}
156+
156157
Uri url = _url;
158+
159+
if (values is List) {
160+
url = _setColumnsSearchParam(values);
161+
}
162+
157163
if (onConflict != null) {
158-
url = _url.replace(
164+
url = url.replace(
159165
queryParameters: {
160166
'on_conflict': onConflict,
161-
..._url.queryParameters,
167+
...url.queryParameters,
162168
},
163169
);
164170
}
165171

166-
if (values is List) {
167-
url = _setColumnsSearchParam(values);
168-
}
169-
170172
return PostgrestFilterBuilder<T>(_copyWith(
171173
method: METHOD_POST,
172174
headers: newHeaders,
Lines changed: 176 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,176 @@
1+
import 'package:postgrest/postgrest.dart';
2+
import 'package:test/test.dart';
3+
4+
import 'reset_helper.dart';
5+
6+
void main() {
7+
const rootUrl = 'http://localhost:3000';
8+
late PostgrestClient postgrest;
9+
final resetHelper = ResetHelper();
10+
11+
group('UPSERT & onConflict tests', () {
12+
setUpAll(() async {
13+
postgrest = PostgrestClient(rootUrl);
14+
await resetHelper.initialize(postgrest);
15+
});
16+
17+
setUp(() {
18+
postgrest = PostgrestClient(rootUrl);
19+
});
20+
21+
tearDown(() async {
22+
await resetHelper.reset();
23+
});
24+
25+
test('upsert with List values and onConflict parameter', () async {
26+
// Clean up the imported_data table before starting the test
27+
await postgrest.from('imported_data').delete().neq('id', 0);
28+
29+
// Test data - 3 rows with unique constraint on external_id + source_system
30+
final testData = [
31+
{
32+
'external_id': 'ext_001',
33+
'source_system': 'system_a',
34+
'data': {'name': 'Test Item 1', 'value': 100},
35+
'status': 'active'
36+
},
37+
{
38+
'external_id': 'ext_002',
39+
'source_system': 'system_a',
40+
'data': {'name': 'Test Item 2', 'value': 200},
41+
'status': 'active'
42+
},
43+
{
44+
'external_id': 'ext_003',
45+
'source_system': 'system_b',
46+
'data': {'name': 'Test Item 3', 'value': 300},
47+
'status': 'active'
48+
}
49+
];
50+
51+
// Step 1: INSERT 3 rows (without onConflict)
52+
final insertResult =
53+
await postgrest.from('imported_data').insert(testData).select();
54+
55+
expect(insertResult.length, 3);
56+
expect(insertResult[0]['external_id'], 'ext_001');
57+
expect(insertResult[1]['external_id'], 'ext_002');
58+
expect(insertResult[2]['external_id'], 'ext_003');
59+
60+
// Step 2: UPSERT with first row from test data (without onConflict) - should fail
61+
final duplicateData = [
62+
{
63+
'external_id': 'ext_001',
64+
'source_system': 'system_a',
65+
'data': {'name': 'Updated Item 1', 'value': 150},
66+
'status': 'updated'
67+
}
68+
];
69+
70+
try {
71+
await postgrest.from('imported_data').upsert(duplicateData).select();
72+
fail(
73+
'Expected upsert without onConflict to fail due to unique constraint violation');
74+
} on PostgrestException catch (error) {
75+
// Should fail with unique constraint violation
76+
expect(error.code, '23505'); // PostgreSQL unique violation error code
77+
}
78+
79+
// Step 3: UPSERT with first row from test data (with onConflict) - should succeed
80+
final updatedData = [
81+
{
82+
'external_id': 'ext_001',
83+
'source_system': 'system_a',
84+
'data': {'name': 'Successfully Updated Item 1', 'value': 175},
85+
'status': 'updated_via_upsert'
86+
}
87+
];
88+
89+
final upsertResult = await postgrest
90+
.from('imported_data')
91+
.upsert(
92+
updatedData,
93+
onConflict: 'external_id,source_system',
94+
)
95+
.select();
96+
97+
expect(upsertResult.length, 1);
98+
expect(upsertResult[0]['external_id'], 'ext_001');
99+
expect(upsertResult[0]['source_system'], 'system_a');
100+
expect(upsertResult[0]['status'], 'updated_via_upsert');
101+
expect(upsertResult[0]['data']['name'], 'Successfully Updated Item 1');
102+
expect(upsertResult[0]['data']['value'], 175);
103+
104+
// Step 4: GET to confirm the UPSERT updated the row
105+
final verifyResult = await postgrest
106+
.from('imported_data')
107+
.select()
108+
.eq('external_id', 'ext_001')
109+
.eq('source_system', 'system_a');
110+
111+
expect(verifyResult.length, 1);
112+
expect(verifyResult[0]['status'], 'updated_via_upsert');
113+
expect(verifyResult[0]['data']['name'], 'Successfully Updated Item 1');
114+
expect(verifyResult[0]['data']['value'], 175);
115+
116+
// Verify total count is still 3 (no new rows added, just updated)
117+
final allRows = await postgrest.from('imported_data').select();
118+
expect(allRows.length, 3);
119+
});
120+
121+
test('upsert with List values and onConflict - multiple rows update',
122+
() async {
123+
// Clean up the imported_data table before starting the test
124+
await postgrest.from('imported_data').delete().neq('id', 0);
125+
126+
// Test the fix with multiple rows in a single upsert operation
127+
final initialData = [
128+
{
129+
'external_id': 'batch_001',
130+
'source_system': 'batch_system',
131+
'data': {'batch': 1, 'initial': true},
132+
'status': 'initial'
133+
},
134+
{
135+
'external_id': 'batch_002',
136+
'source_system': 'batch_system',
137+
'data': {'batch': 1, 'initial': true},
138+
'status': 'initial'
139+
}
140+
];
141+
142+
// Insert initial data
143+
await postgrest.from('imported_data').insert(initialData).select();
144+
145+
// Update both rows in a single upsert operation
146+
final updateData = [
147+
{
148+
'external_id': 'batch_001',
149+
'source_system': 'batch_system',
150+
'data': {'batch': 1, 'updated': true},
151+
'status': 'batch_updated'
152+
},
153+
{
154+
'external_id': 'batch_002',
155+
'source_system': 'batch_system',
156+
'data': {'batch': 1, 'updated': true},
157+
'status': 'batch_updated'
158+
}
159+
];
160+
161+
final batchUpsertResult = await postgrest
162+
.from('imported_data')
163+
.upsert(
164+
updateData,
165+
onConflict: 'external_id,source_system',
166+
)
167+
.select();
168+
169+
expect(batchUpsertResult.length, 2);
170+
expect(batchUpsertResult[0]['status'], 'batch_updated');
171+
expect(batchUpsertResult[1]['status'], 'batch_updated');
172+
expect(batchUpsertResult[0]['data']['updated'], true);
173+
expect(batchUpsertResult[1]['data']['updated'], true);
174+
});
175+
});
176+
}

0 commit comments

Comments
 (0)