Skip to content

Commit 6593b6c

Browse files
committed
feat: add typed table access with PostgrestTable and TableColumn
1 parent 0b44b5a commit 6593b6c

15 files changed

Lines changed: 1390 additions & 0 deletions

packages/postgrest/lib/postgrest.dart

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,5 +3,6 @@ library;
33

44
export 'src/postgrest.dart';
55
export 'src/postgrest_builder.dart';
6+
export 'src/postgrest_typed_builder.dart';
67
export 'src/types.dart';
78
export 'package:http/http.dart' show RequestAbortedException;

packages/postgrest/lib/src/postgrest.dart

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -114,6 +114,22 @@ class PostgrestClient {
114114
);
115115
}
116116

117+
/// Perform a typed table operation.
118+
///
119+
/// Unlike [from], results are converted into the row type of [table]
120+
/// instead of raw `Map<String, dynamic>` data, and filters are built from
121+
/// [TableColumn]s, which makes them compile-time checked.
122+
///
123+
/// ```dart
124+
/// final List<Book> books = await client
125+
/// .table(Books.table)
126+
/// .select()
127+
/// .where(Books.id.gt(10));
128+
/// ```
129+
PostgrestTypedQueryBuilder<Row> table<Row>(PostgrestTable<Row> table) {
130+
return PostgrestTypedQueryBuilder(from(table.name), table);
131+
}
132+
117133
/// Select a schema to query or perform an function (rpc) call.
118134
///
119135
/// The schema needs to be on the list of exposed schemas inside Supabase.
Lines changed: 313 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,313 @@
1+
part of 'postgrest_typed_builder.dart';
2+
3+
/// Converts a single decoded PostgREST row into [Row].
4+
typedef RowConverter<Row> = Row Function(Map<String, dynamic> json);
5+
6+
/// Describes a database table (or view) together with the Dart type its rows
7+
/// are converted into.
8+
///
9+
/// Passing a [PostgrestTable] to [PostgrestClient.table] gives fully typed
10+
/// query results, so no raw `Map<String, dynamic>` needs to be handled:
11+
///
12+
/// ```dart
13+
/// extension type Book(Map<String, dynamic> json) {
14+
/// int get id => json['id'] as int;
15+
/// String get title => json['title'] as String;
16+
/// }
17+
///
18+
/// class Books {
19+
/// static const table = PostgrestTable('books', Book.new);
20+
/// static const id = TableColumn<int>('id');
21+
/// static const title = TableColumn<String>('title');
22+
/// }
23+
///
24+
/// final List<Book> books = await client
25+
/// .table(Books.table)
26+
/// .select()
27+
/// .where(Books.title.like('%Dart%'));
28+
/// ```
29+
///
30+
/// Extension types over the decoded JSON map (as above) are the recommended
31+
/// row representation since they carry no conversion cost and tolerate
32+
/// partial selects, but any converter works, for example `Book.fromJson` on a
33+
/// regular data class.
34+
class PostgrestTable<Row> {
35+
const PostgrestTable(this.name, this.rowFromJson);
36+
37+
/// Name of the table in the database.
38+
final String name;
39+
40+
/// Converts a decoded row into [Row].
41+
final RowConverter<Row> rowFromJson;
42+
}
43+
44+
/// A reference to a column of type [Value] on a database table.
45+
///
46+
/// Used to build compile-time checked filters through methods like
47+
/// [TableColumn.eq], which only accept values matching the column type.
48+
///
49+
/// [Value] is always the non-nullable value type of the column. Null checks
50+
/// are expressed with [isNull] and [isNotNull] instead of nullable values.
51+
class TableColumn<Value extends Object> {
52+
const TableColumn(this.name);
53+
54+
/// Name of the column in the database.
55+
final String name;
56+
57+
@override
58+
String toString() => name;
59+
60+
/// Only rows where this column equals [value].
61+
///
62+
/// For `null` equality, use [isNull] instead.
63+
ColumnFilter eq(Value value) =>
64+
ColumnFilter._(name, 'eq', value, (builder) => builder.eq(name, value));
65+
66+
/// Only rows where this column does not equal [value].
67+
ColumnFilter neq(Value value) =>
68+
ColumnFilter._(name, 'neq', value, (builder) => builder.neq(name, value));
69+
70+
/// Only rows where this column is greater than [value].
71+
ColumnFilter gt(Value value) =>
72+
ColumnFilter._(name, 'gt', value, (builder) => builder.gt(name, value));
73+
74+
/// Only rows where this column is greater than or equal to [value].
75+
ColumnFilter gte(Value value) =>
76+
ColumnFilter._(name, 'gte', value, (builder) => builder.gte(name, value));
77+
78+
/// Only rows where this column is less than [value].
79+
ColumnFilter lt(Value value) =>
80+
ColumnFilter._(name, 'lt', value, (builder) => builder.lt(name, value));
81+
82+
/// Only rows where this column is less than or equal to [value].
83+
ColumnFilter lte(Value value) =>
84+
ColumnFilter._(name, 'lte', value, (builder) => builder.lte(name, value));
85+
86+
/// Only rows where this column is `null`.
87+
ColumnFilter isNull() => ColumnFilter._(
88+
name,
89+
'is',
90+
null,
91+
(builder) => builder.isFilter(name, null),
92+
);
93+
94+
/// Only rows where this column is not `null`.
95+
ColumnFilter isNotNull() => isNull().not();
96+
97+
/// Only rows where this column equals one of [values].
98+
ColumnFilter inFilter(List<Value> values) => ColumnFilter._(
99+
name,
100+
'in',
101+
values,
102+
(builder) => builder.inFilter(name, values),
103+
);
104+
105+
/// Only rows where this column is not equal to [value], treating `null` as
106+
/// a comparable value.
107+
ColumnFilter isDistinctFrom(Value? value) => ColumnFilter._(
108+
name,
109+
'isdistinct',
110+
value,
111+
(builder) => builder.isDistinct(name, value),
112+
);
113+
114+
/// Only rows whose json, array, or range value contains [value].
115+
///
116+
/// See [PostgrestFilterBuilder.contains] for the accepted value shapes.
117+
ColumnFilter contains(Object value) => ColumnFilter._(
118+
name,
119+
'cs',
120+
value,
121+
(builder) => builder.contains(name, value),
122+
);
123+
124+
/// Only rows whose json, array, or range value is contained by [value].
125+
///
126+
/// See [PostgrestFilterBuilder.containedBy] for the accepted value shapes.
127+
ColumnFilter containedBy(Object value) => ColumnFilter._(
128+
name,
129+
'cd',
130+
value,
131+
(builder) => builder.containedBy(name, value),
132+
);
133+
134+
/// Only rows whose array or range value overlaps with [value].
135+
ColumnFilter overlaps(Object value) => ColumnFilter._(
136+
name,
137+
'ov',
138+
value,
139+
(builder) => builder.overlaps(name, value),
140+
);
141+
142+
/// Only rows whose range value is strictly to the left of [range].
143+
ColumnFilter rangeLt(String range) => ColumnFilter._(
144+
name,
145+
'sl',
146+
range,
147+
(builder) => builder.rangeLt(name, range),
148+
);
149+
150+
/// Only rows whose range value is strictly to the right of [range].
151+
ColumnFilter rangeGt(String range) => ColumnFilter._(
152+
name,
153+
'sr',
154+
range,
155+
(builder) => builder.rangeGt(name, range),
156+
);
157+
158+
/// Only rows whose range value does not extend to the left of [range].
159+
ColumnFilter rangeGte(String range) => ColumnFilter._(
160+
name,
161+
'nxl',
162+
range,
163+
(builder) => builder.rangeGte(name, range),
164+
);
165+
166+
/// Only rows whose range value does not extend to the right of [range].
167+
ColumnFilter rangeLte(String range) => ColumnFilter._(
168+
name,
169+
'nxr',
170+
range,
171+
(builder) => builder.rangeLte(name, range),
172+
);
173+
174+
/// Only rows whose range value is adjacent to [range].
175+
ColumnFilter rangeAdjacent(String range) => ColumnFilter._(
176+
name,
177+
'adj',
178+
range,
179+
(builder) => builder.rangeAdjacent(name, range),
180+
);
181+
}
182+
183+
/// Filters that only apply to text columns.
184+
extension TextTableColumnFilters on TableColumn<String> {
185+
/// Only rows whose value matches [pattern] case-sensitively.
186+
ColumnFilter like(String pattern) => ColumnFilter._(
187+
name,
188+
'like',
189+
pattern,
190+
(builder) => builder.like(name, pattern),
191+
);
192+
193+
/// Only rows whose value matches all of [patterns] case-sensitively.
194+
ColumnFilter likeAllOf(List<String> patterns) => ColumnFilter._(
195+
name,
196+
'like(all)',
197+
patterns,
198+
(builder) => builder.likeAllOf(name, patterns),
199+
);
200+
201+
/// Only rows whose value matches any of [patterns] case-sensitively.
202+
ColumnFilter likeAnyOf(List<String> patterns) => ColumnFilter._(
203+
name,
204+
'like(any)',
205+
patterns,
206+
(builder) => builder.likeAnyOf(name, patterns),
207+
);
208+
209+
/// Only rows whose value matches [pattern] case-insensitively.
210+
ColumnFilter ilike(String pattern) => ColumnFilter._(
211+
name,
212+
'ilike',
213+
pattern,
214+
(builder) => builder.ilike(name, pattern),
215+
);
216+
217+
/// Only rows whose value matches all of [patterns] case-insensitively.
218+
ColumnFilter ilikeAllOf(List<String> patterns) => ColumnFilter._(
219+
name,
220+
'ilike(all)',
221+
patterns,
222+
(builder) => builder.ilikeAllOf(name, patterns),
223+
);
224+
225+
/// Only rows whose value matches any of [patterns] case-insensitively.
226+
ColumnFilter ilikeAnyOf(List<String> patterns) => ColumnFilter._(
227+
name,
228+
'ilike(any)',
229+
patterns,
230+
(builder) => builder.ilikeAnyOf(name, patterns),
231+
);
232+
233+
/// Only rows whose value matches [pattern] as a PostgreSQL regular
234+
/// expression, case-sensitively.
235+
ColumnFilter matchRegex(String pattern) => ColumnFilter._(
236+
name,
237+
'match',
238+
pattern,
239+
(builder) => builder.matchRegex(name, pattern),
240+
);
241+
242+
/// Only rows whose value matches [pattern] as a PostgreSQL regular
243+
/// expression, case-insensitively.
244+
ColumnFilter imatchRegex(String pattern) => ColumnFilter._(
245+
name,
246+
'imatch',
247+
pattern,
248+
(builder) => builder.imatchRegex(name, pattern),
249+
);
250+
251+
/// Only rows whose text or tsvector value matches the tsquery in [query].
252+
///
253+
/// See [PostgrestFilterBuilder.textSearch] for [config] and [type].
254+
ColumnFilter textSearch(
255+
String query, {
256+
String? config,
257+
TextSearchType? type,
258+
}) {
259+
final typePart = switch (type) {
260+
TextSearchType.plain => 'pl',
261+
TextSearchType.phrase => 'ph',
262+
TextSearchType.websearch => 'w',
263+
null => '',
264+
};
265+
final configPart = config == null ? '' : '($config)';
266+
return ColumnFilter._(
267+
name,
268+
'${typePart}fts$configPart',
269+
query,
270+
(builder) => builder.textSearch(name, query, config: config, type: type),
271+
);
272+
}
273+
}
274+
275+
/// A single filter condition on a column, created through the methods on
276+
/// [TableColumn] such as [TableColumn.eq].
277+
///
278+
/// Applied to a typed query with [PostgrestTypedFilterBuilder.where].
279+
class ColumnFilter {
280+
const ColumnFilter._(this.column, this.operator, this.value, this._apply);
281+
282+
/// Name of the column being filtered on.
283+
final String column;
284+
285+
/// The PostgREST operator of this filter, for example `eq` or `like(all)`.
286+
final String operator;
287+
288+
/// The value the filter compares against.
289+
final Object? value;
290+
291+
final PostgrestFilterBuilder<dynamic> Function(
292+
PostgrestFilterBuilder<dynamic> builder,
293+
)
294+
_apply;
295+
296+
/// Negates this filter.
297+
///
298+
/// ```dart
299+
/// client.table(Books.table).select().where(Books.id.eq(1).not());
300+
/// ```
301+
ColumnFilter not() {
302+
if (operator.startsWith('not.')) {
303+
throw StateError('The filter on "$column" is already negated.');
304+
}
305+
final positiveOperator = operator;
306+
return ColumnFilter._(
307+
column,
308+
'not.$operator',
309+
value,
310+
(builder) => builder.not(column, positiveOperator, value),
311+
);
312+
}
313+
}

0 commit comments

Comments
 (0)