suparepo 1.19.0
suparepo: ^1.19.0 copied to clipboard
Generate repository/data access layer code from Supabase database schema. Automatically creates CRUD operations, queries, and type-safe API clients.
suparepo #
Generate repository, RPC client, and Edge Function client code from Supabase automatically.
Features #
- Repository generation — CRUD operations (getAll, getById, create, update, delete), pagination, count, relation queries
- RPC client generation — Type-safe Dart methods from Supabase SQL functions (auto-detected via OpenAPI spec, with
pg_proccatalog correction for accurate return types) - Edge Function client generation — Typed or untyped clients from local
supabase/functions/directory, with automatic TypeScript type inference - Type-safe when used with supafreeze models
Installation #
dependencies:
suparepo: ^1.8.0
Quick Start #
1. Create Configuration #
Create suparepo.yaml in your project root:
url: ${SUPABASE_DATA_API_URL}
secret_key: ${SUPABASE_SECRET_KEY}
output: lib/repositories
# Optional: Link to model classes (generated by supafreeze)
# Use model_import_path for a barrel file import:
model_import_path: package:myapp/models/models.dart
# Or use model_import_prefix for individual file imports (recommended):
# model_import_prefix: package:myapp/
# Optional: Generate barrel file (default: false)
generate_barrel: false
# Optional: Generate Riverpod providers (default: false)
generate_providers: true
# Optional: Filter tables
include:
- users
- posts
2. Set Environment Variables #
Create .env file:
SUPABASE_DATA_API_URL=https://your-project.supabase.co
SUPABASE_SECRET_KEY=your-service-role-key
3. Run Generator #
dart run suparepo
CLI Options #
dart run suparepo # Generate all enabled targets
dart run suparepo --repo # Generate table repositories only
dart run suparepo --rpc # Generate RPC client only
dart run suparepo --edge # Generate Edge Function client only
dart run suparepo --force # Force regenerate all
RPC Client Generation #
Automatically generates type-safe Dart methods for your Supabase SQL functions (RPC).
Configuration #
rpc:
enabled: true
output: lib/repositories/rpc_client.dart # optional
include:
- get_user_posts
- search_users
exclude:
- internal_cleanup
Generated Code Example #
For a SQL function get_user_posts(user_id uuid) returning setof json:
class SupabaseRpcClient {
final SupabaseClient _client;
const SupabaseRpcClient(this._client);
Future<List<Map<String, dynamic>>> getUserPosts({
required String userId,
}) async {
final response = await _client.rpc('get_user_posts', params: {
'user_id': userId,
});
return (response as List).cast<Map<String, dynamic>>();
}
}
Return Type Correction #
PostgREST's OpenAPI spec does not always return accurate type information for RPC functions with scalar return values (boolean, integer, text, etc.) or RETURNS TABLE(...) functions. As a result, methods that should be Future<bool>, Future<String>, or Future<List<Map<String, dynamic>>> may be generated as Future<void>.
suparepo resolves return types in the following priority order:
1. YAML return_types (highest priority, manual)
↓ not defined
2. pg_proc auto-correction (when execute_sql exists)
↓ execute_sql not available
3. OpenAPI spec (fallback, may produce void)
Option 1: Manual override with return_types (recommended)
The simplest approach. Specify return types directly in suparepo.yaml:
rpc:
enabled: true
return_types:
get_my_invite_code: text
is_active_user: bool
count_items: int4
execute_exchange_atomic: void
get_favorite_products: setof jsonb
- Values are PostgreSQL type names (
text,bool,int4,int8,jsonb,uuid, etc.) - Add
setofprefix to generate a method returningList<T> - These overrides take the highest priority over all other detection methods
- No need to create an
execute_sqlfunction — this is the easiest option
Type mapping reference:
| PostgreSQL type | Dart type |
|---|---|
text, varchar |
String |
bool |
bool |
int4, int8 |
int |
float4, float8 |
double |
jsonb, json |
Map<String, dynamic> |
uuid |
String |
timestamptz |
DateTime |
void |
void |
Option 2: Automatic correction via execute_sql
By creating an execute_sql RPC function in your Supabase project, suparepo can query the PostgreSQL pg_proc catalog directly to auto-detect accurate return types for all RPC functions.
Why is this needed? PostgREST's OpenAPI spec does not always report RPC return types accurately. The execute_sql function allows suparepo to run a SQL query against pg_proc (PostgreSQL's internal function definition table) to retrieve the correct type information.
Run the following in Supabase SQL Editor:
CREATE OR REPLACE FUNCTION execute_sql(query text)
RETURNS json
LANGUAGE plpgsql
SECURITY DEFINER
AS $$
DECLARE
result json;
BEGIN
EXECUTE query INTO result;
RETURN result;
END;
$$;
Warning: This function runs with
SECURITY DEFINERprivileges. Ensure it is only callable with the service_role key (which suparepo uses viasecret_key). Review your RLS policies and API exposure settings to prevent unauthorized access.
Once created, no additional configuration is needed — suparepo automatically detects execute_sql and applies corrections. Functions using RETURNS TABLE(...) are automatically generated as Future<List<Map<String, dynamic>>>. If execute_sql does not exist, no error is raised and the OpenAPI spec results are used as-is.
Using both return_types and execute_sql: When both are configured, functions listed in return_types use the YAML values, while all other functions are corrected via execute_sql.
Freezed Result Model Generation #
For RETURNS TABLE(...) functions, suparepo can automatically generate Freezed result model classes instead of returning List<Map<String, dynamic>>.
Configuration:
rpc:
enabled: true
generate_result_models: true
result_models_output: lib/models/rpc # optional (default: same directory as rpc client)
Example: For a SQL function:
CREATE FUNCTION get_my_invite_code(user_id uuid)
RETURNS TABLE(code text, max_invites int4, is_active bool)
suparepo generates a Freezed model (get_my_invite_code_result.dart):
@freezed
abstract class GetMyInviteCodeResult with _$GetMyInviteCodeResult {
const factory GetMyInviteCodeResult({
required String code,
required int maxInvites,
required bool isActive,
}) = _GetMyInviteCodeResult;
factory GetMyInviteCodeResult.fromRow(Map<String, dynamic> row) =>
GetMyInviteCodeResult(
code: row['code'] as String,
maxInvites: row['max_invites'] as int,
isActive: row['is_active'] as bool,
);
}
And the RPC client method returns the typed model:
Future<List<GetMyInviteCodeResult>> getMyInviteCode({
required String userId,
}) async {
final response = await _client.rpc<List<dynamic>>('get_my_invite_code',
params: {'user_id': userId});
return response.cast<Map<String, dynamic>>()
.map(GetMyInviteCodeResult.fromRow).toList();
}
Note: Run
build_runnerafter generation to create the.freezed.dartpart files. Requiresexecute_sqlRPC function for TABLE column detection.
Automatic JSON Schema Detection (RETURNS json/jsonb) #
For functions that use RETURNS json or RETURNS jsonb, suparepo automatically parses json_build_object() / jsonb_build_object() calls in the function body to detect field names and infer types from variable declarations.
No configuration needed — just enable generate_result_models:
rpc:
enabled: true
generate_result_models: true
Example: For a SQL function:
CREATE FUNCTION get_membership_rank_info(p_user_id uuid)
RETURNS json AS $$
DECLARE
v_rank text;
v_upload_days int4;
v_is_active bool;
BEGIN
-- ... logic ...
RETURN json_build_object(
'rank', v_rank,
'upload_days', v_upload_days,
'is_active', v_is_active
);
END;
$$ LANGUAGE plpgsql;
suparepo automatically detects the fields (rank: text, upload_days: int4, is_active: bool) and generates the same Freezed model as RETURNS TABLE functions.
Supported patterns:
- Variable references with DECLARE types:
'key', v_variable - Type cast expressions:
'key', expr::int4 json_build_object()andjsonb_build_object()
Note: Requires
execute_sqlRPC function forpg_proc.prosrcaccess. If auto-detection fails or the function doesn't usejson_build_object, use YAML-definedresult_modelsas a fallback.
Error Code Sealed Class Generation #
For RETURNS TABLE(success bool, error text) functions, suparepo automatically detects error code string literals from the PL/pgSQL function body and generates a Freezed sealed class.
Example SQL:
CREATE FUNCTION execute_exchange_atomic(p_mst_id int)
RETURNS TABLE(success bool, error text) AS $$
BEGIN
IF check_daily_limit() THEN
return query select false, 'daily_limit_exceeded'::text;
END IF;
IF check_balance() THEN
return query select false, 'insufficient_balance'::text;
END IF;
return query select true, ''::text;
END;
$$ LANGUAGE plpgsql;
Generated error class (execute_exchange_atomic_error.dart):
@freezed
sealed class ExecuteExchangeAtomicError
with _$ExecuteExchangeAtomicError {
const ExecuteExchangeAtomicError._();
const factory ExecuteExchangeAtomicError.dailyLimitExceeded()
= ExecuteExchangeAtomicErrorDailyLimitExceeded;
const factory ExecuteExchangeAtomicError.insufficientBalance()
= ExecuteExchangeAtomicErrorInsufficientBalance;
const factory ExecuteExchangeAtomicError.unknown({
required String code,
}) = ExecuteExchangeAtomicErrorUnknown;
factory ExecuteExchangeAtomicError.fromErrorCode(
String code) { ... }
}
Generated result model uses the error type:
@freezed
abstract class ExecuteExchangeAtomicResult ... {
const factory ExecuteExchangeAtomicResult({
required bool success,
required ExecuteExchangeAtomicError error, // typed!
}) = _ExecuteExchangeAtomicResult;
}
Supported PL/pgSQL patterns:
return query select false, 'error_code'::text;error := 'error_code';
Note: Only functions with an
error text(orerror_code text) column inRETURNS TABLEare analyzed. Requiresexecute_sqlRPC function.
YAML-Defined Result Models (Manual Override) #
For functions where auto-detection doesn't work (e.g. no json_build_object, or you want to override the detected schema), you can define the column schema in suparepo.yaml:
rpc:
enabled: true
generate_result_models: true
result_models:
get_membership_rank_info:
rank: { type: text }
upload_days: { type: int4 }
is_active: { type: bool }
get_user_profile:
name: text # shorthand syntax
avatar_url: text
Example: For a SQL function:
CREATE FUNCTION get_membership_rank_info(p_user_id uuid)
RETURNS json AS $$
BEGIN
RETURN json_build_object(
'rank', v_rank,
'upload_days', v_upload_days,
'is_active', v_is_active
);
END;
$$ LANGUAGE plpgsql;
suparepo generates:
// get_membership_rank_info_result.dart
@freezed
abstract class GetMembershipRankInfoResult
with _$GetMembershipRankInfoResult {
const factory GetMembershipRankInfoResult({
required String rank,
required int uploadDays,
required bool isActive,
}) = _GetMembershipRankInfoResult;
factory GetMembershipRankInfoResult.fromRow(
Map<String, dynamic> row,
) => GetMembershipRankInfoResult(
rank: row['rank'] as String,
uploadDays: row['upload_days'] as int,
isActive: row['is_active'] as bool,
);
}
And the RPC client returns a single typed object:
Future<GetMembershipRankInfoResult> getMembershipRankInfo({
required String pUserId,
}) async {
final response = await _client.rpc<Map<String, dynamic>>(
'get_membership_rank_info',
params: {'p_user_id': pUserId},
);
return GetMembershipRankInfoResult.fromRow(response);
}
Note: YAML
result_modelsdefinitions take precedence over auto-detectedRETURNS TABLEcolumns, allowing you to override the schema if needed.
Edge Function Client Generation #
Generates client code for your Supabase Edge Functions by scanning the local supabase/functions/ directory.
Automatic TypeScript Type Inference #
suparepo analyzes your Edge Function TypeScript source code and automatically infers request/response types. This eliminates the need to manually define models in YAML.
Supported TypeScript patterns:
// Request: extracted from `body as { ... }` pattern
const { amount, provider } = body as {
amount?: number;
provider?: string;
};
// Required detection: inferred from validation if-statements
if (!amount || !provider) {
return new Response(JSON.stringify({ error: "Missing" }), { status: 400 });
}
// Response: extracted from success response (status 2xx) JSON.stringify()
return new Response(
JSON.stringify({ exchange_id: data.id, message: "OK" }),
{ status: 200, headers },
);
Type mapping:
| TypeScript | Dart |
|---|---|
string |
String |
number |
int |
boolean |
bool |
Required/optional detection:
!fieldcheck present → requiredtypeof field !== "type"check present → required?suffix with no validation → optional
Configuration #
Minimal configuration (types are auto-detected from TypeScript):
edge_functions:
enabled: true
functions_path: supabase/functions
Full configuration:
edge_functions:
enabled: true
functions_path: supabase/functions # default
output: lib/repositories/edge_function_client.dart # optional
auto_detect_types: true # Auto-detect types from TypeScript (default: true)
flatten_request_params: true # Expand request fields into named params (default: false)
include:
- send-email
exclude:
- hello
# Functions with YAML model definitions take precedence over auto-detection
models:
send-email:
request:
to: { type: text, required: true }
subject: { type: text, required: true }
body_html: { type: text }
response:
success: { type: bool, required: true }
message_id: { type: text }
Note:
auto_detect_typesis enabled by default. Functions with YAMLmodelsdefinitions take precedence; only undefined functions are inferred from TypeScript. Setauto_detect_types: falseto disable.
Generated Code — Without Type Definitions #
When auto_detect_types: false and no models defined:
class SupabaseEdgeFunctionClient {
final SupabaseClient _client;
const SupabaseEdgeFunctionClient(this._client);
Future<FunctionResponse> sendEmail({
Map<String, dynamic>? body,
Map<String, String>? headers,
}) async {
return await _client.functions.invoke(
'send-email', body: body, headers: headers,
);
}
}
Generated Code — With Type Definitions #
When types are auto-detected or defined via YAML, typed request/response classes are generated:
class SendEmailRequest {
final String to;
final String subject;
final String? bodyHtml;
const SendEmailRequest({
required this.to,
required this.subject,
this.bodyHtml,
});
Map<String, dynamic> toJson() => {
'to': to,
'subject': subject,
if (bodyHtml != null) 'body_html': bodyHtml,
};
}
class SendEmailResponse {
final bool success;
final String? messageId;
const SendEmailResponse({required this.success, this.messageId});
factory SendEmailResponse.fromJson(Map<String, dynamic> json) {
return SendEmailResponse(
success: json['success'] as bool,
messageId: json['message_id'] as String?,
);
}
}
Flattened Request Parameters (flatten_request_params) #
By default, a typed method takes a single request: wrapper object. Callers
construct the request model themselves:
// flatten_request_params: false (default)
await edgeFunctionClient.sendEmail(
request: SendEmailRequest(to: 'a@example.com', subject: 'Hi'),
);
Set flatten_request_params: true to expand the request fields into named
method parameters instead. The JSON body is built inside the generated
method, so callers never hardcode JSON string keys:
// flatten_request_params: true
await edgeFunctionClient.sendEmail(
to: 'a@example.com',
subject: 'Hi',
);
The generated method builds the body itself:
Future<SendEmailResponse> sendEmail({
required String to,
required String subject,
String? bodyHtml,
Map<String, String>? headers,
}) async {
final response = await _client.functions.invoke(
'send-email',
body: {
'to': to,
'subject': subject,
if (bodyHtml != null) 'body_html': bodyHtml,
},
headers: headers,
);
// ...
}
Note: Requires a request model (auto-detected from TypeScript or defined via YAML
models). TheSendEmailRequestclass is still generated for backward compatibility. Required fields are emitted before optional ones to satisfy Dart's parameter ordering.
Error Type Generation (Freezed sealed class) #
suparepo automatically detects error responses from your Edge Function TypeScript source and generates Freezed sealed classes for type-safe error handling.
Detected TypeScript pattern:
// Edge Function: create-order
if (!hasStock) {
return new Response(
JSON.stringify({ error: "out_of_stock", message: "Item is out of stock" }),
{ status: 400, headers },
);
}
if (duplicateOrder) {
return new Response(
JSON.stringify({ error: "duplicate_order", message: "Already ordered" }),
{ status: 409, headers },
);
}
- Scans
new Response(...)blocks with status 4xx/5xx - Extracts
errorfield string literals insnake_caseformat - Skips generic messages (e.g.,
"Missing","Bad request")
Generated code (create_order_error.dart):
@freezed
sealed class CreateOrderError with _$CreateOrderError {
const CreateOrderError._();
const factory CreateOrderError.outOfStock({
required int status,
required String message,
}) = CreateOrderErrorOutOfStock;
const factory CreateOrderError.duplicateOrder({
required int status,
required String message,
}) = CreateOrderErrorDuplicateOrder;
const factory CreateOrderError.unknown({
required int status,
required String errorCode,
required String message,
}) = CreateOrderErrorUnknown;
factory CreateOrderError.fromFunctionException(
FunctionException e,
) {
final body = jsonDecode(e.details as String)
as Map<String, dynamic>;
final code = body['error'] as String? ?? '';
final msg = body['message'] as String? ?? code;
return switch (code) {
'out_of_stock' => CreateOrderError.outOfStock(
status: e.status, message: msg),
'duplicate_order' => CreateOrderError.duplicateOrder(
status: e.status, message: msg),
_ => CreateOrderError.unknown(
status: e.status, errorCode: code, message: msg),
};
}
}
Usage in your app:
try {
await edgeFunctionClient.createOrder(request: req);
} on FunctionException catch (e) {
final error = CreateOrderError.fromFunctionException(e);
switch (error) {
case CreateOrderErrorOutOfStock(:final message):
showSnackBar(message);
case CreateOrderErrorDuplicateOrder():
showSnackBar('Already ordered');
case CreateOrderErrorUnknown(:final errorCode):
showSnackBar('Error: $errorCode');
}
}
Error class files are generated alongside the Edge Function client in the same directory. Run build_runner to generate the .freezed.dart part files.
Repository Generation #
Generated Code Example #
For a users table with columns id, email, name:
class UsersRepository {
final SupabaseClient _client;
UsersRepository(this._client);
Future<List<User>> getAll() async {
final response = await _client.from('users').select();
return response.map((e) => User.fromJson(e)).toList();
}
Future<User?> getById(String id) async {
final response = await _client
.from('users')
.select()
.eq('id', id)
.maybeSingle();
return response != null ? User.fromJson(response) : null;
}
Future<User> create(User data) async { ... }
Future<User> update(String id, User data) async { ... }
Future<void> delete(String id) async { ... }
Future<int> count() async { ... }
Future<List<User>> paginate({int page = 1, int perPage = 20}) async { ... }
}
Fake Repository Generation #
When generate_fakes: true is set, suparepo emits a sibling {table}_repository.fake.dart file alongside each real repository:
output: lib/repositories
generate_fakes: true
The generated Fake{Table}Repository class implements the real repository and backs CRUD with an in-memory Map<dynamic, Model> keyed by primary key:
class FakeUsersRepository implements UsersRepository {
final Map<dynamic, User> store;
FakeUsersRepository({Map<dynamic, User>? initial})
: store = {...?initial};
void seed(Iterable<User> records) { /* ... */ }
@override
Future<List<User>> getAll() async => store.values.toList();
@override
Future<User?> getById(String id) async => store[id];
// ... create / update / delete / count / paginate ...
// Relation methods fall back to getAll() (relations are not embedded).
@override
Future<List<User>> getAllWithCompany() => getAll();
// Custom methods (from .custom.dart) are stubbed — override in tests.
@override
Future<User?> getActive()
=> throw UnimplementedError(
'FakeUsersRepository.getActive is not faked. '
'Override in a subclass to provide test behavior.',
);
}
Use it in tests by injecting the fake via Riverpod overrides:
final fake = FakeUsersRepository()..seed([alice, bob]);
final container = ProviderContainer(overrides: [
usersRepositoryProvider.overrideWith((ref) => fake),
]);
Custom methods and relation methods are intentionally stubbed because their semantics (.custom.dart SQL, Supabase relation embeds) can't be replicated in memory — subclass the fake in your test file to provide specific behavior when needed.
Riverpod Provider Generation #
When generate_providers: true is set, each repository and RPC client gets a @Riverpod(keepAlive: true) provider:
@Riverpod(keepAlive: true)
UsersRepository usersRepository(Ref ref) {
final client = ref.watch(supabaseClientProvider);
return UsersRepository(client);
}
A supabase_client_provider.dart is also generated, which must be overridden in your ProviderScope:
@Riverpod(keepAlive: true)
SupabaseClient supabaseClient(Ref ref) {
throw UnimplementedError(
'supabaseClientProvider must be overridden in ProviderScope.',
);
}
Custom Provider Output Path
By default, supabase_client_provider.dart is generated in the same directory as repositories. To output it to a different location (e.g., a Gateway package), use client_provider_output and client_provider_import:
generate_providers: true
client_provider_output: ../gateway/lib/supabase/supabase_client_provider.dart
client_provider_import: package:gateway/supabase/supabase_client_provider.dart
client_provider_output— File path wheresupabase_client_provider.dartis writtenclient_provider_import— Import path used in generated repository/RPC code to reference the provider
Model Import Options #
There are two ways to link generated repositories to supafreeze models:
Barrel file import (model_import_path):
model_import_path: package:myapp/models/models.dart
All repositories import from a single barrel file.
Individual file import (model_import_prefix, recommended):
model_import_prefix: package:myapp/
Each repository imports its own model file (e.g., package:myapp/users.supafreeze.dart). Takes precedence over model_import_path.
Full Configuration Reference #
url: ${SUPABASE_DATA_API_URL}
secret_key: ${SUPABASE_SECRET_KEY}
output: lib/repositories
schema: public
fetch: always # always | if_no_cache | never
generate_barrel: false # Generate barrel file for repositories (default: false)
generate_fakes: false # Generate in-memory *.fake.dart files for tests (default: false)
generate_providers: true # Generate Riverpod providers (default: false)
client_provider_output: ../gateway/lib/supabase/supabase_client_provider.dart # Custom output path
client_provider_import: package:gateway/supabase/supabase_client_provider.dart # Custom import path
model_import_path: package:myapp/models/models.dart # Barrel file import
model_import_prefix: package:myapp/ # Individual file import (takes precedence)
supabase_import: package:supabase_flutter/supabase_flutter.dart # or package:supabase/supabase.dart for pure Dart
# Table filter (for repository generation)
include: [users, posts]
# exclude: [_migrations]
# RPC function client
rpc:
enabled: true
output: lib/repositories/rpc_client.dart
include: [get_user_posts]
exclude: [internal_cleanup]
return_types: # Manual return type overrides (see "Return Type Correction")
get_invite_code: text
is_active: bool
get_items: setof jsonb
generate_result_models: true # Generate Freezed result models for RETURNS TABLE functions (default: false)
result_models_output: lib/models/rpc # Custom output directory for result models (optional)
result_models: # YAML-defined schemas for RETURNS json/jsonb functions
get_membership_rank_info:
rank: { type: text }
upload_days: { type: int4 }
is_active: { type: bool }
# Edge Function client
edge_functions:
enabled: true
output: lib/repositories/edge_function_client.dart
functions_path: supabase/functions
auto_detect_types: true # Auto-detect types from TypeScript (default: true)
include: [send-email]
# exclude: [hello]
# Functions with YAML models take precedence; others are auto-detected from TS
models:
send-email:
request:
to: { type: text, required: true }
subject: { type: text, required: true }
response:
success: { type: bool, required: true }
Using with supafreeze #
For the best experience, use suparepo together with supafreeze:
- Generate models with supafreeze
- Set
model_import_prefix(ormodel_import_path) in suparepo.yaml to point to your models - Optionally enable
generate_providers: truefor Riverpod DI - Generate repositories with suparepo
This gives you fully type-safe repositories with Freezed models and optional Riverpod providers.
License #
MIT