Typesafe Supabase Flutter Queries
Generate Flutter / Dart 🎯 classes from your Supabase schema.
// allBooks is a typeof List<Books>
final allBooks = await supabase
.books
.select("*")
.withConverter(Books.converter);
Supabase Identifier | PostgreSQL Format | JSON Type | Dart Type | Runtime Tested |
---|---|---|---|---|
# int2 | smallint | integer | int | type ✅ type[]✅ |
# int4 | integer | integer | int | type ✅ type[]✅ |
# int8 | bigint | integer | BigInt | type ✅ type[]✅ |
# float4 | real | number | double | type ✅ type[]✅ |
# float8 | double precision | number | double | type ✅ type[]✅ |
# numeric | numeric | number | num | type ✅ type[]✅ |
{} json | json | object | Map<String, dynamic> | type ✅ type[]✅ |
{} jsonb | jsonb | object | Map<String, dynamic> | type ✅ type[]✅ |
T text | text | string | String | type ✅ type[]✅ |
T varchar | character varying | string | String | type ✅ type[]✅ |
T uuid | uuid | string | String | type ✅ type[]✅ |
🗓️ date | date | string | DateTime | type ✅ type[]✅ |
🗓️ time | time without time zone | string | DateTime | type ✅ type[]✅ |
🗓️ timetz | time with time zone | string | DateTime | type ✅ type[]✅ |
🗓️ timestamp | timestamp without time zone | string | DateTime | type ✅ type[]✅ |
🗓️ timestamptz | timestamp with time zone | string | DateTime | type ✅ type[]✅ |
🕒 interval | interval | string | Duration | type ✅ type[]✅ |
💡 bool | boolean | boolean | bool | type ✅ type[]✅ |
🗂️ ENUMS | ENUM | string | Enum | type ✅ type[]✅ |
if you have serial types you need to add a [supadart:serial]
to the column like this
COMMENT ON COLUMN test_table.bigserialx IS '[supadart:serial]';
COMMENT ON COLUMN test_table.smallserialx IS 'you can still add comment [supadart:serial]';
COMMENT ON COLUMN test_table.serialx IS 'this part [supadart:serial] just needs to be included';
-- otherwise the insert method will always ask for a value even though serial types are auto-generated
serial types in general are not available in supabase table editor afaik, so if you did not add them manually via sql editor you probably dont have them. Why do we need this?
Internationalization
package# This is an official package from dart and is used for parsing dates
flutter pub add intl
# or
dart pub add intl
Unless you are not using any date types, you can skip this step
this tool will automatically convert snake_case to camelCase for both table (GeneratedClassName) and column (FieldName) generated names.
snake_case => camelCase
user_table => UserTable
snake_case => camelCase
user_id => userId
# 🎯 Active from pub.dev
dart pub global activate supadart
# 🚀 Run via
supadart
# or
dart pub global run supadart
supadart -u <your-supabase-url> -k <your-supabase-anon-key>
# and you are good to go!
-h, --help Show usage information
-i, --init Initialize config file supadart.yaml
-c, --config Path to config file of yaml --(default: supadart.yaml)
-u, --url Supabase URL --(default: supadart.yaml supabase_url)
-k, --key Supabase ANON KEY --(default: supadart.yaml supabase_anon_key)
-o, --output Output file path, add ./ prefix --(default: ./lib/generated_classes.dart or ./lib/models/ if --separated is enabled)
-d, --dart Generation for pure Dart project --(default: false)
-s, --separated Separated files for each classes --(default: false)
-e, --exclude Select methods to exclude ex. "toJson,copyWith"
-v, --version
Alternatively, you can use a configuration file so you just have to run supadart
without any arguments.
Run supadart --init
to create a supadart.yaml
file in your project root directory.
# supadart.yaml
# Required (if you dont have `-u` specified)
supabase_url: https://xxx.supabase.co
# Required (if you dont have `-k` specified)
supabase_anon_key: xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx
# Optional, where to place the generated classes files default: ./lib/models/
output: lib/models/
# Set to true, if you want to generate separated files for each classes
separated: false
# Set to true, if you are not using Flutter, just normal Dart project
dart: false
# Optional, used to map table names to class names(case-sensitive)
mappings:
# books: book
# categories: category
# children: child
# people: person
# Optional, used to exclude methods from generated classes
exclude:
# - toJson
# - copyWith
then you can just run supadart
in the terminal!
# Set the supabase_url and supabase_anon_key in your supadart.yaml file
supadart
# If you dont have the Supabase URL and ANON KEY specified in your .yaml file
supadart -u <your-supabase-url> -k <your-supabase-anon-key>
# If you have a .yaml file in a different location
supadart -c path/to/.yaml
Assuming the following table schema
create table
public.books (
id bigint generated by default as identity,
name character varying not null,
description text null,
price integer not null,
created_at timestamp with time zone not null default now(),
constraint books_pkey primary key (id)
) tablespace pg_default;
class Books implements SupadartClass<Books> {
final BigInt id;
final String name;
final String? description;
final int price;
final DateTime? createdAt;
const Books({
required this.id,
required this.name,
this.description,
required this.price,
this.createdAt,
});
static String get table_name => 'books';
static String get c_id => 'id';
static String get c_name => 'name';
static String get c_description => 'description';
static String get c_price => 'price';
static String get c_createdAt => 'created_at';
static List<Books> converter(List<Map<String, dynamic>> data) {
return data.map(Books.fromJson).toList();
}
static Books converterSingle(Map<String, dynamic> data) {
return Books.fromJson(data);
}
static Map<String, dynamic> _generateMap({
BigInt? id,
String? name,
String? description,
int? price,
DateTime? createdAt,
}) {
return {
if (id != null) 'id': id.toString(),
if (name != null) 'name': name.toString(),
if (description != null) 'description': description.toString(),
if (price != null) 'price': price.toString(),
if (createdAt != null) 'created_at': createdAt.toUtc().toString(),
};
}
static Map<String, dynamic> insert({
BigInt? id,
required String name,
String? description,
required int price,
DateTime? createdAt,
}) {
return _generateMap(
id: id,
name: name,
description: description,
price: price,
createdAt: createdAt,
);
}
static Map<String, dynamic> update({
BigInt? id,
String? name,
String? description,
int? price,
DateTime? createdAt,
}) {
return _generateMap(
id: id,
name: name,
description: description,
price: price,
createdAt: createdAt,
);
}
factory Books.fromJson(Map<String, dynamic> json) {
return Books(
id: json['id'] != null
? BigInt.parse(json['id'].toString())
: BigInt.from(0),
name: json['name'] != null ? json['name'].toString() : '',
description:
json['description'] != null ? json['description'].toString() : '',
price: json['price'] != null ? json['price'] as int : 0,
createdAt: json['created_at'] != null
? DateTime.tryParse(json['created_at'].toString()) as DateTime
: DateTime.fromMillisecondsSinceEpoch(0),
);
}
Map<String, dynamic> toJson() {
// Promotion doesn't work well with public fields due to the possibility of the field being modified elsewhere.
return _generateMap(
id: id,
name: name,
description: description,
price: price,
createdAt: createdAt,
);
}
}
we now have a typesafe'ish to interact with the database.
// allBooks is a typeof List<Books>
final allBooks = await supabase
.books
.select("*")
.withConverter(Books.converter);
// book is a typeof Books
final book = await supabase
.books
.select("*")
.eq(Books.c_id, 1)
.single()
.withConverter(Books.converterSingle);
// Yes we know which one's are optional or required.
final data = Books.insert(
name: 'Learn Flutter',
description: 'Endless brackets and braces',
price: 2,
);
await supabase.books.insert(data);
final many_data = [
Books.insert(
name: 'Learn Minecraft',
description: 'Endless blocks and bricks',
price: 2,
),
Books.insert(
name: 'Description is optional',
created_at: DateTime.now(),
price: 2,
),
];
await supabase.books.insert(many_data);
final newData = Books.update(
name: 'New Book Name',
);
await supabase.books.update(newData).eq(Books.c_id, 1);
await supabase.books.delete().eq(Books.c_id, 1);
-- assuming the following schema
CREATE TYPE mood AS ENUM ('happy', 'sad', 'neutral', 'excited', 'angry');
CREATE TABLE enum_types (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
mood mood NOT NULL
);
// enum names are converted to .toUpperCase()
enum MOOD { happy, sad, neutral, excited, angry }
MOOD firstEnumVal = MOOD.angry;
MOOD newEnumVal = MOOD.excited;
// Create
await supabase.enum_types.insert(EnumTypes.insert(
mood: firstEnumVal,
));
await supabase.enum_types
// Update
.update(EnumTypes.update(mood: newEnumVal))
// Equality ⚠️ you need to do manual ⬇️ enum to string conversion
.eq(EnumTypes.c_mood, firstEnumVal.toString().split(".").last);
// Read
await supabase.enum_types.select().withConverter(EnumTypes.converter);
final book = await supabase
.from('books')
.select('${Books.c_id}, ${Books.c_name}')
.eq(Books.c_id, 69) // Assuming 69 is the id
.single()
.withConverter(Books.converterSingle);
print(book.id); // 69
print(book.name); // "Supadart"
print(book.description); // ""
print(book.price); // 0
print(book.created_at); // 1970-01-01 00:00:00.000
if a value is an enum, the first value of that enum will be used as the default value