Skip to content

feat: add typed table access with PostgrestTable and TableColumn#1634

Draft
spydon wants to merge 6 commits into
mainfrom
feat/typed-table-access
Draft

feat: add typed table access with PostgrestTable and TableColumn#1634
spydon wants to merge 6 commits into
mainfrom
feat/typed-table-access

Conversation

@spydon

@spydon spydon commented Jul 22, 2026

Copy link
Copy Markdown
Contributor

What kind of change does this PR introduce?

Feature (draft for design discussion). This is layer 1 of a plan to make query results type-safe so users never have to handle Map<String, dynamic>. It adds the runtime primitives to postgrest and supabase; a schema code generator that emits row extension types, table definitions and column tokens is a possible follow-up (layer 2), but everything here is also usable hand-written.

What is the current behavior?

Every query resolves to PostgrestList/PostgrestMap (raw Map<String, dynamic> shapes). The only typing affordance is withConverter, which can only be applied at the end of a chain, and filters, stream(), and mutations are stringly typed throughout.

What is the new behavior?

A typed, opt-in parallel surface next to the existing string-based API (which is unchanged):

extension type Book(Map<String, dynamic> json) {
  int get id => json['id'] as int;
  String get title => json['title'] as String;
}

class Books {
  static const table = PostgrestTable('books', Book.new);
  static const id = TableColumn<int>('id');
  static const title = TableColumn<String>('title');
}

final List<Book> books = await supabase
    .table(Books.table)
    .select()
    .where(Books.id.gt(10))
    .where(Books.title.like('%Dart%'))
    .order(Books.title, ascending: true);

final Book book =
    await supabase.table(Books.table).insert({'title': 'foo'}).select().single();

supabase
    .table(Books.table)
    .stream(primaryKey: [Books.id])
    .filter(Books.status.eq(true))
    .listen((List<Book> books) { /* ... */ });
  • PostgrestTable<Row> describes a table plus its row converter; client.table(...) (on PostgrestClient, SupabaseClient and SupabaseQuerySchema) is the typed counterpart of from(...).
  • TableColumn<Value> carries the column name and value type. Filter methods live on the column (Books.id.eq(5)), because that is the only shape where Dart inference actually rejects a wrong value type; builder.eq(column, value) style would silently infer the common supertype. Text-only filters (like, textSearch, regex) are an extension on TableColumn<String>.
  • where(...) applies a ColumnFilter (chain for AND), whereAny([...]) builds an or=(...) group with proper quoting, and .not() negates a filter. ColumnFilter is a sealed hierarchy with one class per operator (EqFilter, InListFilter, LikeFilter, RangeLtFilter, ..., grouped under sealed parents like ComparisonFilter), so consumers switch on the filter type itself exhaustively; operator strings only appear at the URL boundary.
  • The typed builders (PostgrestTypedQueryBuilderPostgrestTypedFilterBuilderPostgrestTypedTransformBuilder) are thin wrappers around the untyped ones, so all request building, retry, isolate decoding and error handling stays in one place. single(), maybeSingle() and count() keep the row type (Row, Row?, PostgrestResponse<List<Row>>).
  • stream() gets a typed counterpart emitting List<Row> with typed primary keys and filters (named filter because Stream.where already exists).

Extension types over the decoded JSON map are the recommended row representation: conversion is a cast (no per-row parse cost), partial selects and embedded relations work naturally, and unknown columns are ignored. Regular data classes with fromJson work as well.

Deliberately not included yet, to keep the surface reviewable: typed csv/geojson/explain/head/dryRun passthroughs, typed rpc, typed Insert/Update value types (planned as generated companion types in layer 2), and typed OR composition beyond whereAny. Dropping to from() covers all of these today.

Additional context

  • All new symbols are registered in sdk-compliance.yaml; the symbol, drift and schema checks from supabase/sdk pass locally.
  • New unit tests run fully mocked (no PostgREST/realtime infra needed): packages/postgrest/test/typed_query_test.dart verifies each ColumnFilter builds the same URL as its untyped counterpart, and packages/supabase/test/mock_test.dart covers typed select and typed streams.

Linear: SDK-1361

@coderabbitai

coderabbitai Bot commented Jul 22, 2026

Copy link
Copy Markdown
Contributor

Important

Review skipped

Draft detected.

Please check the settings in the CodeRabbit UI or the .coderabbit.yaml file in this repository. To trigger a single review, invoke the @coderabbitai review command.

⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro Plus

Run ID: 3ccdd2d8-add6-4a5b-8bb3-5414ccbb34e0

You can disable this status message by setting the reviews.review_status to false in the CodeRabbit configuration file.

Use the checkbox below for a quick retry:

  • 🔍 Trigger review
✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch feat/typed-table-access

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@spydon
spydon force-pushed the feat/typed-table-access branch from 17d12c1 to 6a64143 Compare July 23, 2026 11:48
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant