From 280c870c8a02b981be17253387639ded17e179f7 Mon Sep 17 00:00:00 2001 From: Lukas Klingsbo Date: Thu, 23 Jul 2026 09:13:25 +0200 Subject: [PATCH 1/8] feat: add supabase_typegen package generating typed table definitions --- .sdk-parse-ignore | 4 + packages/supabase_typegen/README.md | 53 ++- .../bin/supabase_typegen.dart | 113 +++++ .../lib/src/dart_generator.dart | 413 ++++++++++++++++++ .../supabase_typegen/lib/src/identifiers.dart | 132 ++++++ .../lib/src/openapi_parser.dart | 105 +++++ .../lib/src/schema_description.dart | 117 +++++ .../lib/supabase_typegen.dart | 12 +- packages/supabase_typegen/pubspec.yaml | 9 + .../test/dart_generator_test.dart | 53 +++ .../test/fixtures/openapi.json | 111 +++++ .../test/generated_schema_behavior_test.dart | 116 +++++ .../test/goldens/supabase_schema.dart | 208 +++++++++ .../test/identifiers_test.dart | 40 ++ .../test/openapi_parser_test.dart | 81 ++++ .../test/supabase_typegen_test.dart | 7 - .../tool/regenerate_goldens.dart | 17 + 17 files changed, 1573 insertions(+), 18 deletions(-) create mode 100644 packages/supabase_typegen/bin/supabase_typegen.dart create mode 100644 packages/supabase_typegen/lib/src/dart_generator.dart create mode 100644 packages/supabase_typegen/lib/src/identifiers.dart create mode 100644 packages/supabase_typegen/lib/src/openapi_parser.dart create mode 100644 packages/supabase_typegen/lib/src/schema_description.dart create mode 100644 packages/supabase_typegen/test/dart_generator_test.dart create mode 100644 packages/supabase_typegen/test/fixtures/openapi.json create mode 100644 packages/supabase_typegen/test/generated_schema_behavior_test.dart create mode 100644 packages/supabase_typegen/test/goldens/supabase_schema.dart create mode 100644 packages/supabase_typegen/test/identifiers_test.dart create mode 100644 packages/supabase_typegen/test/openapi_parser_test.dart delete mode 100644 packages/supabase_typegen/test/supabase_typegen_test.dart create mode 100644 packages/supabase_typegen/tool/regenerate_goldens.dart diff --git a/.sdk-parse-ignore b/.sdk-parse-ignore index fe7ff1295..8e555b5ad 100644 --- a/.sdk-parse-ignore +++ b/.sdk-parse-ignore @@ -21,3 +21,7 @@ packages/supabase_typegen/ # The examples are standalone demo apps, not part of the published SDK, so their # public classes are not capability-matrix symbols. examples/ + +# supabase_typegen is a development-time code generator invoked through its CLI; +# its library API is tool internals, not SDK client surface. +packages/supabase_typegen/ diff --git a/packages/supabase_typegen/README.md b/packages/supabase_typegen/README.md index 103bf11e5..6e1decb3a 100644 --- a/packages/supabase_typegen/README.md +++ b/packages/supabase_typegen/README.md @@ -1,10 +1,51 @@ # supabase_typegen -> [!WARNING] -> This is a placeholder release that reserves the package name on pub.dev. The -> code generator is still under development and this version does nothing yet. +Generates typed Supabase table definitions from your database schema, so +query results never expose raw `Map` data. -A command-line code generator that turns a Supabase database schema into typed -Dart table definitions for use with the Supabase client packages. +For every table the generator emits: -The generator implementation will land in a future release. +- a zero-cost row extension type over the decoded JSON map with typed getters, +- `Insert` and `Update` value types that enforce required columns at the + construction site, +- a `PostgrestTable` definition and `TableColumn` tokens for compile-time + checked filters, +- Dart enums for Postgres enums, with wire-name mapping. + +## Usage + +```sh +dart run supabase_typegen \ + --url https://your-project.supabase.co \ + --key $SUPABASE_ANON_KEY \ + --output lib/supabase_schema.g.dart +``` + +`--url` and `--key` fall back to the `SUPABASE_URL` and `SUPABASE_ANON_KEY` +environment variables. Use `--schema` to generate for a schema other than +`public`, and `--import` to change which library the generated file imports +`PostgrestTable` and `TableColumn` from. + +The schema is read from the OpenAPI description that PostgREST serves at the +API root, so the key only needs read access; tables hidden from the key by +row level security settings are not included. + +## Generated code in action + +```dart +final books = await client.table(Books.table) + .select() + .where(Books.mood.eq(Mood.happy)) + .order(Books.createdAt, ascending: false); // List + +await client.table(Books.table).insert( + BooksInsert(title: 'A typed row', tags: ['dart']), +); +``` + +## Known limitations + +The OpenAPI description does not distinguish nullable columns from `NOT NULL` +columns with a database default, so getters for defaulted columns other than +primary keys are conservatively nullable. Foreign key relationship getters +and typed functions (rpc) are not generated yet. diff --git a/packages/supabase_typegen/bin/supabase_typegen.dart b/packages/supabase_typegen/bin/supabase_typegen.dart new file mode 100644 index 000000000..962cbe12d --- /dev/null +++ b/packages/supabase_typegen/bin/supabase_typegen.dart @@ -0,0 +1,113 @@ +import 'dart:convert'; +import 'dart:io'; + +import 'package:args/args.dart'; +import 'package:http/http.dart' as http; +import 'package:supabase_typegen/supabase_typegen.dart'; + +final _argParser = ArgParser() + ..addOption( + 'url', + help: + 'The Supabase project URL, for example https://xyz.supabase.co. ' + 'Falls back to the SUPABASE_URL environment variable.', + ) + ..addOption( + 'key', + help: + 'The API key used to read the schema description. Falls back to ' + 'the SUPABASE_ANON_KEY or SUPABASE_KEY environment variable.', + ) + ..addOption( + 'schema', + defaultsTo: 'public', + help: 'The database schema to generate types for.', + ) + ..addOption( + 'output', + abbr: 'o', + defaultsTo: 'lib/supabase_schema.g.dart', + help: 'Path of the generated Dart file.', + ) + ..addOption( + 'import', + defaultsTo: 'package:postgrest/postgrest.dart', + help: + 'The import the generated file uses for PostgrestTable and ' + 'TableColumn.', + ) + ..addFlag('help', abbr: 'h', negatable: false, help: 'Show this usage.'); + +Future main(List arguments) async { + final ArgResults options; + try { + options = _argParser.parse(arguments); + } on FormatException catch (error) { + stderr + ..writeln(error.message) + ..writeln(_argParser.usage); + return 64; + } + + if (options.flag('help')) { + stdout + ..writeln('Generates typed Supabase table definitions from a schema.') + ..writeln() + ..writeln('Usage: dart run supabase_typegen [options]') + ..writeln(_argParser.usage); + return 0; + } + + final url = options.option('url') ?? Platform.environment['SUPABASE_URL']; + final key = + options.option('key') ?? + Platform.environment['SUPABASE_ANON_KEY'] ?? + Platform.environment['SUPABASE_KEY']; + if (url == null || key == null) { + stderr.writeln( + 'Both --url and --key are required, either as options or through the ' + 'SUPABASE_URL and SUPABASE_ANON_KEY environment variables.', + ); + return 64; + } + + final schemaName = options.option('schema')!; + final endpoint = Uri.parse('$url/rest/v1/'); + final http.Response response; + try { + response = await http.get( + endpoint, + headers: { + 'apikey': key, + 'Authorization': 'Bearer $key', + 'Accept-Profile': schemaName, + }, + ); + } on http.ClientException catch (error) { + stderr.writeln('Failed to reach $endpoint: $error'); + return 1; + } + if (response.statusCode != 200) { + stderr.writeln( + 'Failed to fetch the schema description from $endpoint ' + '(HTTP ${response.statusCode}): ${response.body}', + ); + return 1; + } + + final schema = parseOpenApiDocument( + jsonDecode(response.body) as Map, + schemaName: schemaName, + ); + final code = generateDartCode(schema, importUri: options.option('import')!); + + final outputFile = File(options.option('output')!); + outputFile.parent.createSync(recursive: true); + outputFile.writeAsStringSync(code); + + stdout.writeln( + 'Generated ${outputFile.path} with ${schema.tables.length} tables and ' + '${schema.enums.length} enums from schema "$schemaName".', + ); + return 0; +} diff --git a/packages/supabase_typegen/lib/src/dart_generator.dart b/packages/supabase_typegen/lib/src/dart_generator.dart new file mode 100644 index 000000000..12492285b --- /dev/null +++ b/packages/supabase_typegen/lib/src/dart_generator.dart @@ -0,0 +1,413 @@ +import 'package:dart_style/dart_style.dart'; + +import 'identifiers.dart'; +import 'schema_description.dart'; + +enum _Kind { direct, floating, dateTime, list, enumType, json } + +class _Binding { + const _Binding(this.dartType, this.kind); + + /// The non-nullable Dart type of the column. + final String dartType; + final _Kind kind; +} + +const _integerFormats = { + 'smallint', + 'integer', + 'bigint', + 'int2', + 'int4', + 'int8', +}; +const _floatingFormats = {'real', 'double precision', 'float4', 'float8'}; +const _numericFormats = {'numeric', 'decimal'}; +const _dateTimeFormats = { + 'date', + 'timestamp', + 'timestamp without time zone', + 'timestamp with time zone', + 'timestamptz', +}; +const _jsonFormats = {'json', 'jsonb'}; + +/// Generates a Dart source file with typed table definitions, row extension +/// types, insert and update value types, column tokens and Postgres enums for +/// [schema]. +/// +/// The generated code depends only on the library at [importUri], which must +/// export the typed table access API of `package:postgrest` (`PostgrestTable` +/// and `TableColumn`). +String generateDartCode( + SchemaDescription schema, { + String importUri = 'package:postgrest/postgrest.dart', +}) { + final buffer = StringBuffer() + ..writeln('// Generated by supabase_typegen. Do not edit by hand.') + ..writeln('//') + ..writeln('// Source schema: ${schema.schemaName}') + ..writeln() + ..writeln("import '$importUri';") + ..writeln(); + + final typeNames = _TypeNameRegistry(); + final enumTypeNames = {}; + + for (final enumDescription in schema.enums) { + final typeName = typeNames.claim(pascalCase(enumDescription.name)); + enumTypeNames[enumDescription.qualifiedName] = typeName; + _writeEnum(buffer, enumDescription, typeName); + } + + for (final table in schema.tables) { + if (table.columns.isEmpty) continue; + _writeTable(buffer, table, typeNames, enumTypeNames); + } + + return DartFormatter( + languageVersion: DartFormatter.latestLanguageVersion, + ).format(buffer.toString()); +} + +/// Hands out unique top level type names, suffixing `$` on collisions. +class _TypeNameRegistry { + final _used = {}; + + String claim(String name) { + var candidate = name; + while (!_used.add(candidate)) { + candidate = '$candidate\$'; + } + return candidate; + } +} + +void _writeEnum( + StringBuffer buffer, + EnumDescription enumDescription, + String typeName, +) { + final valueNames = _uniqueMemberNames(enumDescription.values); + + buffer + ..writeln('/// Postgres enum `${enumDescription.qualifiedName}`.') + ..writeln('enum $typeName {'); + for (final value in enumDescription.values) { + buffer.writeln(" ${valueNames[value]}(${_stringLiteral(value)}),"); + } + buffer + ..writeln(';') + ..writeln() + ..writeln(' const $typeName(this.wireName);') + ..writeln() + ..writeln(' /// The value as stored in the database.') + ..writeln(' final String wireName;') + ..writeln() + ..writeln(' /// Parses the database representation of the enum.') + ..writeln(' static $typeName fromWire(String wireName) =>') + ..writeln(' values.firstWhere((value) => value.wireName == wireName);') + ..writeln() + ..writeln(' @override') + ..writeln(' String toString() => wireName;') + ..writeln('}') + ..writeln(); +} + +void _writeTable( + StringBuffer buffer, + TableDescription table, + _TypeNameRegistry typeNames, + Map enumTypeNames, +) { + final baseName = pascalCase(table.name); + final rowType = typeNames.claim('${baseName}Row'); + final insertType = typeNames.claim('${baseName}Insert'); + final updateType = typeNames.claim('${baseName}Update'); + final namespaceType = typeNames.claim(baseName); + + final memberNames = _uniqueMemberNames([ + for (final column in table.columns) column.name, + ]); + final bindings = { + for (final column in table.columns) + column.name: _bindingFor(column, enumTypeNames), + }; + + _writeRow(buffer, table, rowType, memberNames, bindings); + _writeValues( + buffer, + table, + insertType, + memberNames, + bindings, + requireRequiredColumns: true, + docLine: + 'Values for inserting a row into `${table.name}`. Columns that are ' + 'nullable, part of a generated primary key, or covered by a database ' + 'default are optional; passing `null` omits the column so the ' + 'database default applies.', + ); + _writeValues( + buffer, + table, + updateType, + memberNames, + bindings, + requireRequiredColumns: false, + docLine: + 'Values for updating rows of `${table.name}`. All columns are ' + 'optional; passing `null` leaves the column unchanged.', + ); + _writeNamespace(buffer, table, namespaceType, rowType, memberNames, bindings); +} + +void _writeRow( + StringBuffer buffer, + TableDescription table, + String rowType, + Map memberNames, + Map bindings, +) { + buffer.writeln('/// A row of the `${table.name}` table.'); + _writeDocComment(buffer, table.comment); + buffer + ..writeln('extension type const $rowType(Map _json)') + ..writeln(' implements Map {'); + for (final column in table.columns) { + final binding = bindings[column.name]!; + _writeDocComment(buffer, column.comment, indent: ' '); + buffer.writeln( + ' ${_getterType(column, binding)} get ${memberNames[column.name]} => ' + '${_readExpression(column, binding)};', + ); + } + buffer + ..writeln('}') + ..writeln(); +} + +void _writeValues( + StringBuffer buffer, + TableDescription table, + String typeName, + Map memberNames, + Map bindings, { + required bool requireRequiredColumns, + required String docLine, +}) { + bool isRequired(ColumnDescription column) => + requireRequiredColumns && column.isRequired; + + buffer + ..writeln('/// $docLine') + ..writeln('extension type const $typeName._(Map _json)') + ..writeln(' implements Map {') + ..writeln(' $typeName({'); + for (final column in table.columns) { + final binding = bindings[column.name]!; + final name = memberNames[column.name]!; + if (isRequired(column)) { + buffer.writeln(' required ${binding.dartType} $name,'); + } else { + buffer.writeln(' ${binding.dartType}? $name,'); + } + } + buffer.writeln(' }) : this._({'); + for (final column in table.columns) { + final binding = bindings[column.name]!; + final name = memberNames[column.name]!; + final key = _stringLiteral(column.name); + if (isRequired(column)) { + buffer.writeln( + ' $key: ${_writeExpression(name, binding, nullable: false)},', + ); + } else { + buffer.writeln( + ' $key: ?${_writeExpression(name, binding, nullable: true)},', + ); + } + } + buffer + ..writeln(' });') + ..writeln('}') + ..writeln(); +} + +void _writeNamespace( + StringBuffer buffer, + TableDescription table, + String namespaceType, + String rowType, + Map memberNames, + Map bindings, +) { + final columnNames = _uniqueMemberNames( + [for (final column in table.columns) column.name], + reserved: {'table'}, + existing: memberNames, + ); + + buffer + ..writeln('/// Typed access to the `${table.name}` table.') + ..writeln('class $namespaceType {') + ..writeln(' const $namespaceType._();') + ..writeln() + ..writeln(' /// Table definition for [PostgrestClient.table].') + ..writeln( + ' static const table = PostgrestTable' + '(${_stringLiteral(table.name)}, $rowType.new);', + ) + ..writeln(); + for (final column in table.columns) { + final binding = bindings[column.name]!; + buffer.writeln( + ' static const ${columnNames[column.name]} = ' + 'TableColumn<${binding.dartType}>(${_stringLiteral(column.name)});', + ); + } + buffer + ..writeln('}') + ..writeln(); +} + +_Binding _bindingFor( + ColumnDescription column, + Map enumTypeNames, +) { + final format = column.postgresFormat; + + final enumTypeName = enumTypeNames[format]; + if (enumTypeName != null) { + return _Binding(enumTypeName, _Kind.enumType); + } + if (format.endsWith('[]')) { + return _Binding( + 'List<${_arrayElementType(column.arrayElementJsonType)}>', + _Kind.list, + ); + } + if (_integerFormats.contains(format)) { + return const _Binding('int', _Kind.direct); + } + if (_floatingFormats.contains(format)) { + return const _Binding('double', _Kind.floating); + } + if (_numericFormats.contains(format)) { + return const _Binding('num', _Kind.direct); + } + if (format == 'boolean') { + return const _Binding('bool', _Kind.direct); + } + if (_dateTimeFormats.contains(format)) { + return const _Binding('DateTime', _Kind.dateTime); + } + if (_jsonFormats.contains(format)) { + return const _Binding('Object', _Kind.json); + } + return switch (column.jsonType) { + 'integer' => const _Binding('int', _Kind.direct), + 'number' => const _Binding('num', _Kind.direct), + 'boolean' => const _Binding('bool', _Kind.direct), + 'string' => const _Binding('String', _Kind.direct), + _ => const _Binding('Object', _Kind.json), + }; +} + +String _arrayElementType(String? elementJsonType) => switch (elementJsonType) { + 'integer' => 'int', + 'number' => 'num', + 'boolean' => 'bool', + 'string' => 'String', + _ => 'Object', +}; + +String _getterType(ColumnDescription column, _Binding binding) { + if (binding.kind == _Kind.json) return 'Object?'; + return column.isNullable ? '${binding.dartType}?' : binding.dartType; +} + +String _readExpression(ColumnDescription column, _Binding binding) { + final access = "_json[${_stringLiteral(column.name)}]"; + final nullable = column.isNullable; + return switch (binding.kind) { + _Kind.direct => '$access as ${binding.dartType}${nullable ? '?' : ''}', + _Kind.floating => + nullable + ? '($access as num?)?.toDouble()' + : '($access as num).toDouble()', + _Kind.list => + nullable + ? '($access as List?)?.cast()' + : '($access as List).cast()', + _Kind.dateTime => + nullable + ? _nullableSwitch(access, 'DateTime.parse(value as String)') + : 'DateTime.parse($access as String)', + _Kind.enumType => + nullable + ? _nullableSwitch( + access, + '${binding.dartType}.fromWire(value as String)', + ) + : '${binding.dartType}.fromWire($access as String)', + _Kind.json => '$access as Object?', + }; +} + +String _nullableSwitch(String access, String conversion) => + 'switch ($access) { null => null, final Object value => $conversion }'; + +String _writeExpression( + String parameterName, + _Binding binding, { + required bool nullable, +}) { + final access = nullable ? '$parameterName?' : parameterName; + return switch (binding.kind) { + _Kind.dateTime => '$access.toIso8601String()', + _Kind.enumType => '$access.wireName', + _Kind.direct || _Kind.floating || _Kind.list || _Kind.json => parameterName, + }; +} + +/// Maps raw database names to unique Dart member identifiers. +/// +/// [reserved] seeds identifiers that must not be produced. When [existing] is +/// given, names are kept identical to it where possible so that, for example, +/// column tokens and row getters share their spelling. +Map _uniqueMemberNames( + List names, { + Set reserved = const {}, + Map? existing, +}) { + final used = {...reserved}; + final result = {}; + for (final name in names) { + var candidate = existing?[name] ?? memberIdentifier(name); + while (!used.add(candidate)) { + candidate = '$candidate\$'; + } + result[name] = candidate; + } + return result; +} + +void _writeDocComment( + StringBuffer buffer, + String? comment, { + String indent = '', +}) { + if (comment == null) return; + for (final line in comment.trim().split('\n')) { + buffer.writeln('$indent/// ${line.trim()}'); + } +} + +String _stringLiteral(String value) { + final escaped = value + .replaceAll(r'\', r'\\') + .replaceAll("'", r"\'") + .replaceAll(r'$', r'\$'); + return "'$escaped'"; +} diff --git a/packages/supabase_typegen/lib/src/identifiers.dart b/packages/supabase_typegen/lib/src/identifiers.dart new file mode 100644 index 000000000..130c435e5 --- /dev/null +++ b/packages/supabase_typegen/lib/src/identifiers.dart @@ -0,0 +1,132 @@ +const _reservedWords = { + 'abstract', + 'as', + 'assert', + 'async', + 'await', + 'base', + 'break', + 'case', + 'catch', + 'class', + 'const', + 'continue', + 'covariant', + 'default', + 'deferred', + 'do', + 'dynamic', + 'else', + 'enum', + 'export', + 'extends', + 'extension', + 'external', + 'factory', + 'false', + 'final', + 'finally', + 'for', + 'get', + 'hide', + 'if', + 'implements', + 'import', + 'in', + 'interface', + 'is', + 'late', + 'library', + 'mixin', + 'new', + 'null', + 'of', + 'on', + 'operator', + 'part', + 'required', + 'rethrow', + 'return', + 'sealed', + 'set', + 'show', + 'static', + 'super', + 'switch', + 'sync', + 'this', + 'throw', + 'true', + 'try', + 'type', + 'typedef', + 'var', + 'void', + 'when', + 'while', + 'with', + 'yield', +}; + +/// Members that already exist on `Map`, which generated row +/// extension types implement, so column getters cannot use these names. +const _mapMembers = { + 'addAll', + 'addEntries', + 'cast', + 'clear', + 'containsKey', + 'containsValue', + 'entries', + 'forEach', + 'hashCode', + 'isEmpty', + 'isNotEmpty', + 'keys', + 'length', + 'map', + 'noSuchMethod', + 'putIfAbsent', + 'remove', + 'removeWhere', + 'runtimeType', + 'toString', + 'update', + 'updateAll', + 'values', +}; + +final _wordSeparator = RegExp('[^a-zA-Z0-9]+'); + +List _words(String name) => + name.split(_wordSeparator).where((word) => word.isNotEmpty).toList(); + +/// Converts [name] to PascalCase, for example `author_stats` to +/// `AuthorStats`. +String pascalCase(String name) { + final words = _words(name); + if (words.isEmpty) return r'$'; + final pascal = [ + for (final word in words) + word[0].toUpperCase() + word.substring(1).toLowerCase(), + ].join(); + return pascal.startsWith(RegExp('[0-9]')) ? '\$$pascal' : pascal; +} + +/// Converts [name] to camelCase, for example `created_at` to `createdAt`. +String camelCase(String name) { + final pascal = pascalCase(name); + return pascal[0].toLowerCase() + pascal.substring(1); +} + +/// Converts [name] to a valid Dart member identifier in camelCase. +/// +/// Reserved words and members that would collide with `Map` +/// get a `$` suffix, for example `class` becomes `class$`. +String memberIdentifier(String name) { + final identifier = camelCase(name); + if (_reservedWords.contains(identifier) || _mapMembers.contains(identifier)) { + return '$identifier\$'; + } + return identifier; +} diff --git a/packages/supabase_typegen/lib/src/openapi_parser.dart b/packages/supabase_typegen/lib/src/openapi_parser.dart new file mode 100644 index 000000000..3a7b838d8 --- /dev/null +++ b/packages/supabase_typegen/lib/src/openapi_parser.dart @@ -0,0 +1,105 @@ +import 'schema_description.dart'; + +final _foreignKeyPattern = RegExp(""); + +/// Parses the OpenAPI (Swagger 2.0) document that PostgREST serves at the +/// API root into a [SchemaDescription]. +/// +/// PostgREST encodes primary keys and foreign keys as `` and +/// `` markers inside column descriptions, and +/// lists `NOT NULL` columns without a database default under `required`. +SchemaDescription parseOpenApiDocument( + Map document, { + String schemaName = 'public', +}) { + final definitions = + document['definitions'] as Map? ?? const {}; + + final tables = []; + final enumsByQualifiedName = {}; + + for (final MapEntry(key: tableName, value: definition) + in definitions.entries) { + definition as Map; + final required = { + ...?(definition['required'] as List?)?.cast(), + }; + final properties = + definition['properties'] as Map? ?? const {}; + + final columns = []; + for (final MapEntry(key: columnName, value: property) + in properties.entries) { + property as Map; + final description = property['description'] as String?; + final format = property['format'] as String? ?? ''; + final enumValues = (property['enum'] as List?)?.cast(); + + if (enumValues != null) { + enumsByQualifiedName.putIfAbsent( + format, + () => EnumDescription(qualifiedName: format, values: enumValues), + ); + } + + final foreignKeyMatch = description == null + ? null + : _foreignKeyPattern.firstMatch(description); + + columns.add( + ColumnDescription( + name: columnName, + postgresFormat: format, + jsonType: property['type'] as String? ?? '', + arrayElementJsonType: + (property['items'] as Map?)?['type'] as String?, + enumValues: enumValues, + isRequired: required.contains(columnName), + isPrimaryKey: description?.contains('') ?? false, + hasDefault: property.containsKey('default'), + comment: _cleanComment(description), + foreignKey: foreignKeyMatch == null + ? null + : ForeignKeyDescription( + table: foreignKeyMatch.group(1)!, + column: foreignKeyMatch.group(2)!, + ), + ), + ); + } + + tables.add( + TableDescription( + name: tableName, + comment: _cleanComment(definition['description'] as String?), + columns: columns, + ), + ); + } + + tables.sort((a, b) => a.name.compareTo(b.name)); + final enums = enumsByQualifiedName.values.toList() + ..sort((a, b) => a.qualifiedName.compareTo(b.qualifiedName)); + + return SchemaDescription( + schemaName: schemaName, + tables: tables, + enums: enums, + ); +} + +/// Strips the PostgREST key markers from a column or table description, +/// keeping only the human written comment. +String? _cleanComment(String? description) { + if (description == null) return null; + final cleaned = description + .replaceAll(_foreignKeyPattern, '') + .replaceAll('', '') + .replaceAll(RegExp(r'Note:\s*'), '') + .replaceAll( + RegExp(r'This is a (Primary|Foreign) Key( to `[^`]+`)?\.'), + '', + ) + .trim(); + return cleaned.isEmpty ? null : cleaned; +} diff --git a/packages/supabase_typegen/lib/src/schema_description.dart b/packages/supabase_typegen/lib/src/schema_description.dart new file mode 100644 index 000000000..f986acae1 --- /dev/null +++ b/packages/supabase_typegen/lib/src/schema_description.dart @@ -0,0 +1,117 @@ +/// Description of a single database schema, the input to the code generator. +class SchemaDescription { + const SchemaDescription({ + required this.schemaName, + required this.tables, + required this.enums, + }); + + /// Name of the database schema, for example `public`. + final String schemaName; + + /// Tables and views of the schema, sorted by name. + final List tables; + + /// Postgres enums referenced by the tables, sorted by name. + final List enums; +} + +/// Description of a table or view. +class TableDescription { + const TableDescription({ + required this.name, + required this.columns, + this.comment, + }); + + /// Name of the table in the database. + final String name; + + /// The table comment, when one is set. + final String? comment; + + /// Columns of the table, in database order. + final List columns; +} + +/// Description of a single table column. +class ColumnDescription { + const ColumnDescription({ + required this.name, + required this.postgresFormat, + required this.jsonType, + required this.isRequired, + required this.isPrimaryKey, + required this.hasDefault, + this.arrayElementJsonType, + this.enumValues, + this.foreignKey, + this.comment, + }); + + /// Name of the column in the database. + final String name; + + /// The Postgres type, for example `bigint`, `text[]` or `public.mood`. + final String postgresFormat; + + /// The JSON schema type, for example `integer` or `string`. + final String jsonType; + + /// The JSON schema type of the array elements for array columns. + final String? arrayElementJsonType; + + /// The values of the Postgres enum for enum columns. + final List? enumValues; + + /// Whether the column is `NOT NULL` without a database default, which makes + /// it required on insert. + final bool isRequired; + + /// Whether the column is part of the primary key. + final bool isPrimaryKey; + + /// Whether the column has a database default. + final bool hasDefault; + + /// The column comment, when one is set. + final String? comment; + + /// The referenced table and column for foreign key columns. + final ForeignKeyDescription? foreignKey; + + /// Whether the column can be `null` in query results. + /// + /// Derived from the OpenAPI description: columns in the `required` list are + /// `NOT NULL`, and primary keys are always `NOT NULL`. Other columns are + /// treated as nullable, which is safe but over-approximates for `NOT NULL` + /// columns that have a database default. + bool get isNullable => !isRequired && !isPrimaryKey; +} + +/// The target of a foreign key column. +class ForeignKeyDescription { + const ForeignKeyDescription({required this.table, required this.column}); + + /// The referenced table. + final String table; + + /// The referenced column. + final String column; +} + +/// Description of a Postgres enum type. +class EnumDescription { + const EnumDescription({required this.qualifiedName, required this.values}); + + /// The schema-qualified name of the enum, for example `public.mood`. + final String qualifiedName; + + /// The values of the enum, in declaration order. + final List values; + + /// The enum name without the schema qualifier. + String get name => qualifiedName.contains('.') + ? qualifiedName.split('.').last + : qualifiedName; +} diff --git a/packages/supabase_typegen/lib/supabase_typegen.dart b/packages/supabase_typegen/lib/supabase_typegen.dart index 4b0d261bf..1373314f0 100644 --- a/packages/supabase_typegen/lib/supabase_typegen.dart +++ b/packages/supabase_typegen/lib/supabase_typegen.dart @@ -1,6 +1,8 @@ -/// Command-line code generator that turns a Supabase database schema into -/// typed Dart table definitions. -/// -/// This is a placeholder release that reserves the package name. The generator -/// implementation is not available yet. +/// Generates typed Supabase table definitions, row extension types and +/// column tokens from a database schema. library; + +export 'src/dart_generator.dart'; +export 'src/identifiers.dart'; +export 'src/openapi_parser.dart'; +export 'src/schema_description.dart'; diff --git a/packages/supabase_typegen/pubspec.yaml b/packages/supabase_typegen/pubspec.yaml index 28d63da94..657ceed93 100644 --- a/packages/supabase_typegen/pubspec.yaml +++ b/packages/supabase_typegen/pubspec.yaml @@ -14,6 +14,15 @@ environment: resolution: workspace +executables: + supabase_typegen: + +dependencies: + args: ^2.7.0 + dart_style: ^3.1.0 + http: ^1.6.0 + dev_dependencies: + postgrest: ^2.9.0 supabase_lints: ^0.1.1 test: ^1.25.0 diff --git a/packages/supabase_typegen/test/dart_generator_test.dart b/packages/supabase_typegen/test/dart_generator_test.dart new file mode 100644 index 000000000..82ddbc115 --- /dev/null +++ b/packages/supabase_typegen/test/dart_generator_test.dart @@ -0,0 +1,53 @@ +import 'dart:convert'; +import 'dart:io'; + +import 'package:supabase_typegen/supabase_typegen.dart'; +import 'package:test/test.dart'; + +final _whitespace = RegExp(r'\s+'); + +/// Collapses whitespace so the comparison is stable across formatter +/// versions; `tool/regenerate_goldens.dart` refreshes the golden. +String _normalize(String code) => code.replaceAll(_whitespace, ' ').trim(); + +void main() { + late SchemaDescription schema; + + setUpAll(() { + final document = + jsonDecode(File('test/fixtures/openapi.json').readAsStringSync()) + as Map; + schema = parseOpenApiDocument(document); + }); + + test('matches the golden output', () { + final golden = File('test/goldens/supabase_schema.dart').readAsStringSync(); + + expect( + _normalize(generateDartCode(schema)), + _normalize(golden), + reason: + 'The generator output changed. Regenerate the golden with ' + '`dart run tool/regenerate_goldens.dart` and review the diff.', + ); + }); + + test('respects a custom import', () { + final code = generateDartCode( + schema, + importUri: 'package:supabase_flutter/supabase_flutter.dart', + ); + + expect( + code, + contains("import 'package:supabase_flutter/supabase_flutter.dart';"), + ); + }); + + test('marks not null columns without default as required on insert', () { + final code = generateDartCode(schema); + + expect(code, contains('required String title')); + expect(code, contains('int? id')); + }); +} diff --git a/packages/supabase_typegen/test/fixtures/openapi.json b/packages/supabase_typegen/test/fixtures/openapi.json new file mode 100644 index 000000000..a64a325ad --- /dev/null +++ b/packages/supabase_typegen/test/fixtures/openapi.json @@ -0,0 +1,111 @@ +{ + "swagger": "2.0", + "info": { + "title": "PostgREST API", + "description": "standard public schema", + "version": "12.2.0" + }, + "definitions": { + "books": { + "description": "Books available in the library", + "required": ["title", "author_id"], + "properties": { + "id": { + "description": "Note:\nThis is a Primary Key.", + "format": "bigint", + "type": "integer", + "default": "nextval('books_id_seq'::regclass)" + }, + "title": { + "format": "text", + "type": "string" + }, + "author_id": { + "description": "Note:\nThis is a Foreign Key to `authors.id`.", + "format": "bigint", + "type": "integer" + }, + "price": { + "format": "numeric", + "type": "number" + }, + "rating": { + "format": "double precision", + "type": "number" + }, + "in_print": { + "format": "boolean", + "type": "boolean", + "default": true + }, + "mood": { + "enum": ["happy", "very happy", "sad"], + "format": "public.mood", + "type": "string" + }, + "tags": { + "format": "text[]", + "type": "array", + "items": { + "type": "string" + } + }, + "page_counts": { + "format": "integer[]", + "type": "array", + "items": { + "type": "integer" + } + }, + "metadata": { + "format": "jsonb" + }, + "cover_uuid": { + "format": "uuid", + "type": "string" + }, + "published_on": { + "format": "date", + "type": "string" + }, + "created_at": { + "description": "When the row was created", + "format": "timestamp with time zone", + "type": "string", + "default": "now()" + } + }, + "type": "object" + }, + "authors": { + "required": ["id", "name"], + "properties": { + "id": { + "description": "Note:\nThis is a Primary Key.", + "format": "bigint", + "type": "integer" + }, + "name": { + "format": "text", + "type": "string" + } + }, + "type": "object" + }, + "author_stats": { + "description": "Aggregated statistics per author", + "properties": { + "author_id": { + "format": "bigint", + "type": "integer" + }, + "book_count": { + "format": "bigint", + "type": "integer" + } + }, + "type": "object" + } + }, + "paths": {} +} diff --git a/packages/supabase_typegen/test/generated_schema_behavior_test.dart b/packages/supabase_typegen/test/generated_schema_behavior_test.dart new file mode 100644 index 000000000..512606b95 --- /dev/null +++ b/packages/supabase_typegen/test/generated_schema_behavior_test.dart @@ -0,0 +1,116 @@ +import 'dart:convert'; + +import 'package:http/http.dart'; +import 'package:postgrest/postgrest.dart'; +import 'package:test/test.dart'; + +import 'goldens/supabase_schema.dart'; + +class MockHttpClient extends BaseClient { + String responseBody = '[]'; + BaseRequest? lastRequest; + String? lastRequestBody; + + @override + Future send(BaseRequest request) async { + lastRequest = request; + lastRequestBody = utf8.decode(await request.finalize().toBytes()); + return StreamedResponse( + Stream.value(utf8.encode(responseBody)), + 200, + headers: {'content-type': 'application/json'}, + request: request, + ); + } +} + +void main() { + late MockHttpClient httpClient; + late PostgrestClient client; + + setUp(() { + httpClient = MockHttpClient(); + client = PostgrestClient( + 'http://localhost/rest/v1', + httpClient: httpClient, + ); + }); + + tearDown(() async { + await client.dispose(); + }); + + test('select returns typed rows with converted values', () async { + httpClient.responseBody = jsonEncode([ + { + 'id': 1, + 'title': 'A typed row', + 'author_id': 7, + 'price': 12.5, + 'rating': 4, + 'mood': 'very happy', + 'tags': ['dart', 'types'], + 'metadata': {'reprint': true}, + 'created_at': '2026-07-23T10:00:00Z', + 'published_on': null, + }, + ]); + + final List books = await client.table(Books.table).select(); + + final book = books.single; + expect(book.id, 1); + expect(book.title, 'A typed row'); + expect(book.rating, 4.0); + expect(book.mood, Mood.veryHappy); + expect(book.tags, ['dart', 'types']); + expect(book.metadata, {'reprint': true}); + expect(book.createdAt, DateTime.utc(2026, 7, 23, 10)); + expect(book.publishedOn == null, isTrue); + }); + + test('enum column tokens filter with the wire name', () async { + await client.table(Books.table).select().where(Books.mood.eq(Mood.happy)); + + expect( + httpClient.lastRequest!.url.queryParameters['mood'], + 'eq.happy', + ); + }); + + test('insert sends converted values and omits absent columns', () async { + httpClient.responseBody = ''; + + await client + .table(Books.table) + .insert( + BooksInsert( + title: 'A typed row', + authorId: 7, + mood: Mood.happy, + createdAt: DateTime.utc(2026, 7, 23, 10), + ), + ); + + final sent = + jsonDecode(httpClient.lastRequestBody!) as Map; + expect(sent, { + 'title': 'A typed row', + 'author_id': 7, + 'mood': 'happy', + 'created_at': '2026-07-23T10:00:00.000Z', + }); + }); + + test('update sends only the provided columns', () async { + httpClient.responseBody = ''; + + await client + .table(Books.table) + .update(BooksUpdate(inPrint: false)) + .where(Books.id.eq(1)); + + expect(jsonDecode(httpClient.lastRequestBody!), {'in_print': false}); + expect(httpClient.lastRequest!.url.queryParameters['id'], 'eq.1'); + }); +} diff --git a/packages/supabase_typegen/test/goldens/supabase_schema.dart b/packages/supabase_typegen/test/goldens/supabase_schema.dart new file mode 100644 index 000000000..d2add44cb --- /dev/null +++ b/packages/supabase_typegen/test/goldens/supabase_schema.dart @@ -0,0 +1,208 @@ +// Generated by supabase_typegen. Do not edit by hand. +// +// Source schema: public + +import 'package:postgrest/postgrest.dart'; + +/// Postgres enum `public.mood`. +enum Mood { + happy('happy'), + veryHappy('very happy'), + sad('sad'); + + const Mood(this.wireName); + + /// The value as stored in the database. + final String wireName; + + /// Parses the database representation of the enum. + static Mood fromWire(String wireName) => + values.firstWhere((value) => value.wireName == wireName); + + @override + String toString() => wireName; +} + +/// A row of the `author_stats` table. +/// Aggregated statistics per author +extension type const AuthorStatsRow(Map _json) + implements Map { + int? get authorId => _json['author_id'] as int?; + int? get bookCount => _json['book_count'] as int?; +} + +/// Values for inserting a row into `author_stats`. Columns that are nullable, part of a generated primary key, or covered by a database default are optional; passing `null` omits the column so the database default applies. +extension type const AuthorStatsInsert._(Map _json) + implements Map { + AuthorStatsInsert({int? authorId, int? bookCount}) + : this._({'author_id': ?authorId, 'book_count': ?bookCount}); +} + +/// Values for updating rows of `author_stats`. All columns are optional; passing `null` leaves the column unchanged. +extension type const AuthorStatsUpdate._(Map _json) + implements Map { + AuthorStatsUpdate({int? authorId, int? bookCount}) + : this._({'author_id': ?authorId, 'book_count': ?bookCount}); +} + +/// Typed access to the `author_stats` table. +class AuthorStats { + const AuthorStats._(); + + /// Table definition for [PostgrestClient.table]. + static const table = PostgrestTable('author_stats', AuthorStatsRow.new); + + static const authorId = TableColumn('author_id'); + static const bookCount = TableColumn('book_count'); +} + +/// A row of the `authors` table. +extension type const AuthorsRow(Map _json) + implements Map { + int get id => _json['id'] as int; + String get name => _json['name'] as String; +} + +/// Values for inserting a row into `authors`. Columns that are nullable, part of a generated primary key, or covered by a database default are optional; passing `null` omits the column so the database default applies. +extension type const AuthorsInsert._(Map _json) + implements Map { + AuthorsInsert({required int id, required String name}) + : this._({'id': id, 'name': name}); +} + +/// Values for updating rows of `authors`. All columns are optional; passing `null` leaves the column unchanged. +extension type const AuthorsUpdate._(Map _json) + implements Map { + AuthorsUpdate({int? id, String? name}) : this._({'id': ?id, 'name': ?name}); +} + +/// Typed access to the `authors` table. +class Authors { + const Authors._(); + + /// Table definition for [PostgrestClient.table]. + static const table = PostgrestTable('authors', AuthorsRow.new); + + static const id = TableColumn('id'); + static const name = TableColumn('name'); +} + +/// A row of the `books` table. +/// Books available in the library +extension type const BooksRow(Map _json) + implements Map { + int get id => _json['id'] as int; + String get title => _json['title'] as String; + int get authorId => _json['author_id'] as int; + num? get price => _json['price'] as num?; + double? get rating => (_json['rating'] as num?)?.toDouble(); + bool? get inPrint => _json['in_print'] as bool?; + Mood? get mood => switch (_json['mood']) { + null => null, + final Object value => Mood.fromWire(value as String), + }; + List? get tags => (_json['tags'] as List?)?.cast(); + List? get pageCounts => (_json['page_counts'] as List?)?.cast(); + Object? get metadata => _json['metadata'] as Object?; + String? get coverUuid => _json['cover_uuid'] as String?; + DateTime? get publishedOn => switch (_json['published_on']) { + null => null, + final Object value => DateTime.parse(value as String), + }; + + /// When the row was created + DateTime? get createdAt => switch (_json['created_at']) { + null => null, + final Object value => DateTime.parse(value as String), + }; +} + +/// Values for inserting a row into `books`. Columns that are nullable, part of a generated primary key, or covered by a database default are optional; passing `null` omits the column so the database default applies. +extension type const BooksInsert._(Map _json) + implements Map { + BooksInsert({ + int? id, + required String title, + required int authorId, + num? price, + double? rating, + bool? inPrint, + Mood? mood, + List? tags, + List? pageCounts, + Object? metadata, + String? coverUuid, + DateTime? publishedOn, + DateTime? createdAt, + }) : this._({ + 'id': ?id, + 'title': title, + 'author_id': authorId, + 'price': ?price, + 'rating': ?rating, + 'in_print': ?inPrint, + 'mood': ?mood?.wireName, + 'tags': ?tags, + 'page_counts': ?pageCounts, + 'metadata': ?metadata, + 'cover_uuid': ?coverUuid, + 'published_on': ?publishedOn?.toIso8601String(), + 'created_at': ?createdAt?.toIso8601String(), + }); +} + +/// Values for updating rows of `books`. All columns are optional; passing `null` leaves the column unchanged. +extension type const BooksUpdate._(Map _json) + implements Map { + BooksUpdate({ + int? id, + String? title, + int? authorId, + num? price, + double? rating, + bool? inPrint, + Mood? mood, + List? tags, + List? pageCounts, + Object? metadata, + String? coverUuid, + DateTime? publishedOn, + DateTime? createdAt, + }) : this._({ + 'id': ?id, + 'title': ?title, + 'author_id': ?authorId, + 'price': ?price, + 'rating': ?rating, + 'in_print': ?inPrint, + 'mood': ?mood?.wireName, + 'tags': ?tags, + 'page_counts': ?pageCounts, + 'metadata': ?metadata, + 'cover_uuid': ?coverUuid, + 'published_on': ?publishedOn?.toIso8601String(), + 'created_at': ?createdAt?.toIso8601String(), + }); +} + +/// Typed access to the `books` table. +class Books { + const Books._(); + + /// Table definition for [PostgrestClient.table]. + static const table = PostgrestTable('books', BooksRow.new); + + static const id = TableColumn('id'); + static const title = TableColumn('title'); + static const authorId = TableColumn('author_id'); + static const price = TableColumn('price'); + static const rating = TableColumn('rating'); + static const inPrint = TableColumn('in_print'); + static const mood = TableColumn('mood'); + static const tags = TableColumn>('tags'); + static const pageCounts = TableColumn>('page_counts'); + static const metadata = TableColumn('metadata'); + static const coverUuid = TableColumn('cover_uuid'); + static const publishedOn = TableColumn('published_on'); + static const createdAt = TableColumn('created_at'); +} diff --git a/packages/supabase_typegen/test/identifiers_test.dart b/packages/supabase_typegen/test/identifiers_test.dart new file mode 100644 index 000000000..6907f96bb --- /dev/null +++ b/packages/supabase_typegen/test/identifiers_test.dart @@ -0,0 +1,40 @@ +import 'package:supabase_typegen/supabase_typegen.dart'; +import 'package:test/test.dart'; + +void main() { + group('pascalCase', () { + test('converts snake case names', () { + expect(pascalCase('author_stats'), 'AuthorStats'); + expect(pascalCase('books'), 'Books'); + expect(pascalCase('user-profiles'), 'UserProfiles'); + }); + + test('prefixes names starting with a digit', () { + expect(pascalCase('2fa_codes'), r'$2faCodes'); + }); + }); + + group('camelCase', () { + test('converts snake case names', () { + expect(camelCase('created_at'), 'createdAt'); + expect(camelCase('id'), 'id'); + }); + }); + + group('memberIdentifier', () { + test('suffixes reserved words', () { + expect(memberIdentifier('class'), r'class$'); + expect(memberIdentifier('in'), r'in$'); + }); + + test('suffixes Map member names', () { + expect(memberIdentifier('length'), r'length$'); + expect(memberIdentifier('keys'), r'keys$'); + }); + + test('keeps regular names untouched', () { + expect(memberIdentifier('title'), 'title'); + expect(memberIdentifier('author_id'), 'authorId'); + }); + }); +} diff --git a/packages/supabase_typegen/test/openapi_parser_test.dart b/packages/supabase_typegen/test/openapi_parser_test.dart new file mode 100644 index 000000000..44f5bf5e3 --- /dev/null +++ b/packages/supabase_typegen/test/openapi_parser_test.dart @@ -0,0 +1,81 @@ +import 'dart:convert'; +import 'dart:io'; + +import 'package:supabase_typegen/supabase_typegen.dart'; +import 'package:test/test.dart'; + +void main() { + late SchemaDescription schema; + + setUpAll(() { + final document = + jsonDecode(File('test/fixtures/openapi.json').readAsStringSync()) + as Map; + schema = parseOpenApiDocument(document); + }); + + test('parses all tables sorted by name', () { + expect(schema.tables.map((table) => table.name), [ + 'author_stats', + 'authors', + 'books', + ]); + }); + + test('parses table comments', () { + final books = schema.tables.singleWhere((table) => table.name == 'books'); + expect(books.comment, 'Books available in the library'); + }); + + test('parses primary keys, requiredness and defaults', () { + final books = schema.tables.singleWhere((table) => table.name == 'books'); + final id = books.columns.singleWhere((column) => column.name == 'id'); + expect(id.isPrimaryKey, isTrue); + expect(id.isRequired, isFalse); + expect(id.hasDefault, isTrue); + expect(id.isNullable, isFalse); + + final title = books.columns.singleWhere((column) => column.name == 'title'); + expect(title.isRequired, isTrue); + expect(title.isNullable, isFalse); + + final price = books.columns.singleWhere((column) => column.name == 'price'); + expect(price.isRequired, isFalse); + expect(price.isNullable, isTrue); + }); + + test('parses foreign keys', () { + final books = schema.tables.singleWhere((table) => table.name == 'books'); + final authorId = books.columns.singleWhere( + (column) => column.name == 'author_id', + ); + expect(authorId.foreignKey?.table, 'authors'); + expect(authorId.foreignKey?.column, 'id'); + }); + + test('collects Postgres enums', () { + expect(schema.enums, hasLength(1)); + final mood = schema.enums.single; + expect(mood.qualifiedName, 'public.mood'); + expect(mood.name, 'mood'); + expect(mood.values, ['happy', 'very happy', 'sad']); + }); + + test('parses array columns', () { + final books = schema.tables.singleWhere((table) => table.name == 'books'); + final tags = books.columns.singleWhere((column) => column.name == 'tags'); + expect(tags.postgresFormat, 'text[]'); + expect(tags.arrayElementJsonType, 'string'); + }); + + test('keeps human column comments without the key markers', () { + final books = schema.tables.singleWhere((table) => table.name == 'books'); + final id = books.columns.singleWhere((column) => column.name == 'id'); + expect(id.comment, isNull); + + final createdAt = books.columns.singleWhere( + (column) => column.name == 'created_at', + ); + expect(createdAt.comment, 'When the row was created'); + }); +} diff --git a/packages/supabase_typegen/test/supabase_typegen_test.dart b/packages/supabase_typegen/test/supabase_typegen_test.dart deleted file mode 100644 index 7ff71c15d..000000000 --- a/packages/supabase_typegen/test/supabase_typegen_test.dart +++ /dev/null @@ -1,7 +0,0 @@ -import 'package:test/test.dart'; - -void main() { - test('supabase_typegen placeholder', () { - expect(true, isTrue); - }); -} diff --git a/packages/supabase_typegen/tool/regenerate_goldens.dart b/packages/supabase_typegen/tool/regenerate_goldens.dart new file mode 100644 index 000000000..9d453d5dd --- /dev/null +++ b/packages/supabase_typegen/tool/regenerate_goldens.dart @@ -0,0 +1,17 @@ +import 'dart:convert'; +import 'dart:io'; + +import 'package:supabase_typegen/supabase_typegen.dart'; + +/// Regenerates the golden files under `test/goldens` from the fixtures. +/// +/// Run from the package root with `dart run tool/regenerate_goldens.dart`. +void main() { + final document = + jsonDecode(File('test/fixtures/openapi.json').readAsStringSync()) + as Map; + final schema = parseOpenApiDocument(document); + File( + 'test/goldens/supabase_schema.dart', + ).writeAsStringSync(generateDartCode(schema)); +} From df80c5de1b142f325a2b1a106d6ee3e84e1b82af Mon Sep 17 00:00:00 2001 From: Lukas Klingsbo Date: Thu, 23 Jul 2026 09:16:48 +0200 Subject: [PATCH 2/8] chore: trigger CI From c0e83b0c092c134ddb5bc7e34c65c19218f1cf03 Mon Sep 17 00:00:00 2001 From: Lukas Klingsbo Date: Thu, 23 Jul 2026 09:56:42 +0200 Subject: [PATCH 3/8] refactor: model column type kinds as an enum in the schema description --- .../lib/src/dart_generator.dart | 135 ++++++++---------- .../lib/src/openapi_parser.dart | 67 ++++++++- .../lib/src/schema_description.dart | 49 ++++++- .../test/openapi_parser_test.dart | 19 ++- 4 files changed, 182 insertions(+), 88 deletions(-) diff --git a/packages/supabase_typegen/lib/src/dart_generator.dart b/packages/supabase_typegen/lib/src/dart_generator.dart index 12492285b..ac3e27df5 100644 --- a/packages/supabase_typegen/lib/src/dart_generator.dart +++ b/packages/supabase_typegen/lib/src/dart_generator.dart @@ -3,35 +3,14 @@ import 'package:dart_style/dart_style.dart'; import 'identifiers.dart'; import 'schema_description.dart'; -enum _Kind { direct, floating, dateTime, list, enumType, json } - class _Binding { const _Binding(this.dartType, this.kind); /// The non-nullable Dart type of the column. final String dartType; - final _Kind kind; + final ColumnTypeKind kind; } -const _integerFormats = { - 'smallint', - 'integer', - 'bigint', - 'int2', - 'int4', - 'int8', -}; -const _floatingFormats = {'real', 'double precision', 'float4', 'float8'}; -const _numericFormats = {'numeric', 'decimal'}; -const _dateTimeFormats = { - 'date', - 'timestamp', - 'timestamp without time zone', - 'timestamp with time zone', - 'timestamptz', -}; -const _jsonFormats = {'json', 'jsonb'}; - /// Generates a Dart source file with typed table definitions, row extension /// types, insert and update value types, column tokens and Postgres enums for /// [schema]. @@ -274,56 +253,45 @@ void _writeNamespace( _Binding _bindingFor( ColumnDescription column, Map enumTypeNames, -) { - final format = column.postgresFormat; - - final enumTypeName = enumTypeNames[format]; - if (enumTypeName != null) { - return _Binding(enumTypeName, _Kind.enumType); - } - if (format.endsWith('[]')) { - return _Binding( - 'List<${_arrayElementType(column.arrayElementJsonType)}>', - _Kind.list, - ); - } - if (_integerFormats.contains(format)) { - return const _Binding('int', _Kind.direct); - } - if (_floatingFormats.contains(format)) { - return const _Binding('double', _Kind.floating); - } - if (_numericFormats.contains(format)) { - return const _Binding('num', _Kind.direct); - } - if (format == 'boolean') { - return const _Binding('bool', _Kind.direct); - } - if (_dateTimeFormats.contains(format)) { - return const _Binding('DateTime', _Kind.dateTime); - } - if (_jsonFormats.contains(format)) { - return const _Binding('Object', _Kind.json); - } - return switch (column.jsonType) { - 'integer' => const _Binding('int', _Kind.direct), - 'number' => const _Binding('num', _Kind.direct), - 'boolean' => const _Binding('bool', _Kind.direct), - 'string' => const _Binding('String', _Kind.direct), - _ => const _Binding('Object', _Kind.json), - }; -} - -String _arrayElementType(String? elementJsonType) => switch (elementJsonType) { - 'integer' => 'int', - 'number' => 'num', - 'boolean' => 'bool', - 'string' => 'String', - _ => 'Object', +) => switch (column.typeKind) { + ColumnTypeKind.enumType => _Binding( + enumTypeNames[column.postgresFormat]!, + ColumnTypeKind.enumType, + ), + ColumnTypeKind.array => _Binding( + 'List<${_elementDartType(column.elementTypeKind)}>', + ColumnTypeKind.array, + ), + ColumnTypeKind.integer => const _Binding('int', ColumnTypeKind.integer), + ColumnTypeKind.floating => const _Binding('double', ColumnTypeKind.floating), + ColumnTypeKind.numeric => const _Binding('num', ColumnTypeKind.numeric), + ColumnTypeKind.boolean => const _Binding('bool', ColumnTypeKind.boolean), + ColumnTypeKind.dateTime => const _Binding( + 'DateTime', + ColumnTypeKind.dateTime, + ), + ColumnTypeKind.text => const _Binding('String', ColumnTypeKind.text), + ColumnTypeKind.json || + ColumnTypeKind.unknown => const _Binding('Object', ColumnTypeKind.json), }; +String _elementDartType(ColumnTypeKind? elementTypeKind) => + switch (elementTypeKind) { + ColumnTypeKind.integer => 'int', + ColumnTypeKind.floating => 'double', + ColumnTypeKind.numeric => 'num', + ColumnTypeKind.boolean => 'bool', + ColumnTypeKind.text => 'String', + ColumnTypeKind.dateTime || + ColumnTypeKind.json || + ColumnTypeKind.enumType || + ColumnTypeKind.array || + ColumnTypeKind.unknown || + null => 'Object', + }; + String _getterType(ColumnDescription column, _Binding binding) { - if (binding.kind == _Kind.json) return 'Object?'; + if (binding.kind == ColumnTypeKind.json) return 'Object?'; return column.isNullable ? '${binding.dartType}?' : binding.dartType; } @@ -331,27 +299,31 @@ String _readExpression(ColumnDescription column, _Binding binding) { final access = "_json[${_stringLiteral(column.name)}]"; final nullable = column.isNullable; return switch (binding.kind) { - _Kind.direct => '$access as ${binding.dartType}${nullable ? '?' : ''}', - _Kind.floating => + ColumnTypeKind.integer || + ColumnTypeKind.numeric || + ColumnTypeKind.boolean || + ColumnTypeKind.text => + '$access as ${binding.dartType}${nullable ? '?' : ''}', + ColumnTypeKind.floating => nullable ? '($access as num?)?.toDouble()' : '($access as num).toDouble()', - _Kind.list => + ColumnTypeKind.array => nullable ? '($access as List?)?.cast()' : '($access as List).cast()', - _Kind.dateTime => + ColumnTypeKind.dateTime => nullable ? _nullableSwitch(access, 'DateTime.parse(value as String)') : 'DateTime.parse($access as String)', - _Kind.enumType => + ColumnTypeKind.enumType => nullable ? _nullableSwitch( access, '${binding.dartType}.fromWire(value as String)', ) : '${binding.dartType}.fromWire($access as String)', - _Kind.json => '$access as Object?', + ColumnTypeKind.json || ColumnTypeKind.unknown => '$access as Object?', }; } @@ -365,9 +337,16 @@ String _writeExpression( }) { final access = nullable ? '$parameterName?' : parameterName; return switch (binding.kind) { - _Kind.dateTime => '$access.toIso8601String()', - _Kind.enumType => '$access.wireName', - _Kind.direct || _Kind.floating || _Kind.list || _Kind.json => parameterName, + ColumnTypeKind.dateTime => '$access.toIso8601String()', + ColumnTypeKind.enumType => '$access.wireName', + ColumnTypeKind.integer || + ColumnTypeKind.floating || + ColumnTypeKind.numeric || + ColumnTypeKind.boolean || + ColumnTypeKind.text || + ColumnTypeKind.array || + ColumnTypeKind.json || + ColumnTypeKind.unknown => parameterName, }; } diff --git a/packages/supabase_typegen/lib/src/openapi_parser.dart b/packages/supabase_typegen/lib/src/openapi_parser.dart index 3a7b838d8..0b37d5b20 100644 --- a/packages/supabase_typegen/lib/src/openapi_parser.dart +++ b/packages/supabase_typegen/lib/src/openapi_parser.dart @@ -2,6 +2,62 @@ import 'schema_description.dart'; final _foreignKeyPattern = RegExp(""); +const _integerFormats = { + 'smallint', + 'integer', + 'bigint', + 'int2', + 'int4', + 'int8', +}; +const _floatingFormats = {'real', 'double precision', 'float4', 'float8'}; +const _numericFormats = {'numeric', 'decimal'}; +const _dateTimeFormats = { + 'date', + 'timestamp', + 'timestamp without time zone', + 'timestamp with time zone', + 'timestamptz', +}; +const _jsonFormats = {'json', 'jsonb'}; + +/// Derives the [ColumnTypeKind] from the Postgres [format] and JSON schema +/// [jsonType] of a column. This is the single place where type names are +/// compared as strings; everything downstream works with the enum. +ColumnTypeKind _typeKind({ + required String format, + required String? jsonType, + required bool isEnum, +}) { + if (isEnum) return ColumnTypeKind.enumType; + if (format.endsWith('[]') || jsonType == 'array') { + return ColumnTypeKind.array; + } + if (_integerFormats.contains(format)) return ColumnTypeKind.integer; + if (_floatingFormats.contains(format)) return ColumnTypeKind.floating; + if (_numericFormats.contains(format)) return ColumnTypeKind.numeric; + if (format == 'boolean') return ColumnTypeKind.boolean; + if (_dateTimeFormats.contains(format)) return ColumnTypeKind.dateTime; + if (_jsonFormats.contains(format)) return ColumnTypeKind.json; + return switch (jsonType) { + 'integer' => ColumnTypeKind.integer, + 'number' => ColumnTypeKind.numeric, + 'boolean' => ColumnTypeKind.boolean, + 'string' => ColumnTypeKind.text, + _ => ColumnTypeKind.unknown, + }; +} + +ColumnTypeKind? _elementTypeKind(String? itemsJsonType) => itemsJsonType == null + ? null + : switch (itemsJsonType) { + 'integer' => ColumnTypeKind.integer, + 'number' => ColumnTypeKind.numeric, + 'boolean' => ColumnTypeKind.boolean, + 'string' => ColumnTypeKind.text, + _ => ColumnTypeKind.unknown, + }; + /// Parses the OpenAPI (Swagger 2.0) document that PostgREST serves at the /// API root into a [SchemaDescription]. /// @@ -50,9 +106,14 @@ SchemaDescription parseOpenApiDocument( ColumnDescription( name: columnName, postgresFormat: format, - jsonType: property['type'] as String? ?? '', - arrayElementJsonType: - (property['items'] as Map?)?['type'] as String?, + typeKind: _typeKind( + format: format, + jsonType: property['type'] as String?, + isEnum: enumValues != null, + ), + elementTypeKind: _elementTypeKind( + (property['items'] as Map?)?['type'] as String?, + ), enumValues: enumValues, isRequired: required.contains(columnName), isPrimaryKey: description?.contains('') ?? false, diff --git a/packages/supabase_typegen/lib/src/schema_description.dart b/packages/supabase_typegen/lib/src/schema_description.dart index f986acae1..e94c64650 100644 --- a/packages/supabase_typegen/lib/src/schema_description.dart +++ b/packages/supabase_typegen/lib/src/schema_description.dart @@ -1,3 +1,39 @@ +/// The Dart-relevant type of a column, derived from the Postgres type at +/// parse time so that later stages never have to compare type name strings. +enum ColumnTypeKind { + /// Whole number types such as `smallint`, `integer` and `bigint`. + integer, + + /// Floating point types such as `real` and `double precision`. + floating, + + /// Arbitrary precision types such as `numeric`, mapped to `num` since the + /// decoded JSON value may be either an integer or a double. + numeric, + + /// The `boolean` type. + boolean, + + /// Date and timestamp types, mapped to `DateTime`. + dateTime, + + /// Types carried as text, such as `text`, `uuid` and `character varying`. + text, + + /// The `json` and `jsonb` types, mapped to `Object?`. + json, + + /// A Postgres enum type. + enumType, + + /// An array type; the element type is in + /// [ColumnDescription.elementTypeKind]. + array, + + /// A type without a specific mapping, treated like [json]. + unknown, +} + /// Description of a single database schema, the input to the code generator. class SchemaDescription { const SchemaDescription({ @@ -39,11 +75,11 @@ class ColumnDescription { const ColumnDescription({ required this.name, required this.postgresFormat, - required this.jsonType, + required this.typeKind, required this.isRequired, required this.isPrimaryKey, required this.hasDefault, - this.arrayElementJsonType, + this.elementTypeKind, this.enumValues, this.foreignKey, this.comment, @@ -55,11 +91,12 @@ class ColumnDescription { /// The Postgres type, for example `bigint`, `text[]` or `public.mood`. final String postgresFormat; - /// The JSON schema type, for example `integer` or `string`. - final String jsonType; + /// The kind of Dart type the column maps to. + final ColumnTypeKind typeKind; - /// The JSON schema type of the array elements for array columns. - final String? arrayElementJsonType; + /// The kind of Dart type of the array elements for [ColumnTypeKind.array] + /// columns. + final ColumnTypeKind? elementTypeKind; /// The values of the Postgres enum for enum columns. final List? enumValues; diff --git a/packages/supabase_typegen/test/openapi_parser_test.dart b/packages/supabase_typegen/test/openapi_parser_test.dart index 44f5bf5e3..a3129d360 100644 --- a/packages/supabase_typegen/test/openapi_parser_test.dart +++ b/packages/supabase_typegen/test/openapi_parser_test.dart @@ -53,6 +53,22 @@ void main() { expect(authorId.foreignKey?.column, 'id'); }); + test('derives type kinds from formats', () { + final books = schema.tables.singleWhere((table) => table.name == 'books'); + ColumnTypeKind kindOf(String name) => + books.columns.singleWhere((column) => column.name == name).typeKind; + + expect(kindOf('id'), ColumnTypeKind.integer); + expect(kindOf('title'), ColumnTypeKind.text); + expect(kindOf('price'), ColumnTypeKind.numeric); + expect(kindOf('rating'), ColumnTypeKind.floating); + expect(kindOf('in_print'), ColumnTypeKind.boolean); + expect(kindOf('mood'), ColumnTypeKind.enumType); + expect(kindOf('metadata'), ColumnTypeKind.json); + expect(kindOf('created_at'), ColumnTypeKind.dateTime); + expect(kindOf('cover_uuid'), ColumnTypeKind.text); + }); + test('collects Postgres enums', () { expect(schema.enums, hasLength(1)); final mood = schema.enums.single; @@ -65,7 +81,8 @@ void main() { final books = schema.tables.singleWhere((table) => table.name == 'books'); final tags = books.columns.singleWhere((column) => column.name == 'tags'); expect(tags.postgresFormat, 'text[]'); - expect(tags.arrayElementJsonType, 'string'); + expect(tags.typeKind, ColumnTypeKind.array); + expect(tags.elementTypeKind, ColumnTypeKind.text); }); test('keeps human column comments without the key markers', () { From ad8fdf502bdc5d67481c7d3623d284c29cbfcb9d Mon Sep 17 00:00:00 2001 From: Lukas Klingsbo Date: Thu, 23 Jul 2026 10:04:36 +0200 Subject: [PATCH 4/8] chore: trigger CI From 514450c45d592c5b67550506f12bd15b29f95d69 Mon Sep 17 00:00:00 2001 From: Lukas Klingsbo Date: Thu, 23 Jul 2026 11:18:30 +0200 Subject: [PATCH 5/8] fix(supabase_typegen): review fixes for wire correctness and CLI robustness --- packages/supabase_typegen/README.md | 22 +++-- .../bin/supabase_typegen.dart | 41 +++++++-- .../lib/src/dart_generator.dart | 85 ++++++++++++++++--- .../supabase_typegen/lib/src/identifiers.dart | 7 +- .../lib/src/openapi_parser.dart | 32 +++---- .../lib/src/schema_description.dart | 13 ++- .../test/fixtures/openapi.json | 20 ++++- .../test/generated_schema_behavior_test.dart | 39 +++++++++ .../test/goldens/supabase_schema.dart | 44 ++++++++-- .../test/identifiers_test.dart | 6 ++ .../test/openapi_parser_test.dart | 4 +- 11 files changed, 254 insertions(+), 59 deletions(-) diff --git a/packages/supabase_typegen/README.md b/packages/supabase_typegen/README.md index 6e1decb3a..98ca3d8cb 100644 --- a/packages/supabase_typegen/README.md +++ b/packages/supabase_typegen/README.md @@ -27,8 +27,8 @@ environment variables. Use `--schema` to generate for a schema other than `PostgrestTable` and `TableColumn` from. The schema is read from the OpenAPI description that PostgREST serves at the -API root, so the key only needs read access; tables hidden from the key by -row level security settings are not included. +API root, so the key only needs read access; tables whose role lacks +privileges (grants, not row level security) are not included. ## Generated code in action @@ -45,7 +45,17 @@ await client.table(Books.table).insert( ## Known limitations -The OpenAPI description does not distinguish nullable columns from `NOT NULL` -columns with a database default, so getters for defaulted columns other than -primary keys are conservatively nullable. Foreign key relationship getters -and typed functions (rpc) are not generated yet. +- The OpenAPI description does not distinguish nullable columns from + `NOT NULL` columns with a database default, so getters for defaulted + columns other than primary keys are conservatively nullable. +- Passing `null` to an `Insert`/`Update` parameter omits the column. To write + SQL NULL explicitly, set the raw key: `BooksUpdate()..['price'] = null`. +- Array elements are assumed non-null (`text[]` maps to `List`), + matching the supabase-js type generator; arrays containing SQL NULL + elements throw when the element is read. Enum array columns degrade to + `List`. +- `timestamptz` values are written back in UTC, naive `timestamp` values as + local wall time, and `date` values date-only, so calendar dates never + shift with the client timezone. +- Foreign key relationship getters and typed functions (rpc) are not + generated yet. diff --git a/packages/supabase_typegen/bin/supabase_typegen.dart b/packages/supabase_typegen/bin/supabase_typegen.dart index 962cbe12d..e04e9dc00 100644 --- a/packages/supabase_typegen/bin/supabase_typegen.dart +++ b/packages/supabase_typegen/bin/supabase_typegen.dart @@ -38,7 +38,13 @@ final _argParser = ArgParser() ) ..addFlag('help', abbr: 'h', negatable: false, help: 'Show this usage.'); -Future main(List arguments) async { +Future main(List arguments) async { + // The value returned from main is ignored by the Dart VM, so the exit + // code has to be set explicitly. + exitCode = await _run(arguments); +} + +Future _run(List arguments) async { final ArgResults options; try { options = _argParser.parse(arguments); @@ -72,7 +78,8 @@ Future main(List arguments) async { } final schemaName = options.option('schema')!; - final endpoint = Uri.parse('$url/rest/v1/'); + final baseUrl = url.replaceAll(RegExp(r'/+$'), ''); + final endpoint = Uri.parse('$baseUrl/rest/v1/'); final http.Response response; try { response = await http.get( @@ -95,19 +102,37 @@ Future main(List arguments) async { return 1; } - final schema = parseOpenApiDocument( - jsonDecode(response.body) as Map, - schemaName: schemaName, - ); + final Map document; + try { + document = + jsonDecode(utf8.decode(response.bodyBytes)) as Map; + } on FormatException catch (error) { + stderr.writeln('The response from $endpoint is not valid JSON: $error'); + return 1; + } on TypeError { + stderr.writeln( + 'The response from $endpoint is not an OpenAPI document. Check that ' + 'the URL points to a Supabase project or PostgREST instance.', + ); + return 1; + } + + final schema = parseOpenApiDocument(document, schemaName: schemaName); final code = generateDartCode(schema, importUri: options.option('import')!); final outputFile = File(options.option('output')!); outputFile.parent.createSync(recursive: true); outputFile.writeAsStringSync(code); + final emittedTables = schema.tables + .where((table) => table.columns.isNotEmpty) + .length; + final skippedTables = schema.tables.length - emittedTables; stdout.writeln( - 'Generated ${outputFile.path} with ${schema.tables.length} tables and ' - '${schema.enums.length} enums from schema "$schemaName".', + 'Generated ${outputFile.path} with $emittedTables tables and ' + '${schema.enums.length} enums from schema "$schemaName".' + '${skippedTables == 0 ? '' : ' Skipped $skippedTables tables ' + 'without columns.'}', ); return 0; } diff --git a/packages/supabase_typegen/lib/src/dart_generator.dart b/packages/supabase_typegen/lib/src/dart_generator.dart index ac3e27df5..d032fcd2e 100644 --- a/packages/supabase_typegen/lib/src/dart_generator.dart +++ b/packages/supabase_typegen/lib/src/dart_generator.dart @@ -22,6 +22,11 @@ String generateDartCode( SchemaDescription schema, { String importUri = 'package:postgrest/postgrest.dart', }) { + final usesDateColumns = schema.tables.any( + (table) => table.columns.any( + (column) => column.typeKind == ColumnTypeKind.date, + ), + ); final buffer = StringBuffer() ..writeln('// Generated by supabase_typegen. Do not edit by hand.') ..writeln('//') @@ -44,6 +49,15 @@ String generateDartCode( _writeTable(buffer, table, typeNames, enumTypeNames); } + if (usesDateColumns) { + buffer + ..writeln('String _dateString(DateTime date) =>') + ..writeln(" '\${date.year.toString().padLeft(4, '0')}-'") + ..writeln(" '\${date.month.toString().padLeft(2, '0')}-'") + ..writeln(" '\${date.day.toString().padLeft(2, '0')}';") + ..writeln(); + } + return DartFormatter( languageVersion: DartFormatter.latestLanguageVersion, ).format(buffer.toString()); @@ -67,7 +81,21 @@ void _writeEnum( EnumDescription enumDescription, String typeName, ) { - final valueNames = _uniqueMemberNames(enumDescription.values); + final valueNames = _uniqueMemberNames( + enumDescription.values, + reserved: { + typeName, + 'index', + 'name', + 'values', + 'wireName', + 'fromWire', + 'toString', + 'hashCode', + 'runtimeType', + 'noSuchMethod', + }, + ); buffer ..writeln('/// Postgres enum `${enumDescription.qualifiedName}`.') @@ -85,7 +113,14 @@ void _writeEnum( ..writeln() ..writeln(' /// Parses the database representation of the enum.') ..writeln(' static $typeName fromWire(String wireName) =>') - ..writeln(' values.firstWhere((value) => value.wireName == wireName);') + ..writeln(' values.firstWhere(') + ..writeln(' (value) => value.wireName == wireName,') + ..writeln(' orElse: () => throw ArgumentError.value(') + ..writeln(' wireName,') + ..writeln(" 'wireName',") + ..writeln(" 'No $typeName value with this wire name',") + ..writeln(' ),') + ..writeln(' );') ..writeln() ..writeln(' @override') ..writeln(' String toString() => wireName;') @@ -105,9 +140,10 @@ void _writeTable( final updateType = typeNames.claim('${baseName}Update'); final namespaceType = typeNames.claim(baseName); - final memberNames = _uniqueMemberNames([ - for (final column in table.columns) column.name, - ]); + final memberNames = _uniqueMemberNames( + [for (final column in table.columns) column.name], + reserved: {rowType, insertType, updateType}, + ); final bindings = { for (final column in table.columns) column.name: _bindingFor(column, enumTypeNames), @@ -136,7 +172,9 @@ void _writeTable( requireRequiredColumns: false, docLine: 'Values for updating rows of `${table.name}`. All columns are ' - 'optional; passing `null` leaves the column unchanged.', + 'optional; passing `null` omits the column, leaving it unchanged. ' + 'To write SQL NULL explicitly, set the raw key: ' + '`$updateType()..[\'column_name\'] = null`.', ); _writeNamespace(buffer, table, namespaceType, rowType, memberNames, bindings); } @@ -223,7 +261,7 @@ void _writeNamespace( ) { final columnNames = _uniqueMemberNames( [for (final column in table.columns) column.name], - reserved: {'table'}, + reserved: {'table', namespaceType}, existing: memberNames, ); @@ -266,9 +304,14 @@ _Binding _bindingFor( ColumnTypeKind.floating => const _Binding('double', ColumnTypeKind.floating), ColumnTypeKind.numeric => const _Binding('num', ColumnTypeKind.numeric), ColumnTypeKind.boolean => const _Binding('bool', ColumnTypeKind.boolean), - ColumnTypeKind.dateTime => const _Binding( + ColumnTypeKind.date => const _Binding('DateTime', ColumnTypeKind.date), + ColumnTypeKind.timestamp => const _Binding( + 'DateTime', + ColumnTypeKind.timestamp, + ), + ColumnTypeKind.timestampWithTimeZone => const _Binding( 'DateTime', - ColumnTypeKind.dateTime, + ColumnTypeKind.timestampWithTimeZone, ), ColumnTypeKind.text => const _Binding('String', ColumnTypeKind.text), ColumnTypeKind.json || @@ -282,7 +325,9 @@ String _elementDartType(ColumnTypeKind? elementTypeKind) => ColumnTypeKind.numeric => 'num', ColumnTypeKind.boolean => 'bool', ColumnTypeKind.text => 'String', - ColumnTypeKind.dateTime || + ColumnTypeKind.date || + ColumnTypeKind.timestamp || + ColumnTypeKind.timestampWithTimeZone || ColumnTypeKind.json || ColumnTypeKind.enumType || ColumnTypeKind.array || @@ -312,7 +357,9 @@ String _readExpression(ColumnDescription column, _Binding binding) { nullable ? '($access as List?)?.cast()' : '($access as List).cast()', - ColumnTypeKind.dateTime => + ColumnTypeKind.date || + ColumnTypeKind.timestamp || + ColumnTypeKind.timestampWithTimeZone => nullable ? _nullableSwitch(access, 'DateTime.parse(value as String)') : 'DateTime.parse($access as String)', @@ -337,7 +384,16 @@ String _writeExpression( }) { final access = nullable ? '$parameterName?' : parameterName; return switch (binding.kind) { - ColumnTypeKind.dateTime => '$access.toIso8601String()', + ColumnTypeKind.date => + nullable + ? 'switch ($parameterName) ' + '{ null => null, final value => _dateString(value) }' + : '_dateString($parameterName)', + ColumnTypeKind.timestamp => '$access.toIso8601String()', + ColumnTypeKind.timestampWithTimeZone => + nullable + ? '$access.toUtc().toIso8601String()' + : '$parameterName.toUtc().toIso8601String()', ColumnTypeKind.enumType => '$access.wireName', ColumnTypeKind.integer || ColumnTypeKind.floating || @@ -387,6 +443,9 @@ String _stringLiteral(String value) { final escaped = value .replaceAll(r'\', r'\\') .replaceAll("'", r"\'") - .replaceAll(r'$', r'\$'); + .replaceAll(r'$', r'\$') + .replaceAll('\n', r'\n') + .replaceAll('\r', r'\r') + .replaceAll('\t', r'\t'); return "'$escaped'"; } diff --git a/packages/supabase_typegen/lib/src/identifiers.dart b/packages/supabase_typegen/lib/src/identifiers.dart index 130c435e5..0c8dcb8b9 100644 --- a/packages/supabase_typegen/lib/src/identifiers.dart +++ b/packages/supabase_typegen/lib/src/identifiers.dart @@ -97,9 +97,12 @@ const _mapMembers = { }; final _wordSeparator = RegExp('[^a-zA-Z0-9]+'); +final _camelHumpBoundary = RegExp('(?<=[a-z0-9])(?=[A-Z])'); -List _words(String name) => - name.split(_wordSeparator).where((word) => word.isNotEmpty).toList(); +List _words(String name) => [ + for (final part in name.split(_wordSeparator)) + ...part.split(_camelHumpBoundary), +].where((word) => word.isNotEmpty).toList(); /// Converts [name] to PascalCase, for example `author_stats` to /// `AuthorStats`. diff --git a/packages/supabase_typegen/lib/src/openapi_parser.dart b/packages/supabase_typegen/lib/src/openapi_parser.dart index 0b37d5b20..44925d9c1 100644 --- a/packages/supabase_typegen/lib/src/openapi_parser.dart +++ b/packages/supabase_typegen/lib/src/openapi_parser.dart @@ -12,10 +12,8 @@ const _integerFormats = { }; const _floatingFormats = {'real', 'double precision', 'float4', 'float8'}; const _numericFormats = {'numeric', 'decimal'}; -const _dateTimeFormats = { - 'date', - 'timestamp', - 'timestamp without time zone', +const _timestampFormats = {'timestamp', 'timestamp without time zone'}; +const _timestampWithTimeZoneFormats = { 'timestamp with time zone', 'timestamptz', }; @@ -29,15 +27,19 @@ ColumnTypeKind _typeKind({ required String? jsonType, required bool isEnum, }) { - if (isEnum) return ColumnTypeKind.enumType; if (format.endsWith('[]') || jsonType == 'array') { return ColumnTypeKind.array; } + if (isEnum) return ColumnTypeKind.enumType; if (_integerFormats.contains(format)) return ColumnTypeKind.integer; if (_floatingFormats.contains(format)) return ColumnTypeKind.floating; if (_numericFormats.contains(format)) return ColumnTypeKind.numeric; if (format == 'boolean') return ColumnTypeKind.boolean; - if (_dateTimeFormats.contains(format)) return ColumnTypeKind.dateTime; + if (format == 'date') return ColumnTypeKind.date; + if (_timestampFormats.contains(format)) return ColumnTypeKind.timestamp; + if (_timestampWithTimeZoneFormats.contains(format)) { + return ColumnTypeKind.timestampWithTimeZone; + } if (_jsonFormats.contains(format)) return ColumnTypeKind.json; return switch (jsonType) { 'integer' => ColumnTypeKind.integer, @@ -76,7 +78,7 @@ SchemaDescription parseOpenApiDocument( for (final MapEntry(key: tableName, value: definition) in definitions.entries) { - definition as Map; + if (definition is! Map) continue; final required = { ...?(definition['required'] as List?)?.cast(), }; @@ -90,8 +92,13 @@ SchemaDescription parseOpenApiDocument( final description = property['description'] as String?; final format = property['format'] as String? ?? ''; final enumValues = (property['enum'] as List?)?.cast(); + final typeKind = _typeKind( + format: format, + jsonType: property['type'] as String?, + isEnum: enumValues != null, + ); - if (enumValues != null) { + if (enumValues != null && typeKind == ColumnTypeKind.enumType) { enumsByQualifiedName.putIfAbsent( format, () => EnumDescription(qualifiedName: format, values: enumValues), @@ -106,11 +113,7 @@ SchemaDescription parseOpenApiDocument( ColumnDescription( name: columnName, postgresFormat: format, - typeKind: _typeKind( - format: format, - jsonType: property['type'] as String?, - isEnum: enumValues != null, - ), + typeKind: typeKind, elementTypeKind: _elementTypeKind( (property['items'] as Map?)?['type'] as String?, ), @@ -156,9 +159,8 @@ String? _cleanComment(String? description) { final cleaned = description .replaceAll(_foreignKeyPattern, '') .replaceAll('', '') - .replaceAll(RegExp(r'Note:\s*'), '') .replaceAll( - RegExp(r'This is a (Primary|Foreign) Key( to `[^`]+`)?\.'), + RegExp(r'Note:\s*This is a (Primary|Foreign) Key( to `[^`]+`)?\.'), '', ) .trim(); diff --git a/packages/supabase_typegen/lib/src/schema_description.dart b/packages/supabase_typegen/lib/src/schema_description.dart index e94c64650..6ab5a59b4 100644 --- a/packages/supabase_typegen/lib/src/schema_description.dart +++ b/packages/supabase_typegen/lib/src/schema_description.dart @@ -14,8 +14,17 @@ enum ColumnTypeKind { /// The `boolean` type. boolean, - /// Date and timestamp types, mapped to `DateTime`. - dateTime, + /// The `date` type, mapped to `DateTime` and written back date-only so + /// the calendar date never shifts with the client timezone. + date, + + /// Timestamps without a timezone, mapped to `DateTime` and written back as + /// the local wall time. + timestamp, + + /// Timestamps with a timezone, mapped to `DateTime` and written back in + /// UTC. + timestampWithTimeZone, /// Types carried as text, such as `text`, `uuid` and `character varying`. text, diff --git a/packages/supabase_typegen/test/fixtures/openapi.json b/packages/supabase_typegen/test/fixtures/openapi.json index a64a325ad..8b215ab99 100644 --- a/packages/supabase_typegen/test/fixtures/openapi.json +++ b/packages/supabase_typegen/test/fixtures/openapi.json @@ -8,7 +8,10 @@ "definitions": { "books": { "description": "Books available in the library", - "required": ["title", "author_id"], + "required": [ + "title", + "author_id" + ], "properties": { "id": { "description": "Note:\nThis is a Primary Key.", @@ -39,7 +42,11 @@ "default": true }, "mood": { - "enum": ["happy", "very happy", "sad"], + "enum": [ + "happy", + "very happy", + "sad" + ], "format": "public.mood", "type": "string" }, @@ -73,12 +80,19 @@ "format": "timestamp with time zone", "type": "string", "default": "now()" + }, + "updated_at": { + "format": "timestamp without time zone", + "type": "string" } }, "type": "object" }, "authors": { - "required": ["id", "name"], + "required": [ + "id", + "name" + ], "properties": { "id": { "description": "Note:\nThis is a Primary Key.", diff --git a/packages/supabase_typegen/test/generated_schema_behavior_test.dart b/packages/supabase_typegen/test/generated_schema_behavior_test.dart index 512606b95..14cc69b96 100644 --- a/packages/supabase_typegen/test/generated_schema_behavior_test.dart +++ b/packages/supabase_typegen/test/generated_schema_behavior_test.dart @@ -102,6 +102,45 @@ void main() { }); }); + test( + 'timestamps are sent as UTC instants and dates keep their day', + () async { + httpClient.responseBody = ''; + + await client + .table(Books.table) + .insert( + BooksInsert( + title: 'A typed row', + authorId: 7, + createdAt: DateTime(2026, 7, 23, 10), // local wall time + publishedOn: DateTime(2026, 7, 23, 23, 30), + ), + ); + + final sent = + jsonDecode(httpClient.lastRequestBody!) as Map; + expect( + sent['created_at'], + DateTime(2026, 7, 23, 10).toUtc().toIso8601String(), + ); + expect(sent['published_on'], '2026-07-23'); + }, + ); + + test('unknown enum wire values throw a descriptive error', () { + expect( + () => Mood.fromWire('grumpy'), + throwsA( + isA().having( + (error) => error.message, + 'message', + contains('No Mood value'), + ), + ), + ); + }); + test('update sends only the provided columns', () async { httpClient.responseBody = ''; diff --git a/packages/supabase_typegen/test/goldens/supabase_schema.dart b/packages/supabase_typegen/test/goldens/supabase_schema.dart index d2add44cb..d7f29262d 100644 --- a/packages/supabase_typegen/test/goldens/supabase_schema.dart +++ b/packages/supabase_typegen/test/goldens/supabase_schema.dart @@ -16,8 +16,14 @@ enum Mood { final String wireName; /// Parses the database representation of the enum. - static Mood fromWire(String wireName) => - values.firstWhere((value) => value.wireName == wireName); + static Mood fromWire(String wireName) => values.firstWhere( + (value) => value.wireName == wireName, + orElse: () => throw ArgumentError.value( + wireName, + 'wireName', + 'No Mood value with this wire name', + ), + ); @override String toString() => wireName; @@ -38,7 +44,7 @@ extension type const AuthorStatsInsert._(Map _json) : this._({'author_id': ?authorId, 'book_count': ?bookCount}); } -/// Values for updating rows of `author_stats`. All columns are optional; passing `null` leaves the column unchanged. +/// Values for updating rows of `author_stats`. All columns are optional; passing `null` omits the column, leaving it unchanged. To write SQL NULL explicitly, set the raw key: `AuthorStatsUpdate()..['column_name'] = null`. extension type const AuthorStatsUpdate._(Map _json) implements Map { AuthorStatsUpdate({int? authorId, int? bookCount}) @@ -70,7 +76,7 @@ extension type const AuthorsInsert._(Map _json) : this._({'id': id, 'name': name}); } -/// Values for updating rows of `authors`. All columns are optional; passing `null` leaves the column unchanged. +/// Values for updating rows of `authors`. All columns are optional; passing `null` omits the column, leaving it unchanged. To write SQL NULL explicitly, set the raw key: `AuthorsUpdate()..['column_name'] = null`. extension type const AuthorsUpdate._(Map _json) implements Map { AuthorsUpdate({int? id, String? name}) : this._({'id': ?id, 'name': ?name}); @@ -115,6 +121,10 @@ extension type const BooksRow(Map _json) null => null, final Object value => DateTime.parse(value as String), }; + DateTime? get updatedAt => switch (_json['updated_at']) { + null => null, + final Object value => DateTime.parse(value as String), + }; } /// Values for inserting a row into `books`. Columns that are nullable, part of a generated primary key, or covered by a database default are optional; passing `null` omits the column so the database default applies. @@ -134,6 +144,7 @@ extension type const BooksInsert._(Map _json) String? coverUuid, DateTime? publishedOn, DateTime? createdAt, + DateTime? updatedAt, }) : this._({ 'id': ?id, 'title': title, @@ -146,12 +157,16 @@ extension type const BooksInsert._(Map _json) 'page_counts': ?pageCounts, 'metadata': ?metadata, 'cover_uuid': ?coverUuid, - 'published_on': ?publishedOn?.toIso8601String(), - 'created_at': ?createdAt?.toIso8601String(), + 'published_on': ?switch (publishedOn) { + null => null, + final value => _dateString(value), + }, + 'created_at': ?createdAt?.toUtc().toIso8601String(), + 'updated_at': ?updatedAt?.toIso8601String(), }); } -/// Values for updating rows of `books`. All columns are optional; passing `null` leaves the column unchanged. +/// Values for updating rows of `books`. All columns are optional; passing `null` omits the column, leaving it unchanged. To write SQL NULL explicitly, set the raw key: `BooksUpdate()..['column_name'] = null`. extension type const BooksUpdate._(Map _json) implements Map { BooksUpdate({ @@ -168,6 +183,7 @@ extension type const BooksUpdate._(Map _json) String? coverUuid, DateTime? publishedOn, DateTime? createdAt, + DateTime? updatedAt, }) : this._({ 'id': ?id, 'title': ?title, @@ -180,8 +196,12 @@ extension type const BooksUpdate._(Map _json) 'page_counts': ?pageCounts, 'metadata': ?metadata, 'cover_uuid': ?coverUuid, - 'published_on': ?publishedOn?.toIso8601String(), - 'created_at': ?createdAt?.toIso8601String(), + 'published_on': ?switch (publishedOn) { + null => null, + final value => _dateString(value), + }, + 'created_at': ?createdAt?.toUtc().toIso8601String(), + 'updated_at': ?updatedAt?.toIso8601String(), }); } @@ -205,4 +225,10 @@ class Books { static const coverUuid = TableColumn('cover_uuid'); static const publishedOn = TableColumn('published_on'); static const createdAt = TableColumn('created_at'); + static const updatedAt = TableColumn('updated_at'); } + +String _dateString(DateTime date) => + '${date.year.toString().padLeft(4, '0')}-' + '${date.month.toString().padLeft(2, '0')}-' + '${date.day.toString().padLeft(2, '0')}'; diff --git a/packages/supabase_typegen/test/identifiers_test.dart b/packages/supabase_typegen/test/identifiers_test.dart index 6907f96bb..5755922e9 100644 --- a/packages/supabase_typegen/test/identifiers_test.dart +++ b/packages/supabase_typegen/test/identifiers_test.dart @@ -9,6 +9,12 @@ void main() { expect(pascalCase('user-profiles'), 'UserProfiles'); }); + test('keeps existing camel humps', () { + expect(pascalCase('UserProfiles'), 'UserProfiles'); + expect(pascalCase('userId'), 'UserId'); + expect(camelCase('userId'), 'userId'); + }); + test('prefixes names starting with a digit', () { expect(pascalCase('2fa_codes'), r'$2faCodes'); }); diff --git a/packages/supabase_typegen/test/openapi_parser_test.dart b/packages/supabase_typegen/test/openapi_parser_test.dart index a3129d360..2c71355f7 100644 --- a/packages/supabase_typegen/test/openapi_parser_test.dart +++ b/packages/supabase_typegen/test/openapi_parser_test.dart @@ -65,7 +65,9 @@ void main() { expect(kindOf('in_print'), ColumnTypeKind.boolean); expect(kindOf('mood'), ColumnTypeKind.enumType); expect(kindOf('metadata'), ColumnTypeKind.json); - expect(kindOf('created_at'), ColumnTypeKind.dateTime); + expect(kindOf('created_at'), ColumnTypeKind.timestampWithTimeZone); + expect(kindOf('updated_at'), ColumnTypeKind.timestamp); + expect(kindOf('published_on'), ColumnTypeKind.date); expect(kindOf('cover_uuid'), ColumnTypeKind.text); }); From 1d4a73707eaeae260c140ad6882d0bea0f15169d Mon Sep 17 00:00:00 2001 From: Lukas Klingsbo Date: Thu, 23 Jul 2026 12:08:19 +0200 Subject: [PATCH 6/8] feat(supabase_typegen): generate setXToNull methods for explicit SQL NULL writes --- packages/supabase_typegen/README.md | 4 +- .../lib/src/dart_generator.dart | 23 +++- .../test/generated_schema_behavior_test.dart | 33 ++++++ .../test/goldens/supabase_schema.dart | 106 +++++++++++++++++- 4 files changed, 155 insertions(+), 11 deletions(-) diff --git a/packages/supabase_typegen/README.md b/packages/supabase_typegen/README.md index 98ca3d8cb..2c2828844 100644 --- a/packages/supabase_typegen/README.md +++ b/packages/supabase_typegen/README.md @@ -49,7 +49,9 @@ await client.table(Books.table).insert( `NOT NULL` columns with a database default, so getters for defaulted columns other than primary keys are conservatively nullable. - Passing `null` to an `Insert`/`Update` parameter omits the column. To write - SQL NULL explicitly, set the raw key: `BooksUpdate()..['price'] = null`. + SQL NULL explicitly, use the generated `set…ToNull` methods, for example + `BooksUpdate(inPrint: false).setPriceToNull()`; they only exist for + nullable columns, so nulling a `NOT NULL` column is a compile error. - Array elements are assumed non-null (`text[]` maps to `List`), matching the supabase-js type generator; arrays containing SQL NULL elements throw when the element is read. Enum array columns degrade to diff --git a/packages/supabase_typegen/lib/src/dart_generator.dart b/packages/supabase_typegen/lib/src/dart_generator.dart index d032fcd2e..ba97d2333 100644 --- a/packages/supabase_typegen/lib/src/dart_generator.dart +++ b/packages/supabase_typegen/lib/src/dart_generator.dart @@ -161,7 +161,8 @@ void _writeTable( 'Values for inserting a row into `${table.name}`. Columns that are ' 'nullable, part of a generated primary key, or covered by a database ' 'default are optional; passing `null` omits the column so the ' - 'database default applies.', + 'database default applies. Use the `set…ToNull` methods to insert ' + 'SQL NULL explicitly.', ); _writeValues( buffer, @@ -173,8 +174,7 @@ void _writeTable( docLine: 'Values for updating rows of `${table.name}`. All columns are ' 'optional; passing `null` omits the column, leaving it unchanged. ' - 'To write SQL NULL explicitly, set the raw key: ' - '`$updateType()..[\'column_name\'] = null`.', + 'Use the `set…ToNull` methods to write SQL NULL explicitly.', ); _writeNamespace(buffer, table, namespaceType, rowType, memberNames, bindings); } @@ -245,8 +245,23 @@ void _writeValues( ); } } + buffer.writeln(' });'); + for (final column in table.columns) { + if (!column.isNullable) continue; + final name = memberNames[column.name]!; + final methodName = 'set${name[0].toUpperCase()}${name.substring(1)}ToNull'; + buffer + ..writeln() + ..writeln( + ' /// Returns a copy with `${column.name}` set to SQL NULL, ' + 'overriding any database default.', + ) + ..writeln( + ' $typeName $methodName() => ' + '$typeName._({..._json, ${_stringLiteral(column.name)}: null});', + ); + } buffer - ..writeln(' });') ..writeln('}') ..writeln(); } diff --git a/packages/supabase_typegen/test/generated_schema_behavior_test.dart b/packages/supabase_typegen/test/generated_schema_behavior_test.dart index 14cc69b96..50164f79f 100644 --- a/packages/supabase_typegen/test/generated_schema_behavior_test.dart +++ b/packages/supabase_typegen/test/generated_schema_behavior_test.dart @@ -152,4 +152,37 @@ void main() { expect(jsonDecode(httpClient.lastRequestBody!), {'in_print': false}); expect(httpClient.lastRequest!.url.queryParameters['id'], 'eq.1'); }); + + test('setXToNull writes SQL NULL explicitly', () async { + httpClient.responseBody = ''; + + final update = BooksUpdate(inPrint: false); + await client + .table(Books.table) + .update(update.setPriceToNull().setMoodToNull()) + .where(Books.id.eq(1)); + + expect(jsonDecode(httpClient.lastRequestBody!), { + 'in_print': false, + 'price': null, + 'mood': null, + }); + expect( + update.containsKey('price'), + isFalse, + reason: 'setPriceToNull returns a copy and must not mutate', + ); + + await client + .table(Books.table) + .insert( + BooksInsert(title: 'x', authorId: 7).setPublishedOnToNull(), + ); + + expect(jsonDecode(httpClient.lastRequestBody!), { + 'title': 'x', + 'author_id': 7, + 'published_on': null, + }); + }); } diff --git a/packages/supabase_typegen/test/goldens/supabase_schema.dart b/packages/supabase_typegen/test/goldens/supabase_schema.dart index d7f29262d..a581d6f21 100644 --- a/packages/supabase_typegen/test/goldens/supabase_schema.dart +++ b/packages/supabase_typegen/test/goldens/supabase_schema.dart @@ -37,18 +37,34 @@ extension type const AuthorStatsRow(Map _json) int? get bookCount => _json['book_count'] as int?; } -/// Values for inserting a row into `author_stats`. Columns that are nullable, part of a generated primary key, or covered by a database default are optional; passing `null` omits the column so the database default applies. +/// Values for inserting a row into `author_stats`. Columns that are nullable, part of a generated primary key, or covered by a database default are optional; passing `null` omits the column so the database default applies. Use the `set…ToNull` methods to insert SQL NULL explicitly. extension type const AuthorStatsInsert._(Map _json) implements Map { AuthorStatsInsert({int? authorId, int? bookCount}) : this._({'author_id': ?authorId, 'book_count': ?bookCount}); + + /// Returns a copy with `author_id` set to SQL NULL, overriding any database default. + AuthorStatsInsert setAuthorIdToNull() => + AuthorStatsInsert._({..._json, 'author_id': null}); + + /// Returns a copy with `book_count` set to SQL NULL, overriding any database default. + AuthorStatsInsert setBookCountToNull() => + AuthorStatsInsert._({..._json, 'book_count': null}); } -/// Values for updating rows of `author_stats`. All columns are optional; passing `null` omits the column, leaving it unchanged. To write SQL NULL explicitly, set the raw key: `AuthorStatsUpdate()..['column_name'] = null`. +/// Values for updating rows of `author_stats`. All columns are optional; passing `null` omits the column, leaving it unchanged. Use the `set…ToNull` methods to write SQL NULL explicitly. extension type const AuthorStatsUpdate._(Map _json) implements Map { AuthorStatsUpdate({int? authorId, int? bookCount}) : this._({'author_id': ?authorId, 'book_count': ?bookCount}); + + /// Returns a copy with `author_id` set to SQL NULL, overriding any database default. + AuthorStatsUpdate setAuthorIdToNull() => + AuthorStatsUpdate._({..._json, 'author_id': null}); + + /// Returns a copy with `book_count` set to SQL NULL, overriding any database default. + AuthorStatsUpdate setBookCountToNull() => + AuthorStatsUpdate._({..._json, 'book_count': null}); } /// Typed access to the `author_stats` table. @@ -69,14 +85,14 @@ extension type const AuthorsRow(Map _json) String get name => _json['name'] as String; } -/// Values for inserting a row into `authors`. Columns that are nullable, part of a generated primary key, or covered by a database default are optional; passing `null` omits the column so the database default applies. +/// Values for inserting a row into `authors`. Columns that are nullable, part of a generated primary key, or covered by a database default are optional; passing `null` omits the column so the database default applies. Use the `set…ToNull` methods to insert SQL NULL explicitly. extension type const AuthorsInsert._(Map _json) implements Map { AuthorsInsert({required int id, required String name}) : this._({'id': id, 'name': name}); } -/// Values for updating rows of `authors`. All columns are optional; passing `null` omits the column, leaving it unchanged. To write SQL NULL explicitly, set the raw key: `AuthorsUpdate()..['column_name'] = null`. +/// Values for updating rows of `authors`. All columns are optional; passing `null` omits the column, leaving it unchanged. Use the `set…ToNull` methods to write SQL NULL explicitly. extension type const AuthorsUpdate._(Map _json) implements Map { AuthorsUpdate({int? id, String? name}) : this._({'id': ?id, 'name': ?name}); @@ -127,7 +143,7 @@ extension type const BooksRow(Map _json) }; } -/// Values for inserting a row into `books`. Columns that are nullable, part of a generated primary key, or covered by a database default are optional; passing `null` omits the column so the database default applies. +/// Values for inserting a row into `books`. Columns that are nullable, part of a generated primary key, or covered by a database default are optional; passing `null` omits the column so the database default applies. Use the `set…ToNull` methods to insert SQL NULL explicitly. extension type const BooksInsert._(Map _json) implements Map { BooksInsert({ @@ -164,9 +180,48 @@ extension type const BooksInsert._(Map _json) 'created_at': ?createdAt?.toUtc().toIso8601String(), 'updated_at': ?updatedAt?.toIso8601String(), }); + + /// Returns a copy with `price` set to SQL NULL, overriding any database default. + BooksInsert setPriceToNull() => BooksInsert._({..._json, 'price': null}); + + /// Returns a copy with `rating` set to SQL NULL, overriding any database default. + BooksInsert setRatingToNull() => BooksInsert._({..._json, 'rating': null}); + + /// Returns a copy with `in_print` set to SQL NULL, overriding any database default. + BooksInsert setInPrintToNull() => BooksInsert._({..._json, 'in_print': null}); + + /// Returns a copy with `mood` set to SQL NULL, overriding any database default. + BooksInsert setMoodToNull() => BooksInsert._({..._json, 'mood': null}); + + /// Returns a copy with `tags` set to SQL NULL, overriding any database default. + BooksInsert setTagsToNull() => BooksInsert._({..._json, 'tags': null}); + + /// Returns a copy with `page_counts` set to SQL NULL, overriding any database default. + BooksInsert setPageCountsToNull() => + BooksInsert._({..._json, 'page_counts': null}); + + /// Returns a copy with `metadata` set to SQL NULL, overriding any database default. + BooksInsert setMetadataToNull() => + BooksInsert._({..._json, 'metadata': null}); + + /// Returns a copy with `cover_uuid` set to SQL NULL, overriding any database default. + BooksInsert setCoverUuidToNull() => + BooksInsert._({..._json, 'cover_uuid': null}); + + /// Returns a copy with `published_on` set to SQL NULL, overriding any database default. + BooksInsert setPublishedOnToNull() => + BooksInsert._({..._json, 'published_on': null}); + + /// Returns a copy with `created_at` set to SQL NULL, overriding any database default. + BooksInsert setCreatedAtToNull() => + BooksInsert._({..._json, 'created_at': null}); + + /// Returns a copy with `updated_at` set to SQL NULL, overriding any database default. + BooksInsert setUpdatedAtToNull() => + BooksInsert._({..._json, 'updated_at': null}); } -/// Values for updating rows of `books`. All columns are optional; passing `null` omits the column, leaving it unchanged. To write SQL NULL explicitly, set the raw key: `BooksUpdate()..['column_name'] = null`. +/// Values for updating rows of `books`. All columns are optional; passing `null` omits the column, leaving it unchanged. Use the `set…ToNull` methods to write SQL NULL explicitly. extension type const BooksUpdate._(Map _json) implements Map { BooksUpdate({ @@ -203,6 +258,45 @@ extension type const BooksUpdate._(Map _json) 'created_at': ?createdAt?.toUtc().toIso8601String(), 'updated_at': ?updatedAt?.toIso8601String(), }); + + /// Returns a copy with `price` set to SQL NULL, overriding any database default. + BooksUpdate setPriceToNull() => BooksUpdate._({..._json, 'price': null}); + + /// Returns a copy with `rating` set to SQL NULL, overriding any database default. + BooksUpdate setRatingToNull() => BooksUpdate._({..._json, 'rating': null}); + + /// Returns a copy with `in_print` set to SQL NULL, overriding any database default. + BooksUpdate setInPrintToNull() => BooksUpdate._({..._json, 'in_print': null}); + + /// Returns a copy with `mood` set to SQL NULL, overriding any database default. + BooksUpdate setMoodToNull() => BooksUpdate._({..._json, 'mood': null}); + + /// Returns a copy with `tags` set to SQL NULL, overriding any database default. + BooksUpdate setTagsToNull() => BooksUpdate._({..._json, 'tags': null}); + + /// Returns a copy with `page_counts` set to SQL NULL, overriding any database default. + BooksUpdate setPageCountsToNull() => + BooksUpdate._({..._json, 'page_counts': null}); + + /// Returns a copy with `metadata` set to SQL NULL, overriding any database default. + BooksUpdate setMetadataToNull() => + BooksUpdate._({..._json, 'metadata': null}); + + /// Returns a copy with `cover_uuid` set to SQL NULL, overriding any database default. + BooksUpdate setCoverUuidToNull() => + BooksUpdate._({..._json, 'cover_uuid': null}); + + /// Returns a copy with `published_on` set to SQL NULL, overriding any database default. + BooksUpdate setPublishedOnToNull() => + BooksUpdate._({..._json, 'published_on': null}); + + /// Returns a copy with `created_at` set to SQL NULL, overriding any database default. + BooksUpdate setCreatedAtToNull() => + BooksUpdate._({..._json, 'created_at': null}); + + /// Returns a copy with `updated_at` set to SQL NULL, overriding any database default. + BooksUpdate setUpdatedAtToNull() => + BooksUpdate._({..._json, 'updated_at': null}); } /// Typed access to the `books` table. From 7f69d9dcaa2e2897c9c261c5ca58a6db4e7cc7e6 Mon Sep 17 00:00:00 2001 From: Lukas Klingsbo Date: Thu, 23 Jul 2026 12:18:48 +0200 Subject: [PATCH 7/8] feat: mark typed table access as experimental in typegen output --- packages/supabase_typegen/lib/src/dart_generator.dart | 3 +++ .../supabase_typegen/test/generated_schema_behavior_test.dart | 3 +++ packages/supabase_typegen/test/goldens/supabase_schema.dart | 3 +++ 3 files changed, 9 insertions(+) diff --git a/packages/supabase_typegen/lib/src/dart_generator.dart b/packages/supabase_typegen/lib/src/dart_generator.dart index ba97d2333..316a9b2b6 100644 --- a/packages/supabase_typegen/lib/src/dart_generator.dart +++ b/packages/supabase_typegen/lib/src/dart_generator.dart @@ -32,6 +32,9 @@ String generateDartCode( ..writeln('//') ..writeln('// Source schema: ${schema.schemaName}') ..writeln() + ..writeln('// The typed table access API is still experimental.') + ..writeln('// ignore_for_file: experimental_member_use') + ..writeln() ..writeln("import '$importUri';") ..writeln(); diff --git a/packages/supabase_typegen/test/generated_schema_behavior_test.dart b/packages/supabase_typegen/test/generated_schema_behavior_test.dart index 50164f79f..2c749c1e2 100644 --- a/packages/supabase_typegen/test/generated_schema_behavior_test.dart +++ b/packages/supabase_typegen/test/generated_schema_behavior_test.dart @@ -1,3 +1,6 @@ +// The typed table access API under test is annotated @experimental. +// ignore_for_file: experimental_member_use + import 'dart:convert'; import 'package:http/http.dart'; diff --git a/packages/supabase_typegen/test/goldens/supabase_schema.dart b/packages/supabase_typegen/test/goldens/supabase_schema.dart index a581d6f21..e73e20e7f 100644 --- a/packages/supabase_typegen/test/goldens/supabase_schema.dart +++ b/packages/supabase_typegen/test/goldens/supabase_schema.dart @@ -2,6 +2,9 @@ // // Source schema: public +// The typed table access API is still experimental. +// ignore_for_file: experimental_member_use + import 'package:postgrest/postgrest.dart'; /// Postgres enum `public.mood`. From c795ded39171ac3d1ec615d350562c0de1c8d1d4 Mon Sep 17 00:00:00 2001 From: Lukas Klingsbo Date: Thu, 23 Jul 2026 13:52:10 +0200 Subject: [PATCH 8/8] chore: drop duplicate sdk-parse-ignore entry --- .sdk-parse-ignore | 4 ---- 1 file changed, 4 deletions(-) diff --git a/.sdk-parse-ignore b/.sdk-parse-ignore index 8e555b5ad..fe7ff1295 100644 --- a/.sdk-parse-ignore +++ b/.sdk-parse-ignore @@ -21,7 +21,3 @@ packages/supabase_typegen/ # The examples are standalone demo apps, not part of the published SDK, so their # public classes are not capability-matrix symbols. examples/ - -# supabase_typegen is a development-time code generator invoked through its CLI; -# its library API is tool internals, not SDK client surface. -packages/supabase_typegen/