Skip to content
This repository was archived by the owner on Jul 22, 2025. It is now read-only.

Commit 54e453b

Browse files
committed
feat: implement HtDataPostgresClient for PostgreSQL CRUD operations
1 parent 679d9ab commit 54e453b

File tree

1 file changed

+357
-4
lines changed

1 file changed

+357
-4
lines changed

lib/src/ht_data_postgres.dart

Lines changed: 357 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,360 @@
1+
import 'package:ht_data_client/ht_data_client.dart';
2+
import 'package:ht_shared/ht_shared.dart' hide ServerException;
3+
import 'package:postgres/postgres.dart';
4+
15
/// {@template ht_data_postgres}
2-
/// A Very Good Project created by Very Good CLI.
6+
/// A generic PostgreSQL implementation of the [HtDataClient] interface.
7+
///
8+
/// This client translates the generic CRUD and query operations into SQL
9+
/// commands to interact with a PostgreSQL database. It requires a table name
10+
/// and functions for JSON serialization/deserialization to work with any
11+
/// data model [T].
312
/// {@endtemplate}
4-
class HtDataPostgres {
5-
/// {@macro ht_data_postgres}
6-
const HtDataPostgres();
13+
class HtDataPostgresClient<T> implements HtDataClient<T> {
14+
/// {@macro ht_data_postgres_client}
15+
HtDataPostgresClient({
16+
required this.connection,
17+
required this.tableName,
18+
required this.fromJson,
19+
required this.toJson,
20+
}) : _queryBuilder = _QueryBuilder(tableName: tableName);
21+
22+
/// The active PostgreSQL database connection.
23+
final Connection connection;
24+
25+
/// The name of the database table corresponding to type [T].
26+
final String tableName;
27+
28+
/// A function that converts a JSON map into an object of type [T].
29+
final FromJson<T> fromJson;
30+
31+
/// A function that converts an object of type [T] into a JSON map.
32+
/// This map's keys should correspond to the database column names.
33+
final ToJson<T> toJson;
34+
35+
/// A helper class to construct SQL queries dynamically.
36+
final _QueryBuilder _queryBuilder;
37+
38+
@override
39+
Future<SuccessApiResponse<T>> create({
40+
required T item,
41+
String? userId,
42+
}) async {
43+
try {
44+
final data = toJson(item);
45+
if (userId != null) {
46+
// Assume a 'user_id' column for user-owned models.
47+
data['user_id'] = userId;
48+
}
49+
50+
final columns = data.keys.join(', ');
51+
final placeholders = List.generate(
52+
data.length,
53+
(i) => '@${data.keys.elementAt(i)}',
54+
).join(', ');
55+
56+
final sql = Sql.named(
57+
'INSERT INTO $tableName ($columns) VALUES ($placeholders) RETURNING *;',
58+
);
59+
60+
final result = await connection.execute(sql, parameters: data);
61+
62+
final createdItem = fromJson(
63+
result.first.toColumnMap(),
64+
);
65+
return SuccessApiResponse(data: createdItem);
66+
} on Object catch (e) {
67+
throw _handlePgException(e);
68+
}
69+
}
70+
71+
@override
72+
Future<void> delete({required String id, String? userId}) async {
73+
try {
74+
var sql = 'DELETE FROM $tableName WHERE id = @id';
75+
final parameters = <String, dynamic>{'id': id};
76+
77+
if (userId != null) {
78+
sql += ' AND user_id = @userId';
79+
parameters['userId'] = userId;
80+
}
81+
82+
final result = await connection.execute(
83+
Sql.named(sql),
84+
parameters: parameters,
85+
);
86+
87+
if (result.affectedRows == 0) {
88+
throw NotFoundException(
89+
'Item with ID "$id" not found${userId != null ? ' for this user' : ''}.',
90+
);
91+
}
92+
} on Object catch (e) {
93+
throw _handlePgException(e);
94+
}
95+
}
96+
97+
@override
98+
Future<SuccessApiResponse<T>> read({
99+
required String id,
100+
String? userId,
101+
}) async {
102+
try {
103+
var sql = 'SELECT * FROM $tableName WHERE id = @id';
104+
final parameters = <String, dynamic>{'id': id};
105+
106+
if (userId != null) {
107+
sql += ' AND user_id = @userId';
108+
parameters['userId'] = userId;
109+
}
110+
sql += ';';
111+
112+
final result = await connection.execute(
113+
Sql.named(sql),
114+
parameters: parameters,
115+
);
116+
117+
if (result.isEmpty) {
118+
throw NotFoundException(
119+
'Item with ID "$id" not found${userId != null ? ' for this user' : ''}.',
120+
);
121+
}
122+
final readItem = fromJson(result.first.toColumnMap());
123+
return SuccessApiResponse(data: readItem);
124+
} on Object catch (e) {
125+
throw _handlePgException(e);
126+
}
127+
}
128+
129+
@override
130+
Future<SuccessApiResponse<PaginatedResponse<T>>> readAll({
131+
String? userId,
132+
String? startAfterId,
133+
int? limit,
134+
String? sortBy,
135+
SortOrder? sortOrder,
136+
}) {
137+
// readAll is just a special case of readAllByQuery with an empty query.
138+
return readAllByQuery(
139+
{},
140+
userId: userId,
141+
startAfterId: startAfterId,
142+
limit: limit,
143+
sortBy: sortBy,
144+
sortOrder: sortOrder,
145+
);
146+
}
147+
148+
@override
149+
Future<SuccessApiResponse<PaginatedResponse<T>>> readAllByQuery(
150+
Map<String, dynamic> query, {
151+
String? userId,
152+
String? startAfterId,
153+
int? limit,
154+
String? sortBy,
155+
SortOrder? sortOrder,
156+
}) async {
157+
try {
158+
// Note: startAfterId is not yet implemented for PostgreSQL client.
159+
// Keyset pagination would be required for a robust implementation.
160+
final (sql, params) = _queryBuilder.buildSelect(
161+
query: query,
162+
userId: userId,
163+
limit: limit,
164+
sortBy: sortBy,
165+
sortOrder: sortOrder,
166+
);
167+
168+
final result = await connection.execute(
169+
Sql.named(sql),
170+
parameters: params,
171+
);
172+
173+
final items = result.map((row) => fromJson(row.toColumnMap())).toList();
174+
175+
var hasMore = false;
176+
if (limit != null && items.length > limit) {
177+
hasMore = true;
178+
items.removeLast();
179+
}
180+
181+
// The cursor should be the ID of the last item in the returned list.
182+
final cursor = items.isNotEmpty
183+
? (items.last as dynamic).id as String?
184+
: null;
185+
186+
return SuccessApiResponse(
187+
data: PaginatedResponse(
188+
items: items,
189+
hasMore: hasMore,
190+
cursor: cursor,
191+
),
192+
);
193+
} on Object catch (e) {
194+
throw _handlePgException(e);
195+
}
196+
}
197+
198+
@override
199+
Future<SuccessApiResponse<T>> update({
200+
required String id,
201+
required T item,
202+
String? userId,
203+
}) async {
204+
try {
205+
final data = toJson(item)
206+
// Remove 'id' from the data to be updated in SET clause, as it's used
207+
// in the WHERE clause. Also remove created_at if it exists.
208+
..remove('id')
209+
..remove('created_at');
210+
211+
if (data.isEmpty) {
212+
// Nothing to update, just read and return the item.
213+
return read(id: id, userId: userId);
214+
}
215+
216+
final setClauses = data.keys.map((key) => '$key = @$key').join(', ');
217+
218+
var sql = 'UPDATE $tableName SET $setClauses WHERE id = @id';
219+
final parameters = <String, dynamic>{...data, 'id': id};
220+
221+
if (userId != null) {
222+
sql += ' AND user_id = @userId';
223+
parameters['userId'] = userId;
224+
}
225+
sql += ' RETURNING *;';
226+
227+
final result = await connection.execute(
228+
Sql.named(sql),
229+
parameters: parameters,
230+
);
231+
232+
if (result.isEmpty) {
233+
throw NotFoundException(
234+
'Item with ID "$id" not found${userId != null ? ' for this user' : ''}.',
235+
);
236+
}
237+
final updatedItem = fromJson(result.first.toColumnMap());
238+
return SuccessApiResponse(data: updatedItem);
239+
} on Object catch (e) {
240+
throw _handlePgException(e);
241+
}
242+
}
243+
244+
/// Maps a [PgException] to a corresponding [HtHttpException].
245+
Exception _handlePgException(Object e) {
246+
if (e is ServerException) {
247+
// See PostgreSQL error codes: https://www.postgresql.org/docs/current/errcodes-appendix.html
248+
final code = e.code;
249+
if (code != null) {
250+
switch (code) {
251+
case '23505': // unique_violation
252+
return ConflictException(
253+
e.message,
254+
);
255+
case '23503': // foreign_key_violation
256+
return BadRequestException(
257+
e.message,
258+
);
259+
}
260+
}
261+
return OperationFailedException(
262+
'A database error occurred: ${e.message}',
263+
);
264+
} else if (e is PgException) {
265+
return OperationFailedException(
266+
'A database connection error occurred: ${e.message}',
267+
);
268+
}
269+
return Exception('An unknown error occurred: $e');
270+
}
271+
}
272+
273+
/// A helper class to dynamically build SQL SELECT queries.
274+
class _QueryBuilder {
275+
_QueryBuilder({required this.tableName});
276+
277+
final String tableName;
278+
279+
/// Builds a SQL SELECT statement and its substitution parameters.
280+
(String, Map<String, dynamic>) buildSelect({
281+
required Map<String, dynamic> query,
282+
String? userId,
283+
int? limit,
284+
String? sortBy,
285+
SortOrder? sortOrder,
286+
}) {
287+
final whereClauses = <String>[];
288+
final params = <String, dynamic>{};
289+
var paramCounter = 0;
290+
291+
// Handle user-scoping
292+
if (userId != null) {
293+
whereClauses.add('user_id = @userId');
294+
params['userId'] = userId;
295+
}
296+
297+
// Handle generic query map
298+
for (final entry in query.entries) {
299+
final key = entry.key;
300+
final value = entry.value;
301+
302+
if (key.endsWith('_in')) {
303+
final column = _sanitizeColumnName(key.replaceAll('_in', ''));
304+
final values = (value as String).split(',');
305+
if (values.isNotEmpty) {
306+
final paramNames = <String>[];
307+
for (final val in values) {
308+
final paramName = 'p${paramCounter++}';
309+
paramNames.add('@$paramName');
310+
params[paramName] = val;
311+
}
312+
whereClauses.add('$column IN (${paramNames.join(', ')})');
313+
}
314+
} else if (key.endsWith('_contains')) {
315+
final column = _sanitizeColumnName(key.replaceAll('_contains', ''));
316+
final paramName = 'p${paramCounter++}';
317+
whereClauses.add('$column ILIKE @$paramName');
318+
params[paramName] = '%$value%';
319+
} else {
320+
// Exact match
321+
final column = _sanitizeColumnName(key);
322+
final paramName = 'p${paramCounter++}';
323+
whereClauses.add('$column = @$paramName');
324+
params[paramName] = value;
325+
}
326+
}
327+
328+
var sql = 'SELECT * FROM $tableName';
329+
if (whereClauses.isNotEmpty) {
330+
sql += ' WHERE ${whereClauses.join(' AND ')}';
331+
}
332+
333+
// Handle sorting
334+
if (sortBy != null) {
335+
final order = sortOrder == SortOrder.desc ? 'DESC' : 'ASC';
336+
sql += ' ORDER BY ${_sanitizeColumnName(sortBy)} $order';
337+
}
338+
339+
// Handle limit (fetch one extra to check for `hasMore`)
340+
if (limit != null) {
341+
sql += ' LIMIT ${limit + 1}';
342+
}
343+
344+
sql += ';';
345+
346+
return (sql, params);
347+
}
348+
349+
/// Sanitizes a column name to prevent SQL injection.
350+
/// Converts dot notation (e.g., 'category.id') to snake_case ('category_id').
351+
String _sanitizeColumnName(String name) {
352+
// A simple sanitizer. For production, a more robust one might be needed.
353+
// This prevents basic injection by only allowing alphanumeric, underscore,
354+
// and dot characters, then replacing dots.
355+
if (!RegExp(r'^[a-zA-Z0-9_.]+$').hasMatch(name)) {
356+
throw ArgumentError('Invalid column name format: $name');
357+
}
358+
return name.replaceAll('.', '_');
359+
}
7360
}

0 commit comments

Comments
 (0)