diff --git a/typed_sql/CHANGELOG.md b/typed_sql/CHANGELOG.md index a4e2c875..7d3d1c88 100644 --- a/typed_sql/CHANGELOG.md +++ b/typed_sql/CHANGELOG.md @@ -1,3 +1,7 @@ +## 0.1.14 + * Support `brin`, `hash`, `gin`, `gist` and `spgist` indexes through `@Index` annotations' `method` field. + * Support covering (`INCLUDE`) columns through `@Index` annotations' `covering` field. + ## 0.1.13 * Support `CREATE INDEX` DDLs through `@Index` annotations. diff --git a/typed_sql/lib/src/codegen/build_code.dart b/typed_sql/lib/src/codegen/build_code.dart index 8abadec2..ebd0031d 100644 --- a/typed_sql/lib/src/codegen/build_code.dart +++ b/typed_sql/lib/src/codegen/build_code.dart @@ -433,6 +433,8 @@ Iterable buildTable(ParsedTable table, ParsedSchema schema) sync* { name: ${idx.name == null ? 'null' : '\'${idx.name}\''}, sqlName: ${idx.sqlName == null ? 'null' : '\'${idx.sqlName}\''}, columns: [${idx.fields.map((f) => '\'${f.sqlName}\'').join(', ')}], + method: .${idx.method.name}, + covering: [${idx.covering.map((f) => '\'${f.sqlName}\'').join(', ')}], ) ''').join(', ')} ], diff --git a/typed_sql/lib/src/codegen/parse_library.dart b/typed_sql/lib/src/codegen/parse_library.dart index 702167ef..5e2e4815 100644 --- a/typed_sql/lib/src/codegen/parse_library.dart +++ b/typed_sql/lib/src/codegen/parse_library.dart @@ -654,18 +654,36 @@ Future _parseRowClass( ); } - if (field.backingType == 'JsonValue') { + final method = ParsedIndexAccessMethod.values.firstWhere( + (m) => value.getField('_method')?.variable?.name == m.name, + orElse: () => .btree, + ); + + if (field.backingType == 'JsonValue' && method != .gin) { await throwInvalidAnnotationInSource( - 'JsonValue field cannot be used in an `Index` annotation', + 'JsonValue field cannot be used in an `Index` annotation, ' + 'unless `method: .gin` is given', annotatedElement: a, annotation: elementAnnotation, ); } + final covering = await _parseCoveringFields( + value: value, + fields: fields, + keyFields: [field], + method: method, + context: 'Index.field', + annotatedElement: a, + annotation: elementAnnotation, + ); + indexes.add( ParsedIndex( name: name, fields: [field], + method: method, + covering: covering, ), ); } @@ -706,6 +724,11 @@ Future _parseRowClass( ); } + final method = ParsedIndexAccessMethod.values.firstWhere( + (m) => a.getField('_method')?.variable?.name == m.name, + orElse: () => .btree, + ); + final indexFieldRefs = []; for (final fieldName in indexFields) { final field = fields.firstWhereOrNull((f) => f.name == fieldName); @@ -717,9 +740,10 @@ Future _parseRowClass( annotation: ea, ); } - if (field.backingType == 'JsonValue') { + if (field.backingType == 'JsonValue' && method != .gin) { await throwInvalidAnnotationInSource( - 'JsonValue field cannot be used in an `Index` annotation', + 'JsonValue field cannot be used in an `Index` annotation, ' + 'unless `method: .gin` is given', annotatedElement: cls, annotation: ea, ); @@ -727,10 +751,22 @@ Future _parseRowClass( indexFieldRefs.add(field); } + final covering = await _parseCoveringFields( + value: a, + fields: fields, + keyFields: indexFieldRefs, + method: method, + context: 'Index', + annotatedElement: cls, + annotation: ea, + ); + indexes.add( ParsedIndex( name: name == '-' ? null : name, fields: indexFieldRefs, + method: method, + covering: covering, ), ); } @@ -1035,6 +1071,66 @@ Future _parseReferentialAction( return first; } +/// Parses the `covering` field of an `Index`/`Index.field` annotation, +/// resolving field names and validating they exist, don't overlap the key +/// fields, and are only used with [ParsedIndexAccessMethod.btree] or +/// [ParsedIndexAccessMethod.gist]. +Future> _parseCoveringFields({ + required DartObject value, + required List fields, + required List keyFields, + required ParsedIndexAccessMethod method, + required String context, + required Element annotatedElement, + required ElementAnnotation annotation, +}) async { + final coveringNames = + value + .getField('_covering') + ?.toListValue() + ?.map( + (v) => v.toStringValue()!, + ) ?? + const []; + + if (coveringNames.isEmpty) { + return const []; + } + + if (method != ParsedIndexAccessMethod.btree && + method != ParsedIndexAccessMethod.gist) { + await throwInvalidAnnotationInSource( + '`$context(covering: ...)` is only supported for `method: .btree` or ' + '`method: .gist` indexes', + annotatedElement: annotatedElement, + annotation: annotation, + ); + } + + final coveringFields = []; + for (final fieldName in coveringNames) { + final field = fields.firstWhereOrNull((f) => f.name == fieldName); + if (field == null) { + await throwInvalidAnnotationInSource( + '`$context(covering: ...)` references unknown field "$fieldName", ' + 'no such field on row class.', + annotatedElement: annotatedElement, + annotation: annotation, + ); + } + if (keyFields.any((f) => f.name == fieldName)) { + await throwInvalidAnnotationInSource( + '`$context(covering: ...)` references field "$fieldName", which is ' + 'already part of the index key.', + annotatedElement: annotatedElement, + annotation: annotation, + ); + } + coveringFields.add(field); + } + return coveringFields; +} + String? _tryGetColumnType(DartType t) { if (t.isDartCoreBool) { return 'bool'; diff --git a/typed_sql/lib/src/codegen/parsed_library.dart b/typed_sql/lib/src/codegen/parsed_library.dart index 286bbd06..4bf8ef2f 100644 --- a/typed_sql/lib/src/codegen/parsed_library.dart +++ b/typed_sql/lib/src/codegen/parsed_library.dart @@ -130,14 +130,32 @@ final class ParsedUniqueConstraint { }); } +/// Parsed representation of [IndexAccessMethod]. +/// +/// This should always stay in sync with the [IndexAccessMethod] enum. +enum ParsedIndexAccessMethod { + brin, + btree, + gin, + gist, + hash, + spgist, +} + final class ParsedIndex { /// The user-provided name segment for the index, or `null` to derive it from the indexed columns. final String? name; final List fields; + final ParsedIndexAccessMethod method; + + /// Non-key columns included for index-only scans. + final List covering; ParsedIndex({ required this.name, required this.fields, + required this.method, + required this.covering, }); } diff --git a/typed_sql/lib/src/dialect/mysql_dialect.dart b/typed_sql/lib/src/dialect/mysql_dialect.dart index 2ffe7a34..8d2b6df7 100644 --- a/typed_sql/lib/src/dialect/mysql_dialect.dart +++ b/typed_sql/lib/src/dialect/mysql_dialect.dart @@ -186,7 +186,14 @@ final class _MysqlSqlDialect extends SqlDialect { ); }), // Indexes are emitted as separate statements after the tables. - ...statements.expand((table) => createIndexStatements(table, escape)), + ...statements.expand( + (table) => createIndexStatements( + table, + escape, + supportedMethods: {.hash}, + typeClausePosition: .beforeOn, + ), + ), ]; return ScriptSqlTask(sqlStatements.toList()); } diff --git a/typed_sql/lib/src/dialect/postgres_dialect.dart b/typed_sql/lib/src/dialect/postgres_dialect.dart index b9fe7b73..93584d6a 100644 --- a/typed_sql/lib/src/dialect/postgres_dialect.dart +++ b/typed_sql/lib/src/dialect/postgres_dialect.dart @@ -112,7 +112,14 @@ final class _PostgresDialect extends SqlDialect { ); }), // Indexes are emitted as separate statements after the tables. - ...statements.expand((table) => createIndexStatements(table, escape)), + ...statements.expand( + (table) => createIndexStatements( + table, + escape, + supportedMethods: {.brin, .gin, .gist, .hash, .spgist}, + supportsCoveringColumns: true, + ), + ), ]; if (resolver.context.parameters.isNotEmpty) { throw AssertionError('Parameters are not allowed in DDL'); diff --git a/typed_sql/lib/src/dialect/shared_dialect.dart b/typed_sql/lib/src/dialect/shared_dialect.dart index 2ef93847..daab1ce7 100644 --- a/typed_sql/lib/src/dialect/shared_dialect.dart +++ b/typed_sql/lib/src/dialect/shared_dialect.dart @@ -48,11 +48,28 @@ String foreignKeyConstraintName( ].join('_'); } +/// Where the `USING ...` index-type clause goes in `CREATE INDEX`; differs +/// by dialect. +enum IndexTypeClausePosition { + /// `... ON table USING method (columns)` (PostgreSQL). + afterOn, + + /// `... USING method ON table (columns)` (MySQL/MariaDB). + beforeOn, +} + /// Returns the `CREATE INDEX` statements for the indexes defined on [table]. +/// +/// [supportedMethods] are the [IndexAccessMethod]s supported beyond +/// [IndexAccessMethod.btree]; unsupported methods fall back to a plain index. +/// [supportsCoveringColumns] controls whether `INCLUDE` columns are emitted. Iterable createIndexStatements( CreateTableStatement table, - String Function(String) escape, -) { + String Function(String) escape, { + Set supportedMethods = const {}, + IndexTypeClausePosition typeClausePosition = .afterOn, + bool supportsCoveringColumns = false, +}) { return table.indexes.map((index) { final indexName = [ table.tableName, @@ -60,10 +77,32 @@ Iterable createIndexStatements( if (index.sqlName == null) ...index.columns else index.sqlName, ].join('_'); + final usingClause = switch (index.method) { + .brin when supportedMethods.contains(IndexAccessMethod.brin) => + 'USING BRIN', + .btree => null, + .gin when supportedMethods.contains(IndexAccessMethod.gin) => 'USING GIN', + .gist when supportedMethods.contains(IndexAccessMethod.gist) => + 'USING GIST', + .hash when supportedMethods.contains(IndexAccessMethod.hash) => + 'USING HASH', + .spgist when supportedMethods.contains(IndexAccessMethod.spgist) => + 'USING SPGIST', + _ => null, + }; + final beforeOnClause = typeClausePosition == .beforeOn ? usingClause : null; + final afterOnClause = typeClausePosition == .afterOn ? usingClause : null; + final covering = supportsCoveringColumns + ? index.covering + : const []; + return [ 'CREATE INDEX ${escape(indexName)}', + ?beforeOnClause, 'ON ${escape(table.tableName)}', + ?afterOnClause, '(${index.columns.map(escape).join(', ')})', + if (covering.isNotEmpty) 'INCLUDE (${covering.map(escape).join(', ')})', ].join(' '); }); } diff --git a/typed_sql/lib/src/typed_sql.annotations.dart b/typed_sql/lib/src/typed_sql.annotations.dart index a80d1e48..225757b2 100644 --- a/typed_sql/lib/src/typed_sql.annotations.dart +++ b/typed_sql/lib/src/typed_sql.annotations.dart @@ -268,6 +268,45 @@ final class Unique { const Unique.field({String? name}) : _name = name, _fields = null; } +/// The index access method used to build a database `INDEX`. +/// +/// {@category schema} +enum IndexAccessMethod { + /// Block Range Index (BRIN), for huge tables whose rows correlate with + /// physical storage order, e.g. an append-only `createdAt` column. + /// + /// Only supported by PostgreSQL; other dialects fall back to a plain index. + brin, + + /// Balanced tree (B-tree) index. The default, supported by all dialects. + btree, + + /// Generalized Inverted Index (GIN), for indexing composite values such + /// as JSON documents or arrays (e.g. containment queries `@>`, `?`, `?|`, + /// `?&`). + /// + /// Only supported by PostgreSQL; other dialects fall back to a plain index. + gin, + + /// Generalized Search Tree (GiST), for indexing geometric and range + /// types, or building exclusion constraints. + /// + /// Only supported by PostgreSQL; other dialects fall back to a plain index. + gist, + + /// Hash index, for equality lookups only (no ordering or range queries). + /// + /// Not supported by SQLite, which falls back to a plain index. + hash, + + /// Space-Partitioned GiST (SP-GiST), for non-balanced data structures + /// such as quad-trees, k-d trees, and radix trees (e.g. IP ranges or + /// phone number prefixes). + /// + /// Only supported by PostgreSQL; other dialects fall back to a plain index. + spgist, +} + /// Annotation to define a database `INDEX`. /// /// The index is emitted as a separate `CREATE INDEX` statement following the @@ -283,10 +322,16 @@ final class Index { final String? _name; // used by code-gen ('-' means derive from columns) // ignore: unused_field final List? _fields; // used by code-gen (null => field-level) + // ignore: unused_field + final IndexAccessMethod _method; // used by code-gen + // ignore: unused_field + final List _covering; // used by code-gen /// Add a composite index covering multiple [fields]. /// - /// If [name] is not given it'll be derived from indexed fields. + /// If [name] is not given it'll be derived from indexed fields. Use + /// [method] to pick an [IndexAccessMethod] and [covering] to add non-key + /// columns for index-only scans. /// /// **Example:** /// ```dart @@ -306,15 +351,18 @@ final class Index { const Index({ String? name, required List fields, + IndexAccessMethod method = .btree, + List covering = const [], }) : _name = name ?? '-', - _fields = fields; + _fields = fields, + _method = method, + _covering = covering; /// Add an index covering a single field. /// - /// To create a _composite index_, use the [Index] annotation at the - /// _row class_ level. - /// - /// If [name] is not given, it'll be derived from indexed field. + /// To create a _composite index_, use the [Index] annotation instead. Use + /// [method] to pick an [IndexAccessMethod] (e.g. `.gin` for a JSON field) + /// and [covering] to add non-key columns for index-only scans. /// /// **Example:** /// ```dart @@ -326,7 +374,14 @@ final class Index { /// String get email; /// } /// ``` - const Index.field({String? name}) : _name = name, _fields = null; + const Index.field({ + String? name, + IndexAccessMethod method = .btree, + List covering = const [], + }) : _name = name, + _fields = null, + _method = method, + _covering = covering; } /// Naming scheme for deriving SQL _table_ and _column_ names from Dart diff --git a/typed_sql/lib/src/typed_sql.dart b/typed_sql/lib/src/typed_sql.dart index e425de58..41421a79 100644 --- a/typed_sql/lib/src/typed_sql.dart +++ b/typed_sql/lib/src/typed_sql.dart @@ -132,10 +132,18 @@ final class IndexDefinition { final List columns; + /// The access method used to build this index. + final IndexAccessMethod method; + + /// Non-key columns included for index-only scans. + final List covering; + const IndexDefinition({ required this.name, required this.sqlName, required this.columns, + required this.method, + required this.covering, }); } @@ -478,11 +486,15 @@ final class $ForGeneratedCode { required String? name, required String? sqlName, required List columns, + required IndexAccessMethod method, + required List covering, }) { return IndexDefinition( name: name, sqlName: sqlName, columns: columns, + method: method, + covering: covering, ); } diff --git a/typed_sql/pubspec.yaml b/typed_sql/pubspec.yaml index 7828795c..8c968449 100644 --- a/typed_sql/pubspec.yaml +++ b/typed_sql/pubspec.yaml @@ -1,5 +1,5 @@ name: typed_sql -version: 0.1.13 +version: 0.1.14 description: Package for doing SQL with some type safety. homepage: https://github.com/google/dart-neats/tree/master/typed_sql repository: https://github.com/google/dart-neats.git diff --git a/typed_sql/test/codegen/index_test.dart b/typed_sql/test/codegen/index_test.dart index 26761466..187b1d8f 100644 --- a/typed_sql/test/codegen/index_test.dart +++ b/typed_sql/test/codegen/index_test.dart @@ -14,95 +14,99 @@ import 'test_code_generation.dart'; +/// Wraps [body] (field declarations, optionally `@Index...`-annotated) +/// inside the `BankVault`/`Account` schema boilerplate shared by (almost) +/// every test in this file. +/// +/// [classAnnotations], if given, is inserted directly above the `Account` +/// class declaration (e.g. for a class-level `@Index(...)` or +/// `@SqlOverride.table(...)`). +String schema({String classAnnotations = '', required String body}) => + ''' + abstract final class BankVault extends Schema { + Table get accounts; + } + + @PrimaryKey(['accountId']) + $classAnnotations + abstract final class Account extends Row { + int get accountId; + +$body + } +'''; + +/// All index access methods, and their quirks: whether each supports +/// indexing a `JsonValue` field, and whether each supports `covering` +/// columns. +const _methods = [ + (name: 'brin', allowsJson: false, allowsCovering: false), + (name: 'btree', allowsJson: false, allowsCovering: true), + (name: 'gin', allowsJson: true, allowsCovering: false), + (name: 'gist', allowsJson: false, allowsCovering: true), + (name: 'hash', allowsJson: false, allowsCovering: false), + (name: 'spgist', allowsJson: false, allowsCovering: false), +]; + void main() { testCodeGeneration( name: 'Index.field() works on single field', - source: r''' - abstract final class BankVault extends Schema { - Table get accounts; - } - - @PrimaryKey(['accountId']) - abstract final class Account extends Row { - int get accountId; - - @Index.field() - String get accountNumber; - } - ''', + source: schema( + body: ''' + @Index.field() + String get accountNumber; +''', + ), output: (s) => s.contains('indexDefinition'), ); testCodeGeneration( name: 'Index.field() covers the annotated column', - source: r''' - abstract final class BankVault extends Schema { - Table get accounts; - } - - @PrimaryKey(['accountId']) - abstract final class Account extends Row { - int get accountId; - - @Index.field() - String get accountNumber; - } - ''', + source: schema( + body: ''' + @Index.field() + String get accountNumber; +''', + ), output: (s) => s.contains("columns: ['accountNumber']"), ); testCodeGeneration( name: 'Index.field() does NOT generate a by lookup method', - source: r''' - abstract final class BankVault extends Schema { - Table get accounts; - } - - @PrimaryKey(['accountId']) - abstract final class Account extends Row { - int get accountId; - - @Index.field() - String get accountNumber; - } - ''', + source: schema( + body: ''' + @Index.field() + String get accountNumber; +''', + ), output: (s) => s.not((s) => s.contains('byAccountNumber')), ); testCodeGeneration( name: 'Index() on row class works', - source: r''' - abstract final class BankVault extends Schema { - Table get accounts; - } - - @PrimaryKey(['accountId']) - @Index(name: 'ownerName', fields: ['lastName', 'firstName']) - abstract final class Account extends Row { - int get accountId; - String get firstName; - String get lastName; - } - ''', + source: schema( + classAnnotations: + "@Index(name: 'ownerName', fields: ['lastName', " + "'firstName'])", + body: ''' + String get firstName; + String get lastName; +''', + ), output: (s) => s.contains("name: 'ownerName'"), ); testCodeGeneration( name: 'Index() name is converted using the schema naming rules', - source: r''' - abstract final class BankVault extends Schema { - Table get accounts; - } - - @SqlOverride.table(naming: .snake_case) - @PrimaryKey(['accountId']) - @Index(name: 'ownerName', fields: ['lastName', 'firstName']) - abstract final class Account extends Row { - int get accountId; - String get firstName; - String get lastName; - } - ''', + source: schema( + classAnnotations: ''' +@SqlOverride.table(naming: .snake_case) + @Index(name: 'ownerName', fields: ['lastName', 'firstName'])''', + body: ''' + String get firstName; + String get lastName; +''', + ), output: (s) { // The raw name is preserved, and `sqlName` carries the converted name. s.contains("name: 'ownerName'"); @@ -112,20 +116,15 @@ void main() { testCodeGeneration( name: 'Multiple Index() annotations on a row class are allowed', - source: r''' - abstract final class BankVault extends Schema { - Table get accounts; - } - - @PrimaryKey(['accountId']) - @Index(fields: ['firstName']) - @Index(fields: ['lastName']) - abstract final class Account extends Row { - int get accountId; - String get firstName; - String get lastName; - } - ''', + source: schema( + classAnnotations: ''' +@Index(fields: ['firstName']) + @Index(fields: ['lastName'])''', + body: ''' + String get firstName; + String get lastName; +''', + ), output: (s) { s.contains("columns: ['firstName']"); s.contains("columns: ['lastName']"); @@ -134,19 +133,12 @@ void main() { testCodeGeneration( name: 'Index() cannot be used on fields', - source: r''' - abstract final class BankVault extends Schema { - Table get accounts; - } - - @PrimaryKey(['accountId']) - abstract final class Account extends Row { - int get accountId; - - @Index(fields: ['accountNumber']) - String get accountNumber; - } - ''', + source: schema( + body: ''' + @Index(fields: ['accountNumber']) + String get accountNumber; +''', + ), error: (s) => s.contains( '`Index()` cannot be used on fields, use `Index.field()` instead', ), @@ -154,18 +146,10 @@ void main() { testCodeGeneration( name: 'Index.field() cannot be used on classes', - source: r''' - abstract final class BankVault extends Schema { - Table get accounts; - } - - @PrimaryKey(['accountId']) - @Index.field() - abstract final class Account extends Row { - int get accountId; - String get accountNumber; - } - ''', + source: schema( + classAnnotations: '@Index.field()', + body: ' String get accountNumber;', + ), error: (s) => s.contains( '`Index.field()` cannot be used on classes, use `Index()` instead', ), @@ -173,18 +157,11 @@ void main() { testCodeGeneration( name: 'Index(name: "hello world") is an invalid identifier', - source: r''' - abstract final class BankVault extends Schema { - Table get accounts; - } - - @PrimaryKey(['accountId']) - @Index(name: 'hello world', fields: ['accountNumber']) - abstract final class Account extends Row { - int get accountId; - String get accountNumber; - } - ''', + source: schema( + classAnnotations: + "@Index(name: 'hello world', fields: ['accountNumber'])", + body: ' String get accountNumber;', + ), error: (s) => s.contains( '`Index(name: "hello world")`: name is not a valid identifier', ), @@ -192,57 +169,245 @@ void main() { testCodeGeneration( name: 'Fields are required in @Index(fields: [])', - source: r''' - abstract final class BankVault extends Schema { - Table get accounts; - } - - @PrimaryKey(['accountId']) - @Index(fields: []) - abstract final class Account extends Row { - int get accountId; - String get accountNumber; - } - ''', + source: schema( + classAnnotations: '@Index(fields: [])', + body: ' String get accountNumber;', + ), error: (s) => s.contains('`Index()` annotation must have non-empty `fields`'), ); testCodeGeneration( name: 'Unknown field in @Index(fields: [...])', - source: r''' - abstract final class BankVault extends Schema { - Table get accounts; - } - - @PrimaryKey(['accountId']) - @Index(fields: ['noSuchField']) - abstract final class Account extends Row { - int get accountId; - String get accountNumber; - } - ''', + source: schema( + classAnnotations: "@Index(fields: ['noSuchField'])", + body: ' String get accountNumber;', + ), error: (s) => s.contains( '`Index()` annotation references unknown field "noSuchField"', ), ); testCodeGeneration( - name: 'Index is not allowed on JsonValue', - source: r''' - abstract final class BankVault extends Schema { - Table get accounts; - } + name: 'Index.field() without method defaults to btree', + source: schema( + body: ''' + @Index.field() + String get accountNumber; +''', + ), + output: (s) => s.contains('method: .btree'), + ); - @PrimaryKey(['accountId']) - abstract final class Account extends Row { - int get accountId; + // Every IndexAccessMethod shares the same shape of tests: works as a + // single-field and composite index, its JsonValue support (only `.gin` + // allows it), and its `covering` support (only `.btree` and `.gist` allow + // it). + for (final m in _methods) { + testCodeGeneration( + name: 'Index.field(method: .${m.name}) is allowed on a regular field', + source: schema( + body: + ''' + @Index.field(method: .${m.name}) + String get accountNumber; +''', + ), + output: (s) { + s.contains("columns: ['accountNumber']"); + s.contains('method: .${m.name}'); + }, + ); - @Index.field() - JsonValue get metadata; - } - ''', - error: (s) => - s.contains('JsonValue field cannot be used in an `Index` annotation'), + testCodeGeneration( + name: + 'Index(fields: [...], method: .${m.name}) is allowed on regular ' + 'fields', + source: schema( + classAnnotations: + "@Index(fields: ['firstName', 'lastName'], method: .${m.name})", + body: ''' + String get firstName; + String get lastName; +''', + ), + output: (s) { + s.contains("columns: ['firstName', 'lastName']"); + s.contains('method: .${m.name}'); + }, + ); + + testCodeGeneration( + name: + 'Index.field(method: .${m.name}) ' + '${m.allowsJson ? 'is allowed' : 'is not allowed'} ' + 'on JsonValue fields', + source: schema( + body: + ''' + @Index.field(method: .${m.name}) + JsonValue get metadata; +''', + ), + output: m.allowsJson + ? (s) { + s.contains("columns: ['metadata']"); + s.contains('method: .${m.name}'); + } + : null, + error: m.allowsJson + ? null + : (s) => s.contains( + 'JsonValue field cannot be used in an `Index` annotation', + ), + ); + + testCodeGeneration( + name: + 'Index(fields: [...], method: .${m.name}) ' + '${m.allowsJson ? 'is allowed' : 'is not allowed'} ' + 'on JsonValue fields', + source: schema( + classAnnotations: "@Index(fields: ['metadata'], method: .${m.name})", + body: ' JsonValue get metadata;', + ), + output: m.allowsJson + ? (s) { + s.contains("columns: ['metadata']"); + s.contains('method: .${m.name}'); + } + : null, + error: m.allowsJson + ? null + : (s) => s.contains( + 'JsonValue field cannot be used in an `Index` annotation', + ), + ); + + testCodeGeneration( + name: + 'covering: [...] ' + '${m.allowsCovering ? 'is allowed' : 'is not allowed'} ' + 'together with method: .${m.name}', + source: schema( + body: + ''' + @Index.field(method: .${m.name}, covering: ['balance']) + String get accountNumber; + + int get balance; +''', + ), + output: m.allowsCovering + ? (s) { + s.contains('method: .${m.name}'); + s.contains("covering: ['balance']"); + } + : null, + error: m.allowsCovering + ? null + : (s) => s.contains( + '`Index.field(covering: ...)` is only supported for ' + '`method: .btree` or `method: .gist` indexes', + ), + ); + } + + testCodeGeneration( + name: 'Index.field(covering: [...]) covers additional columns', + source: schema( + body: ''' + @Index.field(covering: ['balance']) + String get accountNumber; + + int get balance; +''', + ), + output: (s) { + s.contains("columns: ['accountNumber']"); + s.contains("covering: ['balance']"); + }, + ); + + testCodeGeneration( + name: 'Index(fields: [...], covering: [...]) covers additional columns', + source: schema( + classAnnotations: + "@Index(fields: ['lastName', 'firstName'], covering: ['balance'])", + body: ''' + String get firstName; + String get lastName; + int get balance; +''', + ), + output: (s) { + s.contains("columns: ['lastName', 'firstName']"); + s.contains("covering: ['balance']"); + }, + ); + + testCodeGeneration( + name: 'Index without covering: [...] defaults to an empty list', + source: schema( + body: ''' + @Index.field() + String get accountNumber; +''', + ), + output: (s) => s.contains('covering: []'), + ); + + testCodeGeneration( + name: 'Index.field(covering: [...]) rejects unknown field', + source: schema( + body: ''' + @Index.field(covering: ['noSuchField']) + String get accountNumber; +''', + ), + error: (s) => s.contains( + '`Index.field(covering: ...)` references unknown field "noSuchField"', + ), + ); + + testCodeGeneration( + name: 'Index.field(covering: [...]) rejects field already in the key', + source: schema( + body: ''' + @Index.field(covering: ['accountNumber']) + String get accountNumber; +''', + ), + error: (s) => s.contains( + '`Index.field(covering: ...)` references field "accountNumber", ' + 'which is already part of the index key.', + ), + ); + + testCodeGeneration( + name: 'Index(covering: [...]) rejects field already in the key', + source: schema( + classAnnotations: + "@Index(fields: ['firstName'], covering: " + "['firstName'])", + body: ' String get firstName;', + ), + error: (s) => s.contains( + '`Index(covering: ...)` references field "firstName", ' + 'which is already part of the index key.', + ), + ); + + testCodeGeneration( + name: 'covering: [...] on a JsonValue field is allowed', + source: schema( + body: ''' + @Index.field(covering: ['metadata']) + String get accountNumber; + + JsonValue get metadata; +''', + ), + output: (s) => s.contains("covering: ['metadata']"), ); } diff --git a/typed_sql/test/sqlite/model.g.dart b/typed_sql/test/sqlite/model.g.dart index 09f36696..17adfa6e 100644 --- a/typed_sql/test/sqlite/model.g.dart +++ b/typed_sql/test/sqlite/model.g.dart @@ -670,11 +670,15 @@ final class _$Package extends Package { name: null, sqlName: null, columns: ['ownerId'], + method: .btree, + covering: [], ), $ForGeneratedCode.indexDefinition( name: null, sqlName: null, columns: ['ownerId', 'publisher'], + method: .btree, + covering: [], ), ], readRow: _$Package._$fromDatabase, diff --git a/typed_sql/test/typed_sql/index/brin_index/brin_index_ddl_test.dart b/typed_sql/test/typed_sql/index/brin_index/brin_index_ddl_test.dart new file mode 100644 index 00000000..2b454840 --- /dev/null +++ b/typed_sql/test/typed_sql/index/brin_index/brin_index_ddl_test.dart @@ -0,0 +1,49 @@ +// Copyright 2026 Google LLC +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// https://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +import 'package:checks/checks.dart'; +import 'package:test/test.dart'; + +import '../index_ddl_helpers.dart'; +import 'brin_index_test.dart' hide main; + +void main() { + test('Postgres emits `USING BRIN` for method: .brin indexes', () async { + final ddl = await capturePostgresDdl( + (adapter, db) => db.createTables(), + ); + if (ddl == null) return; + + check(ddl).contains('USING BRIN ("createdAt")'); + }); + + test('SQLite drops BRIN and falls back to a plain index', () async { + final ddl = await captureSqliteDdl( + (adapter, db) => db.createTables(), + ); + + check(ddl).contains('CREATE INDEX'); + check(ddl).not((d) => d.contains('USING BRIN')); + }); + + test('MySQL/MariaDB drops BRIN and falls back to a plain index', () async { + final ddl = await captureMariadbDdl( + (adapter, db) => db.createTables(), + ); + if (ddl == null) return; + + check(ddl).contains('CREATE INDEX'); + check(ddl).not((d) => d.contains('BRIN')); + }); +} diff --git a/typed_sql/test/typed_sql/index/brin_index/brin_index_test.dart b/typed_sql/test/typed_sql/index/brin_index/brin_index_test.dart new file mode 100644 index 00000000..0df35ee8 --- /dev/null +++ b/typed_sql/test/typed_sql/index/brin_index/brin_index_test.dart @@ -0,0 +1,49 @@ +// Copyright 2026 Google LLC +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// https://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +import 'package:typed_sql/typed_sql.dart'; +import '../../testrunner.dart'; + +part 'brin_index_test.g.dart'; + +abstract final class EventLog extends Schema { + Table get events; +} + +@PrimaryKey(['id']) +abstract final class Event extends Row { + @AutoIncrement() + int get id; + + @Index.field(method: .brin) + DateTime get createdAt; +} + +void main() { + final r = TestRunner( + setup: (db) async { + await db.createTables(); + }, + ); + + r.addTest('createTables() succeeds with a BRIN index', (db) async { + final now = DateTime.utc(2026, 7, 27); + await db.events.insertValue(createdAt: now).execute(); + + final item = await db.events.first.fetch(); + check(item).isNotNull().createdAt.equals(now); + }); + + r.run(); +} diff --git a/typed_sql/test/typed_sql/index/brin_index/brin_index_test.g.dart b/typed_sql/test/typed_sql/index/brin_index/brin_index_test.g.dart new file mode 100644 index 00000000..c9f93c17 --- /dev/null +++ b/typed_sql/test/typed_sql/index/brin_index/brin_index_test.g.dart @@ -0,0 +1,496 @@ +// GENERATED CODE - DO NOT MODIFY BY HAND + +part of 'brin_index_test.dart'; + +// ************************************************************************** +// Generator: _TypedSqlBuilder +// ************************************************************************** + +/// Extension methods for a [Database] operating on [EventLog]. +extension EventLogSchema on Database { + static final _$tables = [_$Event._$table]; + + Table get events => + $ForGeneratedCode.declareTable(this, _$Event._$table); + + /// Create tables defined in [EventLog]. + /// + /// Calling this on an empty database will create the tables + /// defined in [EventLog]. In production it's often better to + /// use [createEventLogTables] and manage migrations using + /// external tools. + /// + /// This method is mostly useful for testing. + /// + /// > [!WARNING] + /// > If the database is **not empty** behavior is undefined, most + /// > likely this operation will fail. + Future createTables() async => + $ForGeneratedCode.createTables(context: this, tables: _$tables); +} + +/// Get SQL [DDL statements][1] for tables defined in [EventLog]. +/// +/// This returns a SQL script with multiple DDL statements separated by `;` +/// using the specified [dialect]. +/// +/// Executing these statements in an empty database will create the tables +/// defined in [EventLog]. In practice, this method is often used for +/// printing the DDL statements, such that migrations can be managed by +/// external tools. +/// +/// [1]: https://en.wikipedia.org/wiki/Data_definition_language +String createEventLogTables(SqlDialect dialect) => $ForGeneratedCode + .createTableSchema(dialect: dialect, tables: EventLogSchema._$tables); + +final class _$Event extends Event { + _$Event._(this.id, this.createdAt); + + @override + final int id; + + @override + final DateTime createdAt; + + static final _$table = $ForGeneratedCode.tableDefinition( + tableName: 'events', + columns: ['id', 'createdAt'], + columnInfo: [ + $ForGeneratedCode.columnDefinition( + type: $ForGeneratedCode.integer, + isNotNull: true, + defaultValue: null, + autoIncrement: true, + overrides: [], + ), + $ForGeneratedCode.columnDefinition( + type: $ForGeneratedCode.dateTime, + isNotNull: true, + defaultValue: null, + autoIncrement: false, + overrides: [], + ), + ], + primaryKey: ['id'], + unique: >[], + foreignKeys: [], + indexes: [ + $ForGeneratedCode.indexDefinition( + name: null, + sqlName: null, + columns: ['createdAt'], + method: .brin, + covering: [], + ), + ], + readRow: _$Event._$fromDatabase, + ); + + static Event? _$fromDatabase(RowReader row) { + final id = row.readInt(); + final createdAt = row.readDateTime(); + if (id == null && createdAt == null) { + return null; + } + return _$Event._(id!, createdAt!); + } + + @override + String toString() => 'Event(id: "$id", createdAt: "$createdAt")'; +} + +/// Extension methods for table defined in [Event]. +extension TableEventExt on Table { + /// Insert row into the `events` table. + /// + /// Returns a [InsertSingle] statement on which `.execute` must be + /// called for the row to be inserted. + InsertSingle insert({ + Expr? id, + required Expr createdAt, + }) => $ForGeneratedCode.insertInto(table: this, values: [id, createdAt]); + + /// Insert row into the `events` table. + /// + /// Returns a [InsertSingle] statement on which `.execute` must be + /// called for the row to be inserted. + InsertSingle insertValue({int? id, required DateTime createdAt}) => + $ForGeneratedCode.insertInto( + table: this, + values: [id?.asExpr, createdAt.asExpr], + ); + + /// Bulk insert rows into the `events` table. + /// + /// This method takes an `Iterable` and requires that you provide + /// a _mapping function_ from `T` to each column to be inserted. + /// + /// If a mapping function is omitted, the _default value_ will be + /// inserted, or `NULL` if column is nullable and as no default value. + /// To explicitely insert `NULL`, use a _mapping function_ that maps + /// `T` to `null`. + /// + /// > [!NOTE] + /// > This method aims utilize database specific bulk insertion logic + /// > to ensure good performance. Database adapters may pipeline bulk + /// > insertions through multiple statements inside a transaction. + /// + /// Returns a [Insert] statement on which `.execute` must be + /// called for the rows to be inserted. + Insert insertValuesMapped( + Iterable rows, { + int Function(T row)? id, + required DateTime Function(T row) createdAt, + }) => $ForGeneratedCode.insertValuesMapped( + table: this, + rows: rows, + mappings: [id, createdAt], + ); + + /// Delete a single row from the `events` table, specified by + /// _primary key_. + /// + /// Returns a [DeleteSingle] statement on which `.execute()` must be + /// called for the row to be deleted. + /// + /// To delete multiple rows, using `.where()` to filter which rows + /// should be deleted. If you wish to delete all rows, use + /// `.where((_) => toExpr(true)).delete()`. + DeleteSingle delete(int id) => + $ForGeneratedCode.deleteSingle(byKey(id), _$Event._$table); +} + +/// Extension methods for building queries against the `events` table. +extension QueryEventExt on Query<(Expr,)> { + /// Lookup a single row in `events` table using the _primary key_. + /// + /// Returns a [QuerySingle] object, which returns at-most one row, + /// when `.fetch()` is called. + QuerySingle<(Expr,)> byKey(int id) => + where((event) => event.id.equalsValue(id)).first; + + /// Update all rows in the `events` table matching this [Query]. + /// + /// The changes to be applied to each row matching this [Query] are + /// defined using the [updateBuilder], which is given an [Expr] + /// representation of the row being updated and a `set` function to + /// specify which fields should be updated. The result of the `set` + /// function should always be returned from the `updateBuilder`. + /// + /// Returns an [Update] statement on which `.execute()` must be called + /// for the rows to be updated. + /// + /// **Example:** decrementing `1` from the `value` field for each row + /// where `value > 0`. + /// ```dart + /// await db.mytable + /// .where((row) => row.value > toExpr(0)) + /// .update((row, set) => set( + /// value: row.value - toExpr(1), + /// )) + /// .execute(); + /// ``` + /// + /// > [!WARNING] + /// > The `updateBuilder` callback does not make the update, it builds + /// > the expressions for updating the rows. You should **never** invoke + /// > the `set` function more than once, and the result should always + /// > be returned immediately. + Update update( + UpdateSet Function( + Expr event, + UpdateSet Function({Expr id, Expr createdAt}) set, + ) + updateBuilder, + ) => $ForGeneratedCode.update( + this, + _$Event._$table, + (event) => updateBuilder( + event, + ({Expr? id, Expr? createdAt}) => + $ForGeneratedCode.buildUpdate([id, createdAt]), + ), + ); + + /// Delete all rows in the `events` table matching this [Query]. + /// + /// Returns a [Delete] statement on which `.execute()` must be called + /// for the rows to be deleted. + Delete delete() => $ForGeneratedCode.delete(this, _$Event._$table); +} + +/// Extension methods for building point queries against the `events` table. +extension QuerySingleEventExt on QuerySingle<(Expr,)> { + /// Update the row (if any) in the `events` table matching this + /// [QuerySingle]. + /// + /// The changes to be applied to the row matching this [QuerySingle] are + /// defined using the [updateBuilder], which is given an [Expr] + /// representation of the row being updated and a `set` function to + /// specify which fields should be updated. The result of the `set` + /// function should always be returned from the `updateBuilder`. + /// + /// Returns an [UpdateSingle] statement on which `.execute()` must be + /// called for the row to be updated. The resulting statement will + /// **not** fail, if there are no rows matching this query exists. + /// + /// **Example:** decrementing `1` from the `value` field the row with + /// `id = 1`. + /// ```dart + /// await db.mytable + /// .byKey(1) + /// .update((row, set) => set( + /// value: row.value - toExpr(1), + /// )) + /// .execute(); + /// ``` + /// + /// > [!WARNING] + /// > The `updateBuilder` callback does not make the update, it builds + /// > the expressions for updating the rows. You should **never** invoke + /// > the `set` function more than once, and the result should always + /// > be returned immediately. + UpdateSingle update( + UpdateSet Function( + Expr event, + UpdateSet Function({Expr id, Expr createdAt}) set, + ) + updateBuilder, + ) => $ForGeneratedCode.updateSingle( + this, + _$Event._$table, + (event) => updateBuilder( + event, + ({Expr? id, Expr? createdAt}) => + $ForGeneratedCode.buildUpdate([id, createdAt]), + ), + ); + + /// Delete the row (if any) in the `events` table matching this [QuerySingle]. + /// + /// Returns a [DeleteSingle] statement on which `.execute()` must be called + /// for the row to be deleted. The resulting statement will **not** + /// fail, if there are no rows matching this query exists. + DeleteSingle delete() => + $ForGeneratedCode.deleteSingle(this, _$Event._$table); +} + +/// Extension methods for expressions on a row in the `events` table. +extension ExpressionEventExt on Expr { + Expr get id => + $ForGeneratedCode.field(this, 0, $ForGeneratedCode.integer); + + Expr get createdAt => + $ForGeneratedCode.field(this, 1, $ForGeneratedCode.dateTime); +} + +extension ExpressionNullableEventExt on Expr { + Expr get id => + $ForGeneratedCode.field(this, 0, $ForGeneratedCode.integer); + + Expr get createdAt => + $ForGeneratedCode.field(this, 1, $ForGeneratedCode.dateTime); + + /// Check if the row is not `NULL`. + /// + /// This will check if _primary key_ fields in this row are `NULL`. + /// + /// If this is a reference lookup by subquery it might be more efficient + /// to check if the referencing field is `NULL`. + Expr isNotNull() => id.isNotNull(); + + /// Check if the row is `NULL`. + /// + /// This will check if _primary key_ fields in this row are `NULL`. + /// + /// If this is a reference lookup by subquery it might be more efficient + /// to check if the referencing field is `NULL`. + Expr isNull() => isNotNull().not(); +} + +/// `Table` conflict targets for use with `.onConflict`. +enum EventConflict { + /// Conflict with an existing row that has a matching primary key. + /// + /// Thus, the other row has matching values for: + /// `id`. + primaryKey(['id']); + + const EventConflict(this._fields); + + final List _fields; +} + +extension InsertEventExt on Insert { + /// Build an `INSERT` statement with an `ON CONFLICT` clause. + /// + /// The [target] argument specifies the _conflict target_ to be + /// handled. The _conflict target_ is always a `UNIQUE` constraint or + /// `PRIMARY KEY` constraint. + /// + /// If a row to be inserted violates the _conflict target_ constraint, + /// then the conflict action is triggered: + /// * `.doNothing()` to skip insertion of the new row, and, + /// * `.update((event, excluded, set) => set(...))` to + /// update the conflicting row. + /// + /// If a row to be inserted violates a constraint other than the one + /// specified in _conflict target_ then the entire `INSERT` statement + /// will fail. + /// + /// This is equivalent to `INSERT ... ON CONFLICT (...)` in SQL. + InsertOnConflict onConflict(EventConflict target) => + $ForGeneratedCode.insertOnConflict(this, target._fields); +} + +extension InsertOnConflictEventExt on InsertOnConflict { + /// Build an `INSERT` statement an [upsert-clause][1]. + /// + /// When a row to be inserted violates the `UNIQUE` or `PRIMARY KEY` + /// constraint previously specified as _conflict target_, the existing + /// row is updated using the expressions defined with the + /// [updateBuilder]. The [updateBuilder] is given 3 parameters: + /// * `event` an [Expr] representing the existing row in + /// the database, + /// * `excluded` an [Expr] representing the row to be inserted in the + /// database, and, + /// * `set` a function to specify which fields should be updated and + /// build the [UpdateSet]. + /// + /// The result of the `set` function should always be immediately + /// returned from the [updateBuilder]. + /// + /// **Example:** Insert a counter with `count = 2` or increment the + /// existing row, if a `PRIMARY KEY` conflict occurs. + /// ```dart + /// await db.counters.insertValue( + /// name: 'my-counter', // primary key + /// count: 2, + /// ) + /// .onConflict(.primaryKey) + /// .update((counter, excluded, set) => set( + /// count: counter.count + excluded.count, + /// )) + /// .execute(); + /// ``` + /// + /// This is equivalent to + /// `INSERT ... ON CONFLICT (...) UPDATE SET ...` in SQL. + /// + /// > [!WARNING] + /// > The `updateBuilder` callback does not make the update, it builds + /// > the expressions for updating the rows. You should **never** invoke + /// > the `set` function more than once, and the result should always + /// > be returned immediately. + /// + /// [1]: https://www.sqlite.org/lang_upsert.html + Upsert update( + UpdateSet Function( + Expr event, + Expr excluded, + UpdateSet Function({Expr id, Expr createdAt}) set, + ) + updateBuilder, + ) => $ForGeneratedCode.updateOnConflict( + this, + (event, excluded) => updateBuilder( + event, + excluded, + ({Expr? id, Expr? createdAt}) => + $ForGeneratedCode.buildUpdate([id, createdAt]), + ), + ); +} + +extension InsertSingleEventExt on InsertSingle { + /// Build an `INSERT` statement with an `ON CONFLICT` clause. + /// + /// The [target] argument specifies the _conflict target_ to be + /// handled. The _conflict target_ is always a `UNIQUE` constraint or + /// `PRIMARY KEY` constraint. + /// + /// If a row to be inserted violates the _conflict target_ constraint, + /// then the conflict action is triggered: + /// * `.doNothing()` to skip insertion of the new row, and, + /// * `.update((event, excluded, set) => set(...))` to + /// update the conflicting row. + /// + /// If a row to be inserted violates a constraint other than the one + /// specified in _conflict target_ then the entire `INSERT` statement + /// will fail. + /// + /// This is equivalent to `INSERT ... ON CONFLICT (...)` in SQL. + InsertOnConflictSingle onConflict(EventConflict target) => + $ForGeneratedCode.insertOnConflictSingle(this, target._fields); +} + +extension InsertOnConflictSingleEventExt on InsertOnConflictSingle { + /// Build an `INSERT` statement an [upsert-clause][1]. + /// + /// When a row to be inserted violates the `UNIQUE` or `PRIMARY KEY` + /// constraint previously specified as _conflict target_, the existing + /// row is updated using the expressions defined with the + /// [updateBuilder]. The [updateBuilder] is given 3 parameters: + /// * `event` an [Expr] representing the existing row in + /// the database, + /// * `excluded` an [Expr] representing the row to be inserted in the + /// database, and, + /// * `set` a function to specify which fields should be updated and + /// build the [UpdateSet]. + /// + /// The result of the `set` function should always be immediately + /// returned from the [updateBuilder]. + /// + /// **Example:** Insert a counter with `count = 2` or increment the + /// existing row, if a `PRIMARY KEY` conflict occurs. + /// ```dart + /// await db.counters.insertValue( + /// name: 'my-counter', // primary key + /// count: 2, + /// ) + /// .onConflict(.primaryKey) + /// .update((counter, excluded, set) => set( + /// count: counter.count + excluded.count, + /// )) + /// .execute(); + /// ``` + /// + /// This is equivalent to + /// `INSERT ... ON CONFLICT (...) UPDATE SET ...` in SQL. + /// + /// > [!WARNING] + /// > The `updateBuilder` callback does not make the update, it builds + /// > the expressions for updating the rows. You should **never** invoke + /// > the `set` function more than once, and the result should always + /// > be returned immediately. + /// + /// [1]: https://www.sqlite.org/lang_upsert.html + UpsertSingle update( + UpdateSet Function( + Expr event, + Expr excluded, + UpdateSet Function({Expr id, Expr createdAt}) set, + ) + updateBuilder, + ) => $ForGeneratedCode.updateOnConflictSingle( + this, + (event, excluded) => updateBuilder( + event, + excluded, + ({Expr? id, Expr? createdAt}) => + $ForGeneratedCode.buildUpdate([id, createdAt]), + ), + ); +} + +/// Extension methods for assertions on [Event] using +/// [`package:checks`][1]. +/// +/// [1]: https://pub.dev/packages/checks +extension EventChecks on Subject { + /// Create assertions on [Event.id]. + Subject get id => has((m) => m.id, 'id'); + + /// Create assertions on [Event.createdAt]. + Subject get createdAt => has((m) => m.createdAt, 'createdAt'); +} diff --git a/typed_sql/test/typed_sql/index/covering_index/covering_index_ddl_test.dart b/typed_sql/test/typed_sql/index/covering_index/covering_index_ddl_test.dart new file mode 100644 index 00000000..2a6eaacf --- /dev/null +++ b/typed_sql/test/typed_sql/index/covering_index/covering_index_ddl_test.dart @@ -0,0 +1,50 @@ +// Copyright 2026 Google LLC +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// https://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +import 'package:checks/checks.dart'; +import 'package:test/test.dart'; + +import '../index_ddl_helpers.dart'; +import 'covering_index_test.dart' hide main; + +void main() { + test('Postgres emits `INCLUDE` for covering columns', () async { + final ddl = await capturePostgresDdl( + (adapter, db) => db.createTables(), + ); + if (ddl == null) return; + + check(ddl).contains('("lastName") INCLUDE ("email")'); + check(ddl).contains('("phone") INCLUDE ("lastName")'); + }); + + test('SQLite drops covering columns', () async { + final ddl = await captureSqliteDdl( + (adapter, db) => db.createTables(), + ); + + check(ddl).contains('CREATE INDEX'); + check(ddl).not((d) => d.contains('INCLUDE')); + }); + + test('MySQL/MariaDB drops covering columns', () async { + final ddl = await captureMariadbDdl( + (adapter, db) => db.createTables(), + ); + if (ddl == null) return; + + check(ddl).contains('CREATE INDEX'); + check(ddl).not((d) => d.contains('INCLUDE')); + }); +} diff --git a/typed_sql/test/typed_sql/index/covering_index/covering_index_test.dart b/typed_sql/test/typed_sql/index/covering_index/covering_index_test.dart new file mode 100644 index 00000000..2de711c3 --- /dev/null +++ b/typed_sql/test/typed_sql/index/covering_index/covering_index_test.dart @@ -0,0 +1,67 @@ +// Copyright 2026 Google LLC +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// https://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +import 'package:typed_sql/typed_sql.dart'; +import '../../testrunner.dart'; + +part 'covering_index_test.g.dart'; + +abstract final class Directory extends Schema { + Table get contacts; +} + +@PrimaryKey(['id']) +@Index(fields: ['lastName'], covering: ['email']) +abstract final class Contact extends Row { + @AutoIncrement() + int get id; + + @SqlOverride.field(dialect: 'mysql', columnType: 'VARCHAR(255)') + String get lastName; + String get firstName; + + @Index.field(covering: ['lastName']) + @SqlOverride.field(dialect: 'mysql', columnType: 'VARCHAR(255)') + String get phone; + + String get email; +} + +void main() { + final r = TestRunner( + setup: (db) async { + await db.createTables(); + }, + ); + + r.addTest('createTables() succeeds with covering columns', (db) async { + await db.contacts + .insertValue( + lastName: 'Doe', + firstName: 'Jane', + phone: '555-0100', + email: 'jane@example.com', + ) + .execute(); + + final item = await db.contacts.first.fetch(); + check(item).isNotNull() + ..lastName.equals('Doe') + ..firstName.equals('Jane') + ..phone.equals('555-0100') + ..email.equals('jane@example.com'); + }); + + r.run(); +} diff --git a/typed_sql/test/typed_sql/index/covering_index/covering_index_test.g.dart b/typed_sql/test/typed_sql/index/covering_index/covering_index_test.g.dart new file mode 100644 index 00000000..ebd3c54d --- /dev/null +++ b/typed_sql/test/typed_sql/index/covering_index/covering_index_test.g.dart @@ -0,0 +1,674 @@ +// GENERATED CODE - DO NOT MODIFY BY HAND + +part of 'covering_index_test.dart'; + +// ************************************************************************** +// Generator: _TypedSqlBuilder +// ************************************************************************** + +/// Extension methods for a [Database] operating on [Directory]. +extension DirectorySchema on Database { + static final _$tables = [_$Contact._$table]; + + Table get contacts => + $ForGeneratedCode.declareTable(this, _$Contact._$table); + + /// Create tables defined in [Directory]. + /// + /// Calling this on an empty database will create the tables + /// defined in [Directory]. In production it's often better to + /// use [createDirectoryTables] and manage migrations using + /// external tools. + /// + /// This method is mostly useful for testing. + /// + /// > [!WARNING] + /// > If the database is **not empty** behavior is undefined, most + /// > likely this operation will fail. + Future createTables() async => + $ForGeneratedCode.createTables(context: this, tables: _$tables); +} + +/// Get SQL [DDL statements][1] for tables defined in [Directory]. +/// +/// This returns a SQL script with multiple DDL statements separated by `;` +/// using the specified [dialect]. +/// +/// Executing these statements in an empty database will create the tables +/// defined in [Directory]. In practice, this method is often used for +/// printing the DDL statements, such that migrations can be managed by +/// external tools. +/// +/// [1]: https://en.wikipedia.org/wiki/Data_definition_language +String createDirectoryTables(SqlDialect dialect) => $ForGeneratedCode + .createTableSchema(dialect: dialect, tables: DirectorySchema._$tables); + +final class _$Contact extends Contact { + _$Contact._(this.id, this.lastName, this.firstName, this.phone, this.email); + + @override + final int id; + + @override + final String lastName; + + @override + final String firstName; + + @override + final String phone; + + @override + final String email; + + static final _$table = $ForGeneratedCode.tableDefinition( + tableName: 'contacts', + columns: ['id', 'lastName', 'firstName', 'phone', 'email'], + columnInfo: [ + $ForGeneratedCode.columnDefinition( + type: $ForGeneratedCode.integer, + isNotNull: true, + defaultValue: null, + autoIncrement: true, + overrides: [], + ), + $ForGeneratedCode.columnDefinition( + type: $ForGeneratedCode.text, + isNotNull: true, + defaultValue: null, + autoIncrement: false, + overrides: [ + ( + dialect: 'mysql', + columnType: 'VARCHAR(255)', + defaultValue: null, + collation: null, + ), + ], + ), + $ForGeneratedCode.columnDefinition( + type: $ForGeneratedCode.text, + isNotNull: true, + defaultValue: null, + autoIncrement: false, + overrides: [], + ), + $ForGeneratedCode.columnDefinition( + type: $ForGeneratedCode.text, + isNotNull: true, + defaultValue: null, + autoIncrement: false, + overrides: [ + ( + dialect: 'mysql', + columnType: 'VARCHAR(255)', + defaultValue: null, + collation: null, + ), + ], + ), + $ForGeneratedCode.columnDefinition( + type: $ForGeneratedCode.text, + isNotNull: true, + defaultValue: null, + autoIncrement: false, + overrides: [], + ), + ], + primaryKey: ['id'], + unique: >[], + foreignKeys: [], + indexes: [ + $ForGeneratedCode.indexDefinition( + name: null, + sqlName: null, + columns: ['phone'], + method: .btree, + covering: ['lastName'], + ), + $ForGeneratedCode.indexDefinition( + name: null, + sqlName: null, + columns: ['lastName'], + method: .btree, + covering: ['email'], + ), + ], + readRow: _$Contact._$fromDatabase, + ); + + static Contact? _$fromDatabase(RowReader row) { + final id = row.readInt(); + final lastName = row.readString(); + final firstName = row.readString(); + final phone = row.readString(); + final email = row.readString(); + if (id == null && + lastName == null && + firstName == null && + phone == null && + email == null) { + return null; + } + return _$Contact._(id!, lastName!, firstName!, phone!, email!); + } + + @override + String toString() => + 'Contact(id: "$id", lastName: "$lastName", firstName: "$firstName", phone: "$phone", email: "$email")'; +} + +/// Extension methods for table defined in [Contact]. +extension TableContactExt on Table { + /// Insert row into the `contacts` table. + /// + /// Returns a [InsertSingle] statement on which `.execute` must be + /// called for the row to be inserted. + InsertSingle insert({ + Expr? id, + required Expr lastName, + required Expr firstName, + required Expr phone, + required Expr email, + }) => $ForGeneratedCode.insertInto( + table: this, + values: [id, lastName, firstName, phone, email], + ); + + /// Insert row into the `contacts` table. + /// + /// Returns a [InsertSingle] statement on which `.execute` must be + /// called for the row to be inserted. + InsertSingle insertValue({ + int? id, + required String lastName, + required String firstName, + required String phone, + required String email, + }) => $ForGeneratedCode.insertInto( + table: this, + values: [ + id?.asExpr, + lastName.asExpr, + firstName.asExpr, + phone.asExpr, + email.asExpr, + ], + ); + + /// Bulk insert rows into the `contacts` table. + /// + /// This method takes an `Iterable` and requires that you provide + /// a _mapping function_ from `T` to each column to be inserted. + /// + /// If a mapping function is omitted, the _default value_ will be + /// inserted, or `NULL` if column is nullable and as no default value. + /// To explicitely insert `NULL`, use a _mapping function_ that maps + /// `T` to `null`. + /// + /// > [!NOTE] + /// > This method aims utilize database specific bulk insertion logic + /// > to ensure good performance. Database adapters may pipeline bulk + /// > insertions through multiple statements inside a transaction. + /// + /// Returns a [Insert] statement on which `.execute` must be + /// called for the rows to be inserted. + Insert insertValuesMapped( + Iterable rows, { + int Function(T row)? id, + required String Function(T row) lastName, + required String Function(T row) firstName, + required String Function(T row) phone, + required String Function(T row) email, + }) => $ForGeneratedCode.insertValuesMapped( + table: this, + rows: rows, + mappings: [id, lastName, firstName, phone, email], + ); + + /// Delete a single row from the `contacts` table, specified by + /// _primary key_. + /// + /// Returns a [DeleteSingle] statement on which `.execute()` must be + /// called for the row to be deleted. + /// + /// To delete multiple rows, using `.where()` to filter which rows + /// should be deleted. If you wish to delete all rows, use + /// `.where((_) => toExpr(true)).delete()`. + DeleteSingle delete(int id) => + $ForGeneratedCode.deleteSingle(byKey(id), _$Contact._$table); +} + +/// Extension methods for building queries against the `contacts` table. +extension QueryContactExt on Query<(Expr,)> { + /// Lookup a single row in `contacts` table using the _primary key_. + /// + /// Returns a [QuerySingle] object, which returns at-most one row, + /// when `.fetch()` is called. + QuerySingle<(Expr,)> byKey(int id) => + where((contact) => contact.id.equalsValue(id)).first; + + /// Update all rows in the `contacts` table matching this [Query]. + /// + /// The changes to be applied to each row matching this [Query] are + /// defined using the [updateBuilder], which is given an [Expr] + /// representation of the row being updated and a `set` function to + /// specify which fields should be updated. The result of the `set` + /// function should always be returned from the `updateBuilder`. + /// + /// Returns an [Update] statement on which `.execute()` must be called + /// for the rows to be updated. + /// + /// **Example:** decrementing `1` from the `value` field for each row + /// where `value > 0`. + /// ```dart + /// await db.mytable + /// .where((row) => row.value > toExpr(0)) + /// .update((row, set) => set( + /// value: row.value - toExpr(1), + /// )) + /// .execute(); + /// ``` + /// + /// > [!WARNING] + /// > The `updateBuilder` callback does not make the update, it builds + /// > the expressions for updating the rows. You should **never** invoke + /// > the `set` function more than once, and the result should always + /// > be returned immediately. + Update update( + UpdateSet Function( + Expr contact, + UpdateSet Function({ + Expr id, + Expr lastName, + Expr firstName, + Expr phone, + Expr email, + }) + set, + ) + updateBuilder, + ) => $ForGeneratedCode.update( + this, + _$Contact._$table, + (contact) => updateBuilder( + contact, + ({ + Expr? id, + Expr? lastName, + Expr? firstName, + Expr? phone, + Expr? email, + }) => $ForGeneratedCode.buildUpdate([ + id, + lastName, + firstName, + phone, + email, + ]), + ), + ); + + /// Delete all rows in the `contacts` table matching this [Query]. + /// + /// Returns a [Delete] statement on which `.execute()` must be called + /// for the rows to be deleted. + Delete delete() => $ForGeneratedCode.delete(this, _$Contact._$table); +} + +/// Extension methods for building point queries against the `contacts` table. +extension QuerySingleContactExt on QuerySingle<(Expr,)> { + /// Update the row (if any) in the `contacts` table matching this + /// [QuerySingle]. + /// + /// The changes to be applied to the row matching this [QuerySingle] are + /// defined using the [updateBuilder], which is given an [Expr] + /// representation of the row being updated and a `set` function to + /// specify which fields should be updated. The result of the `set` + /// function should always be returned from the `updateBuilder`. + /// + /// Returns an [UpdateSingle] statement on which `.execute()` must be + /// called for the row to be updated. The resulting statement will + /// **not** fail, if there are no rows matching this query exists. + /// + /// **Example:** decrementing `1` from the `value` field the row with + /// `id = 1`. + /// ```dart + /// await db.mytable + /// .byKey(1) + /// .update((row, set) => set( + /// value: row.value - toExpr(1), + /// )) + /// .execute(); + /// ``` + /// + /// > [!WARNING] + /// > The `updateBuilder` callback does not make the update, it builds + /// > the expressions for updating the rows. You should **never** invoke + /// > the `set` function more than once, and the result should always + /// > be returned immediately. + UpdateSingle update( + UpdateSet Function( + Expr contact, + UpdateSet Function({ + Expr id, + Expr lastName, + Expr firstName, + Expr phone, + Expr email, + }) + set, + ) + updateBuilder, + ) => $ForGeneratedCode.updateSingle( + this, + _$Contact._$table, + (contact) => updateBuilder( + contact, + ({ + Expr? id, + Expr? lastName, + Expr? firstName, + Expr? phone, + Expr? email, + }) => $ForGeneratedCode.buildUpdate([ + id, + lastName, + firstName, + phone, + email, + ]), + ), + ); + + /// Delete the row (if any) in the `contacts` table matching this [QuerySingle]. + /// + /// Returns a [DeleteSingle] statement on which `.execute()` must be called + /// for the row to be deleted. The resulting statement will **not** + /// fail, if there are no rows matching this query exists. + DeleteSingle delete() => + $ForGeneratedCode.deleteSingle(this, _$Contact._$table); +} + +/// Extension methods for expressions on a row in the `contacts` table. +extension ExpressionContactExt on Expr { + Expr get id => + $ForGeneratedCode.field(this, 0, $ForGeneratedCode.integer); + + Expr get lastName => + $ForGeneratedCode.field(this, 1, $ForGeneratedCode.text); + + Expr get firstName => + $ForGeneratedCode.field(this, 2, $ForGeneratedCode.text); + + Expr get phone => + $ForGeneratedCode.field(this, 3, $ForGeneratedCode.text); + + Expr get email => + $ForGeneratedCode.field(this, 4, $ForGeneratedCode.text); +} + +extension ExpressionNullableContactExt on Expr { + Expr get id => + $ForGeneratedCode.field(this, 0, $ForGeneratedCode.integer); + + Expr get lastName => + $ForGeneratedCode.field(this, 1, $ForGeneratedCode.text); + + Expr get firstName => + $ForGeneratedCode.field(this, 2, $ForGeneratedCode.text); + + Expr get phone => + $ForGeneratedCode.field(this, 3, $ForGeneratedCode.text); + + Expr get email => + $ForGeneratedCode.field(this, 4, $ForGeneratedCode.text); + + /// Check if the row is not `NULL`. + /// + /// This will check if _primary key_ fields in this row are `NULL`. + /// + /// If this is a reference lookup by subquery it might be more efficient + /// to check if the referencing field is `NULL`. + Expr isNotNull() => id.isNotNull(); + + /// Check if the row is `NULL`. + /// + /// This will check if _primary key_ fields in this row are `NULL`. + /// + /// If this is a reference lookup by subquery it might be more efficient + /// to check if the referencing field is `NULL`. + Expr isNull() => isNotNull().not(); +} + +/// `Table` conflict targets for use with `.onConflict`. +enum ContactConflict { + /// Conflict with an existing row that has a matching primary key. + /// + /// Thus, the other row has matching values for: + /// `id`. + primaryKey(['id']); + + const ContactConflict(this._fields); + + final List _fields; +} + +extension InsertContactExt on Insert { + /// Build an `INSERT` statement with an `ON CONFLICT` clause. + /// + /// The [target] argument specifies the _conflict target_ to be + /// handled. The _conflict target_ is always a `UNIQUE` constraint or + /// `PRIMARY KEY` constraint. + /// + /// If a row to be inserted violates the _conflict target_ constraint, + /// then the conflict action is triggered: + /// * `.doNothing()` to skip insertion of the new row, and, + /// * `.update((contact, excluded, set) => set(...))` to + /// update the conflicting row. + /// + /// If a row to be inserted violates a constraint other than the one + /// specified in _conflict target_ then the entire `INSERT` statement + /// will fail. + /// + /// This is equivalent to `INSERT ... ON CONFLICT (...)` in SQL. + InsertOnConflict onConflict(ContactConflict target) => + $ForGeneratedCode.insertOnConflict(this, target._fields); +} + +extension InsertOnConflictContactExt on InsertOnConflict { + /// Build an `INSERT` statement an [upsert-clause][1]. + /// + /// When a row to be inserted violates the `UNIQUE` or `PRIMARY KEY` + /// constraint previously specified as _conflict target_, the existing + /// row is updated using the expressions defined with the + /// [updateBuilder]. The [updateBuilder] is given 3 parameters: + /// * `contact` an [Expr] representing the existing row in + /// the database, + /// * `excluded` an [Expr] representing the row to be inserted in the + /// database, and, + /// * `set` a function to specify which fields should be updated and + /// build the [UpdateSet]. + /// + /// The result of the `set` function should always be immediately + /// returned from the [updateBuilder]. + /// + /// **Example:** Insert a counter with `count = 2` or increment the + /// existing row, if a `PRIMARY KEY` conflict occurs. + /// ```dart + /// await db.counters.insertValue( + /// name: 'my-counter', // primary key + /// count: 2, + /// ) + /// .onConflict(.primaryKey) + /// .update((counter, excluded, set) => set( + /// count: counter.count + excluded.count, + /// )) + /// .execute(); + /// ``` + /// + /// This is equivalent to + /// `INSERT ... ON CONFLICT (...) UPDATE SET ...` in SQL. + /// + /// > [!WARNING] + /// > The `updateBuilder` callback does not make the update, it builds + /// > the expressions for updating the rows. You should **never** invoke + /// > the `set` function more than once, and the result should always + /// > be returned immediately. + /// + /// [1]: https://www.sqlite.org/lang_upsert.html + Upsert update( + UpdateSet Function( + Expr contact, + Expr excluded, + UpdateSet Function({ + Expr id, + Expr lastName, + Expr firstName, + Expr phone, + Expr email, + }) + set, + ) + updateBuilder, + ) => $ForGeneratedCode.updateOnConflict( + this, + (contact, excluded) => updateBuilder( + contact, + excluded, + ({ + Expr? id, + Expr? lastName, + Expr? firstName, + Expr? phone, + Expr? email, + }) => $ForGeneratedCode.buildUpdate([ + id, + lastName, + firstName, + phone, + email, + ]), + ), + ); +} + +extension InsertSingleContactExt on InsertSingle { + /// Build an `INSERT` statement with an `ON CONFLICT` clause. + /// + /// The [target] argument specifies the _conflict target_ to be + /// handled. The _conflict target_ is always a `UNIQUE` constraint or + /// `PRIMARY KEY` constraint. + /// + /// If a row to be inserted violates the _conflict target_ constraint, + /// then the conflict action is triggered: + /// * `.doNothing()` to skip insertion of the new row, and, + /// * `.update((contact, excluded, set) => set(...))` to + /// update the conflicting row. + /// + /// If a row to be inserted violates a constraint other than the one + /// specified in _conflict target_ then the entire `INSERT` statement + /// will fail. + /// + /// This is equivalent to `INSERT ... ON CONFLICT (...)` in SQL. + InsertOnConflictSingle onConflict(ContactConflict target) => + $ForGeneratedCode.insertOnConflictSingle(this, target._fields); +} + +extension InsertOnConflictSingleContactExt on InsertOnConflictSingle { + /// Build an `INSERT` statement an [upsert-clause][1]. + /// + /// When a row to be inserted violates the `UNIQUE` or `PRIMARY KEY` + /// constraint previously specified as _conflict target_, the existing + /// row is updated using the expressions defined with the + /// [updateBuilder]. The [updateBuilder] is given 3 parameters: + /// * `contact` an [Expr] representing the existing row in + /// the database, + /// * `excluded` an [Expr] representing the row to be inserted in the + /// database, and, + /// * `set` a function to specify which fields should be updated and + /// build the [UpdateSet]. + /// + /// The result of the `set` function should always be immediately + /// returned from the [updateBuilder]. + /// + /// **Example:** Insert a counter with `count = 2` or increment the + /// existing row, if a `PRIMARY KEY` conflict occurs. + /// ```dart + /// await db.counters.insertValue( + /// name: 'my-counter', // primary key + /// count: 2, + /// ) + /// .onConflict(.primaryKey) + /// .update((counter, excluded, set) => set( + /// count: counter.count + excluded.count, + /// )) + /// .execute(); + /// ``` + /// + /// This is equivalent to + /// `INSERT ... ON CONFLICT (...) UPDATE SET ...` in SQL. + /// + /// > [!WARNING] + /// > The `updateBuilder` callback does not make the update, it builds + /// > the expressions for updating the rows. You should **never** invoke + /// > the `set` function more than once, and the result should always + /// > be returned immediately. + /// + /// [1]: https://www.sqlite.org/lang_upsert.html + UpsertSingle update( + UpdateSet Function( + Expr contact, + Expr excluded, + UpdateSet Function({ + Expr id, + Expr lastName, + Expr firstName, + Expr phone, + Expr email, + }) + set, + ) + updateBuilder, + ) => $ForGeneratedCode.updateOnConflictSingle( + this, + (contact, excluded) => updateBuilder( + contact, + excluded, + ({ + Expr? id, + Expr? lastName, + Expr? firstName, + Expr? phone, + Expr? email, + }) => $ForGeneratedCode.buildUpdate([ + id, + lastName, + firstName, + phone, + email, + ]), + ), + ); +} + +/// Extension methods for assertions on [Contact] using +/// [`package:checks`][1]. +/// +/// [1]: https://pub.dev/packages/checks +extension ContactChecks on Subject { + /// Create assertions on [Contact.id]. + Subject get id => has((m) => m.id, 'id'); + + /// Create assertions on [Contact.lastName]. + Subject get lastName => has((m) => m.lastName, 'lastName'); + + /// Create assertions on [Contact.firstName]. + Subject get firstName => has((m) => m.firstName, 'firstName'); + + /// Create assertions on [Contact.phone]. + Subject get phone => has((m) => m.phone, 'phone'); + + /// Create assertions on [Contact.email]. + Subject get email => has((m) => m.email, 'email'); +} diff --git a/typed_sql/test/typed_sql/index/gin_index/gin_index_ddl_test.dart b/typed_sql/test/typed_sql/index/gin_index/gin_index_ddl_test.dart new file mode 100644 index 00000000..fc7cc440 --- /dev/null +++ b/typed_sql/test/typed_sql/index/gin_index/gin_index_ddl_test.dart @@ -0,0 +1,53 @@ +// Copyright 2026 Google LLC +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// https://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +import 'package:checks/checks.dart'; +import 'package:test/test.dart'; + +import '../index_ddl_helpers.dart'; +import 'gin_index_test.dart' hide main; + +void main() { + test('Postgres emits `USING GIN` for method: .gin indexes', () async { + final ddl = await capturePostgresDdl( + (adapter, db) => db.createTables(), + ); + if (ddl == null) return; + + check(ddl).contains('USING GIN ("metadata")'); + check(ddl).contains('USING GIN ("tags")'); + }); + + test('SQLite falls back to a plain index for method: .gin indexes', () async { + final ddl = await captureSqliteDdl( + (adapter, db) => db.createTables(), + ); + + check(ddl).contains('CREATE INDEX'); + check(ddl).not((d) => d.contains('USING GIN')); + }); + + test( + 'MySQL/MariaDB falls back to a plain index for method: .gin indexes', + () async { + final ddl = await captureMariadbDdl( + (adapter, db) => db.createTables(), + ); + if (ddl == null) return; + + check(ddl).contains('CREATE INDEX'); + check(ddl).not((d) => d.contains('USING GIN')); + }, + ); +} diff --git a/typed_sql/test/typed_sql/index/gin_index/gin_index_test.dart b/typed_sql/test/typed_sql/index/gin_index/gin_index_test.dart new file mode 100644 index 00000000..e2c0d9dc --- /dev/null +++ b/typed_sql/test/typed_sql/index/gin_index/gin_index_test.dart @@ -0,0 +1,64 @@ +// Copyright 2026 Google LLC +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// https://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +import 'package:typed_sql/typed_sql.dart'; +import '../../testrunner.dart'; + +part 'gin_index_test.g.dart'; + +abstract final class ProductCatalog extends Schema { + Table get products; +} + +@PrimaryKey(['id']) +@Index(fields: ['tags'], method: .gin) +abstract final class Product extends Row { + @AutoIncrement() + int get id; + + String get name; + + @Index.field(method: .gin) + JsonValue get metadata; + + JsonValue get tags; +} + +void main() { + final r = TestRunner( + setup: (db) async { + await db.createTables(); + }, + ); + + r.addTest('createTables() succeeds with GIN indexes on JSON fields', ( + db, + ) async { + await db.products + .insertValue( + name: 'Gadget', + metadata: const JsonValue({'color': 'black', 'weight': 10}), + tags: const JsonValue(['electronics', 'new']), + ) + .execute(); + + final item = await db.products.first.fetch(); + check(item).isNotNull() + ..name.equals('Gadget') + ..metadata.deepEquals(const JsonValue({'color': 'black', 'weight': 10})) + ..tags.deepEquals(const JsonValue(['electronics', 'new'])); + }); + + r.run(); +} diff --git a/typed_sql/test/typed_sql/index/gin_index/gin_index_test.g.dart b/typed_sql/test/typed_sql/index/gin_index/gin_index_test.g.dart new file mode 100644 index 00000000..54259a25 --- /dev/null +++ b/typed_sql/test/typed_sql/index/gin_index/gin_index_test.g.dart @@ -0,0 +1,595 @@ +// GENERATED CODE - DO NOT MODIFY BY HAND + +part of 'gin_index_test.dart'; + +// ************************************************************************** +// Generator: _TypedSqlBuilder +// ************************************************************************** + +/// Extension methods for a [Database] operating on [ProductCatalog]. +extension ProductCatalogSchema on Database { + static final _$tables = [_$Product._$table]; + + Table get products => + $ForGeneratedCode.declareTable(this, _$Product._$table); + + /// Create tables defined in [ProductCatalog]. + /// + /// Calling this on an empty database will create the tables + /// defined in [ProductCatalog]. In production it's often better to + /// use [createProductCatalogTables] and manage migrations using + /// external tools. + /// + /// This method is mostly useful for testing. + /// + /// > [!WARNING] + /// > If the database is **not empty** behavior is undefined, most + /// > likely this operation will fail. + Future createTables() async => + $ForGeneratedCode.createTables(context: this, tables: _$tables); +} + +/// Get SQL [DDL statements][1] for tables defined in [ProductCatalog]. +/// +/// This returns a SQL script with multiple DDL statements separated by `;` +/// using the specified [dialect]. +/// +/// Executing these statements in an empty database will create the tables +/// defined in [ProductCatalog]. In practice, this method is often used for +/// printing the DDL statements, such that migrations can be managed by +/// external tools. +/// +/// [1]: https://en.wikipedia.org/wiki/Data_definition_language +String createProductCatalogTables(SqlDialect dialect) => $ForGeneratedCode + .createTableSchema(dialect: dialect, tables: ProductCatalogSchema._$tables); + +final class _$Product extends Product { + _$Product._(this.id, this.name, this.metadata, this.tags); + + @override + final int id; + + @override + final String name; + + @override + final JsonValue metadata; + + @override + final JsonValue tags; + + static final _$table = $ForGeneratedCode.tableDefinition( + tableName: 'products', + columns: ['id', 'name', 'metadata', 'tags'], + columnInfo: [ + $ForGeneratedCode.columnDefinition( + type: $ForGeneratedCode.integer, + isNotNull: true, + defaultValue: null, + autoIncrement: true, + overrides: [], + ), + $ForGeneratedCode.columnDefinition( + type: $ForGeneratedCode.text, + isNotNull: true, + defaultValue: null, + autoIncrement: false, + overrides: [], + ), + $ForGeneratedCode.columnDefinition( + type: $ForGeneratedCode.jsonValue, + isNotNull: true, + defaultValue: null, + autoIncrement: false, + overrides: [], + ), + $ForGeneratedCode.columnDefinition( + type: $ForGeneratedCode.jsonValue, + isNotNull: true, + defaultValue: null, + autoIncrement: false, + overrides: [], + ), + ], + primaryKey: ['id'], + unique: >[], + foreignKeys: [], + indexes: [ + $ForGeneratedCode.indexDefinition( + name: null, + sqlName: null, + columns: ['metadata'], + method: .gin, + covering: [], + ), + $ForGeneratedCode.indexDefinition( + name: null, + sqlName: null, + columns: ['tags'], + method: .gin, + covering: [], + ), + ], + readRow: _$Product._$fromDatabase, + ); + + static Product? _$fromDatabase(RowReader row) { + final id = row.readInt(); + final name = row.readString(); + final metadata = row.readJsonValue(); + final tags = row.readJsonValue(); + if (id == null && name == null && metadata == null && tags == null) { + return null; + } + return _$Product._(id!, name!, metadata!, tags!); + } + + @override + String toString() => + 'Product(id: "$id", name: "$name", metadata: "$metadata", tags: "$tags")'; +} + +/// Extension methods for table defined in [Product]. +extension TableProductExt on Table { + /// Insert row into the `products` table. + /// + /// Returns a [InsertSingle] statement on which `.execute` must be + /// called for the row to be inserted. + InsertSingle insert({ + Expr? id, + required Expr name, + required Expr metadata, + required Expr tags, + }) => $ForGeneratedCode.insertInto( + table: this, + values: [id, name, metadata, tags], + ); + + /// Insert row into the `products` table. + /// + /// Returns a [InsertSingle] statement on which `.execute` must be + /// called for the row to be inserted. + InsertSingle insertValue({ + int? id, + required String name, + required JsonValue metadata, + required JsonValue tags, + }) => $ForGeneratedCode.insertInto( + table: this, + values: [id?.asExpr, name.asExpr, metadata.asExpr, tags.asExpr], + ); + + /// Bulk insert rows into the `products` table. + /// + /// This method takes an `Iterable` and requires that you provide + /// a _mapping function_ from `T` to each column to be inserted. + /// + /// If a mapping function is omitted, the _default value_ will be + /// inserted, or `NULL` if column is nullable and as no default value. + /// To explicitely insert `NULL`, use a _mapping function_ that maps + /// `T` to `null`. + /// + /// > [!NOTE] + /// > This method aims utilize database specific bulk insertion logic + /// > to ensure good performance. Database adapters may pipeline bulk + /// > insertions through multiple statements inside a transaction. + /// + /// Returns a [Insert] statement on which `.execute` must be + /// called for the rows to be inserted. + Insert insertValuesMapped( + Iterable rows, { + int Function(T row)? id, + required String Function(T row) name, + required JsonValue Function(T row) metadata, + required JsonValue Function(T row) tags, + }) => $ForGeneratedCode.insertValuesMapped( + table: this, + rows: rows, + mappings: [id, name, metadata, tags], + ); + + /// Delete a single row from the `products` table, specified by + /// _primary key_. + /// + /// Returns a [DeleteSingle] statement on which `.execute()` must be + /// called for the row to be deleted. + /// + /// To delete multiple rows, using `.where()` to filter which rows + /// should be deleted. If you wish to delete all rows, use + /// `.where((_) => toExpr(true)).delete()`. + DeleteSingle delete(int id) => + $ForGeneratedCode.deleteSingle(byKey(id), _$Product._$table); +} + +/// Extension methods for building queries against the `products` table. +extension QueryProductExt on Query<(Expr,)> { + /// Lookup a single row in `products` table using the _primary key_. + /// + /// Returns a [QuerySingle] object, which returns at-most one row, + /// when `.fetch()` is called. + QuerySingle<(Expr,)> byKey(int id) => + where((product) => product.id.equalsValue(id)).first; + + /// Update all rows in the `products` table matching this [Query]. + /// + /// The changes to be applied to each row matching this [Query] are + /// defined using the [updateBuilder], which is given an [Expr] + /// representation of the row being updated and a `set` function to + /// specify which fields should be updated. The result of the `set` + /// function should always be returned from the `updateBuilder`. + /// + /// Returns an [Update] statement on which `.execute()` must be called + /// for the rows to be updated. + /// + /// **Example:** decrementing `1` from the `value` field for each row + /// where `value > 0`. + /// ```dart + /// await db.mytable + /// .where((row) => row.value > toExpr(0)) + /// .update((row, set) => set( + /// value: row.value - toExpr(1), + /// )) + /// .execute(); + /// ``` + /// + /// > [!WARNING] + /// > The `updateBuilder` callback does not make the update, it builds + /// > the expressions for updating the rows. You should **never** invoke + /// > the `set` function more than once, and the result should always + /// > be returned immediately. + Update update( + UpdateSet Function( + Expr product, + UpdateSet Function({ + Expr id, + Expr name, + Expr metadata, + Expr tags, + }) + set, + ) + updateBuilder, + ) => $ForGeneratedCode.update( + this, + _$Product._$table, + (product) => updateBuilder( + product, + ({ + Expr? id, + Expr? name, + Expr? metadata, + Expr? tags, + }) => $ForGeneratedCode.buildUpdate([id, name, metadata, tags]), + ), + ); + + /// Delete all rows in the `products` table matching this [Query]. + /// + /// Returns a [Delete] statement on which `.execute()` must be called + /// for the rows to be deleted. + Delete delete() => $ForGeneratedCode.delete(this, _$Product._$table); +} + +/// Extension methods for building point queries against the `products` table. +extension QuerySingleProductExt on QuerySingle<(Expr,)> { + /// Update the row (if any) in the `products` table matching this + /// [QuerySingle]. + /// + /// The changes to be applied to the row matching this [QuerySingle] are + /// defined using the [updateBuilder], which is given an [Expr] + /// representation of the row being updated and a `set` function to + /// specify which fields should be updated. The result of the `set` + /// function should always be returned from the `updateBuilder`. + /// + /// Returns an [UpdateSingle] statement on which `.execute()` must be + /// called for the row to be updated. The resulting statement will + /// **not** fail, if there are no rows matching this query exists. + /// + /// **Example:** decrementing `1` from the `value` field the row with + /// `id = 1`. + /// ```dart + /// await db.mytable + /// .byKey(1) + /// .update((row, set) => set( + /// value: row.value - toExpr(1), + /// )) + /// .execute(); + /// ``` + /// + /// > [!WARNING] + /// > The `updateBuilder` callback does not make the update, it builds + /// > the expressions for updating the rows. You should **never** invoke + /// > the `set` function more than once, and the result should always + /// > be returned immediately. + UpdateSingle update( + UpdateSet Function( + Expr product, + UpdateSet Function({ + Expr id, + Expr name, + Expr metadata, + Expr tags, + }) + set, + ) + updateBuilder, + ) => $ForGeneratedCode.updateSingle( + this, + _$Product._$table, + (product) => updateBuilder( + product, + ({ + Expr? id, + Expr? name, + Expr? metadata, + Expr? tags, + }) => $ForGeneratedCode.buildUpdate([id, name, metadata, tags]), + ), + ); + + /// Delete the row (if any) in the `products` table matching this [QuerySingle]. + /// + /// Returns a [DeleteSingle] statement on which `.execute()` must be called + /// for the row to be deleted. The resulting statement will **not** + /// fail, if there are no rows matching this query exists. + DeleteSingle delete() => + $ForGeneratedCode.deleteSingle(this, _$Product._$table); +} + +/// Extension methods for expressions on a row in the `products` table. +extension ExpressionProductExt on Expr { + Expr get id => + $ForGeneratedCode.field(this, 0, $ForGeneratedCode.integer); + + Expr get name => + $ForGeneratedCode.field(this, 1, $ForGeneratedCode.text); + + Expr get metadata => + $ForGeneratedCode.field(this, 2, $ForGeneratedCode.jsonValue); + + Expr get tags => + $ForGeneratedCode.field(this, 3, $ForGeneratedCode.jsonValue); +} + +extension ExpressionNullableProductExt on Expr { + Expr get id => + $ForGeneratedCode.field(this, 0, $ForGeneratedCode.integer); + + Expr get name => + $ForGeneratedCode.field(this, 1, $ForGeneratedCode.text); + + Expr get metadata => + $ForGeneratedCode.field(this, 2, $ForGeneratedCode.jsonValue); + + Expr get tags => + $ForGeneratedCode.field(this, 3, $ForGeneratedCode.jsonValue); + + /// Check if the row is not `NULL`. + /// + /// This will check if _primary key_ fields in this row are `NULL`. + /// + /// If this is a reference lookup by subquery it might be more efficient + /// to check if the referencing field is `NULL`. + Expr isNotNull() => id.isNotNull(); + + /// Check if the row is `NULL`. + /// + /// This will check if _primary key_ fields in this row are `NULL`. + /// + /// If this is a reference lookup by subquery it might be more efficient + /// to check if the referencing field is `NULL`. + Expr isNull() => isNotNull().not(); +} + +/// `Table` conflict targets for use with `.onConflict`. +enum ProductConflict { + /// Conflict with an existing row that has a matching primary key. + /// + /// Thus, the other row has matching values for: + /// `id`. + primaryKey(['id']); + + const ProductConflict(this._fields); + + final List _fields; +} + +extension InsertProductExt on Insert { + /// Build an `INSERT` statement with an `ON CONFLICT` clause. + /// + /// The [target] argument specifies the _conflict target_ to be + /// handled. The _conflict target_ is always a `UNIQUE` constraint or + /// `PRIMARY KEY` constraint. + /// + /// If a row to be inserted violates the _conflict target_ constraint, + /// then the conflict action is triggered: + /// * `.doNothing()` to skip insertion of the new row, and, + /// * `.update((product, excluded, set) => set(...))` to + /// update the conflicting row. + /// + /// If a row to be inserted violates a constraint other than the one + /// specified in _conflict target_ then the entire `INSERT` statement + /// will fail. + /// + /// This is equivalent to `INSERT ... ON CONFLICT (...)` in SQL. + InsertOnConflict onConflict(ProductConflict target) => + $ForGeneratedCode.insertOnConflict(this, target._fields); +} + +extension InsertOnConflictProductExt on InsertOnConflict { + /// Build an `INSERT` statement an [upsert-clause][1]. + /// + /// When a row to be inserted violates the `UNIQUE` or `PRIMARY KEY` + /// constraint previously specified as _conflict target_, the existing + /// row is updated using the expressions defined with the + /// [updateBuilder]. The [updateBuilder] is given 3 parameters: + /// * `product` an [Expr] representing the existing row in + /// the database, + /// * `excluded` an [Expr] representing the row to be inserted in the + /// database, and, + /// * `set` a function to specify which fields should be updated and + /// build the [UpdateSet]. + /// + /// The result of the `set` function should always be immediately + /// returned from the [updateBuilder]. + /// + /// **Example:** Insert a counter with `count = 2` or increment the + /// existing row, if a `PRIMARY KEY` conflict occurs. + /// ```dart + /// await db.counters.insertValue( + /// name: 'my-counter', // primary key + /// count: 2, + /// ) + /// .onConflict(.primaryKey) + /// .update((counter, excluded, set) => set( + /// count: counter.count + excluded.count, + /// )) + /// .execute(); + /// ``` + /// + /// This is equivalent to + /// `INSERT ... ON CONFLICT (...) UPDATE SET ...` in SQL. + /// + /// > [!WARNING] + /// > The `updateBuilder` callback does not make the update, it builds + /// > the expressions for updating the rows. You should **never** invoke + /// > the `set` function more than once, and the result should always + /// > be returned immediately. + /// + /// [1]: https://www.sqlite.org/lang_upsert.html + Upsert update( + UpdateSet Function( + Expr product, + Expr excluded, + UpdateSet Function({ + Expr id, + Expr name, + Expr metadata, + Expr tags, + }) + set, + ) + updateBuilder, + ) => $ForGeneratedCode.updateOnConflict( + this, + (product, excluded) => updateBuilder( + product, + excluded, + ({ + Expr? id, + Expr? name, + Expr? metadata, + Expr? tags, + }) => $ForGeneratedCode.buildUpdate([id, name, metadata, tags]), + ), + ); +} + +extension InsertSingleProductExt on InsertSingle { + /// Build an `INSERT` statement with an `ON CONFLICT` clause. + /// + /// The [target] argument specifies the _conflict target_ to be + /// handled. The _conflict target_ is always a `UNIQUE` constraint or + /// `PRIMARY KEY` constraint. + /// + /// If a row to be inserted violates the _conflict target_ constraint, + /// then the conflict action is triggered: + /// * `.doNothing()` to skip insertion of the new row, and, + /// * `.update((product, excluded, set) => set(...))` to + /// update the conflicting row. + /// + /// If a row to be inserted violates a constraint other than the one + /// specified in _conflict target_ then the entire `INSERT` statement + /// will fail. + /// + /// This is equivalent to `INSERT ... ON CONFLICT (...)` in SQL. + InsertOnConflictSingle onConflict(ProductConflict target) => + $ForGeneratedCode.insertOnConflictSingle(this, target._fields); +} + +extension InsertOnConflictSingleProductExt on InsertOnConflictSingle { + /// Build an `INSERT` statement an [upsert-clause][1]. + /// + /// When a row to be inserted violates the `UNIQUE` or `PRIMARY KEY` + /// constraint previously specified as _conflict target_, the existing + /// row is updated using the expressions defined with the + /// [updateBuilder]. The [updateBuilder] is given 3 parameters: + /// * `product` an [Expr] representing the existing row in + /// the database, + /// * `excluded` an [Expr] representing the row to be inserted in the + /// database, and, + /// * `set` a function to specify which fields should be updated and + /// build the [UpdateSet]. + /// + /// The result of the `set` function should always be immediately + /// returned from the [updateBuilder]. + /// + /// **Example:** Insert a counter with `count = 2` or increment the + /// existing row, if a `PRIMARY KEY` conflict occurs. + /// ```dart + /// await db.counters.insertValue( + /// name: 'my-counter', // primary key + /// count: 2, + /// ) + /// .onConflict(.primaryKey) + /// .update((counter, excluded, set) => set( + /// count: counter.count + excluded.count, + /// )) + /// .execute(); + /// ``` + /// + /// This is equivalent to + /// `INSERT ... ON CONFLICT (...) UPDATE SET ...` in SQL. + /// + /// > [!WARNING] + /// > The `updateBuilder` callback does not make the update, it builds + /// > the expressions for updating the rows. You should **never** invoke + /// > the `set` function more than once, and the result should always + /// > be returned immediately. + /// + /// [1]: https://www.sqlite.org/lang_upsert.html + UpsertSingle update( + UpdateSet Function( + Expr product, + Expr excluded, + UpdateSet Function({ + Expr id, + Expr name, + Expr metadata, + Expr tags, + }) + set, + ) + updateBuilder, + ) => $ForGeneratedCode.updateOnConflictSingle( + this, + (product, excluded) => updateBuilder( + product, + excluded, + ({ + Expr? id, + Expr? name, + Expr? metadata, + Expr? tags, + }) => $ForGeneratedCode.buildUpdate([id, name, metadata, tags]), + ), + ); +} + +/// Extension methods for assertions on [Product] using +/// [`package:checks`][1]. +/// +/// [1]: https://pub.dev/packages/checks +extension ProductChecks on Subject { + /// Create assertions on [Product.id]. + Subject get id => has((m) => m.id, 'id'); + + /// Create assertions on [Product.name]. + Subject get name => has((m) => m.name, 'name'); + + /// Create assertions on [Product.metadata]. + Subject get metadata => has((m) => m.metadata, 'metadata'); + + /// Create assertions on [Product.tags]. + Subject get tags => has((m) => m.tags, 'tags'); +} diff --git a/typed_sql/test/typed_sql/index/gist_index/gist_index_ddl_test.dart b/typed_sql/test/typed_sql/index/gist_index/gist_index_ddl_test.dart new file mode 100644 index 00000000..1312ed08 --- /dev/null +++ b/typed_sql/test/typed_sql/index/gist_index/gist_index_ddl_test.dart @@ -0,0 +1,63 @@ +// Copyright 2026 Google LLC +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// https://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +import 'package:checks/checks.dart'; +import 'package:test/test.dart'; +import 'package:typed_sql/typed_sql.dart'; + +import '../index_ddl_helpers.dart'; +import 'gist_index_test.dart' hide main; + +void main() { + test( + 'Postgres emits `USING GIST` and round-trips data (needs btree_gist)', + () async { + final ddl = await capturePostgresDdl(( + adapter, + db, + ) async { + // A GiST index on `text` has no default operator class; enabling + // `btree_gist` is a prerequisite the application must handle itself. + await adapter.script('CREATE EXTENSION IF NOT EXISTS btree_gist;'); + await db.createTables(); + + await db.contacts.insertValue(email: 'jane@example.com').execute(); + final item = await db.contacts.first.fetch(); + check(item).isNotNull().email.equals('jane@example.com'); + }); + if (ddl == null) return; + + check(ddl).contains('USING GIST ("email")'); + }, + ); + + test('SQLite drops GIST and falls back to a plain index', () async { + final ddl = await captureSqliteDdl( + (adapter, db) => db.createTables(), + ); + + check(ddl).contains('CREATE INDEX'); + check(ddl).not((d) => d.contains('GIST')); + }); + + test('MySQL/MariaDB drops GIST and falls back to a plain index', () async { + final ddl = await captureMariadbDdl( + (adapter, db) => db.createTables(), + ); + if (ddl == null) return; + + check(ddl).contains('CREATE INDEX'); + check(ddl).not((d) => d.contains('GIST')); + }); +} diff --git a/typed_sql/test/typed_sql/index/gist_index/gist_index_test.dart b/typed_sql/test/typed_sql/index/gist_index/gist_index_test.dart new file mode 100644 index 00000000..9753fbb0 --- /dev/null +++ b/typed_sql/test/typed_sql/index/gist_index/gist_index_test.dart @@ -0,0 +1,59 @@ +// Copyright 2026 Google LLC +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// https://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +import 'package:typed_sql/typed_sql.dart'; +import '../../testrunner.dart'; + +part 'gist_index_test.g.dart'; + +abstract final class ContactDirectory extends Schema { + Table get contacts; +} + +@PrimaryKey(['id']) +abstract final class Contact extends Row { + @AutoIncrement() + int get id; + + // A GiST index on an ordinary scalar column only works if the + // `btree_gist` extension is installed, since PostgreSQL does not ship a + // default GiST operator class for `text`. typed_sql does not manage + // extensions, so this is up to the application/migration to install. + @Index.field(method: .gist) + @SqlOverride.field(dialect: 'mysql', columnType: 'VARCHAR(255)') + String get email; +} + +void main() { + final r = TestRunner( + setup: (db) async { + await db.createTables(); + }, + ); + + r.addTest( + 'createTables() succeeds with a GiST index', + (db) async { + await db.contacts.insertValue(email: 'jane@example.com').execute(); + + final item = await db.contacts.first.fetch(); + check(item).isNotNull().email.equals('jane@example.com'); + }, + skipPostgres: + 'GiST on a plain text column needs the btree_gist extension ' + 'bootstrapped first; see gist_index_ddl_test.dart', + ); + + r.run(); +} diff --git a/typed_sql/test/typed_sql/index/gist_index/gist_index_test.g.dart b/typed_sql/test/typed_sql/index/gist_index/gist_index_test.g.dart new file mode 100644 index 00000000..b0faa35f --- /dev/null +++ b/typed_sql/test/typed_sql/index/gist_index/gist_index_test.g.dart @@ -0,0 +1,504 @@ +// GENERATED CODE - DO NOT MODIFY BY HAND + +part of 'gist_index_test.dart'; + +// ************************************************************************** +// Generator: _TypedSqlBuilder +// ************************************************************************** + +/// Extension methods for a [Database] operating on [ContactDirectory]. +extension ContactDirectorySchema on Database { + static final _$tables = [_$Contact._$table]; + + Table get contacts => + $ForGeneratedCode.declareTable(this, _$Contact._$table); + + /// Create tables defined in [ContactDirectory]. + /// + /// Calling this on an empty database will create the tables + /// defined in [ContactDirectory]. In production it's often better to + /// use [createContactDirectoryTables] and manage migrations using + /// external tools. + /// + /// This method is mostly useful for testing. + /// + /// > [!WARNING] + /// > If the database is **not empty** behavior is undefined, most + /// > likely this operation will fail. + Future createTables() async => + $ForGeneratedCode.createTables(context: this, tables: _$tables); +} + +/// Get SQL [DDL statements][1] for tables defined in [ContactDirectory]. +/// +/// This returns a SQL script with multiple DDL statements separated by `;` +/// using the specified [dialect]. +/// +/// Executing these statements in an empty database will create the tables +/// defined in [ContactDirectory]. In practice, this method is often used for +/// printing the DDL statements, such that migrations can be managed by +/// external tools. +/// +/// [1]: https://en.wikipedia.org/wiki/Data_definition_language +String createContactDirectoryTables(SqlDialect dialect) => + $ForGeneratedCode.createTableSchema( + dialect: dialect, + tables: ContactDirectorySchema._$tables, + ); + +final class _$Contact extends Contact { + _$Contact._(this.id, this.email); + + @override + final int id; + + @override + final String email; + + static final _$table = $ForGeneratedCode.tableDefinition( + tableName: 'contacts', + columns: ['id', 'email'], + columnInfo: [ + $ForGeneratedCode.columnDefinition( + type: $ForGeneratedCode.integer, + isNotNull: true, + defaultValue: null, + autoIncrement: true, + overrides: [], + ), + $ForGeneratedCode.columnDefinition( + type: $ForGeneratedCode.text, + isNotNull: true, + defaultValue: null, + autoIncrement: false, + overrides: [ + ( + dialect: 'mysql', + columnType: 'VARCHAR(255)', + defaultValue: null, + collation: null, + ), + ], + ), + ], + primaryKey: ['id'], + unique: >[], + foreignKeys: [], + indexes: [ + $ForGeneratedCode.indexDefinition( + name: null, + sqlName: null, + columns: ['email'], + method: .gist, + covering: [], + ), + ], + readRow: _$Contact._$fromDatabase, + ); + + static Contact? _$fromDatabase(RowReader row) { + final id = row.readInt(); + final email = row.readString(); + if (id == null && email == null) { + return null; + } + return _$Contact._(id!, email!); + } + + @override + String toString() => 'Contact(id: "$id", email: "$email")'; +} + +/// Extension methods for table defined in [Contact]. +extension TableContactExt on Table { + /// Insert row into the `contacts` table. + /// + /// Returns a [InsertSingle] statement on which `.execute` must be + /// called for the row to be inserted. + InsertSingle insert({Expr? id, required Expr email}) => + $ForGeneratedCode.insertInto(table: this, values: [id, email]); + + /// Insert row into the `contacts` table. + /// + /// Returns a [InsertSingle] statement on which `.execute` must be + /// called for the row to be inserted. + InsertSingle insertValue({int? id, required String email}) => + $ForGeneratedCode.insertInto( + table: this, + values: [id?.asExpr, email.asExpr], + ); + + /// Bulk insert rows into the `contacts` table. + /// + /// This method takes an `Iterable` and requires that you provide + /// a _mapping function_ from `T` to each column to be inserted. + /// + /// If a mapping function is omitted, the _default value_ will be + /// inserted, or `NULL` if column is nullable and as no default value. + /// To explicitely insert `NULL`, use a _mapping function_ that maps + /// `T` to `null`. + /// + /// > [!NOTE] + /// > This method aims utilize database specific bulk insertion logic + /// > to ensure good performance. Database adapters may pipeline bulk + /// > insertions through multiple statements inside a transaction. + /// + /// Returns a [Insert] statement on which `.execute` must be + /// called for the rows to be inserted. + Insert insertValuesMapped( + Iterable rows, { + int Function(T row)? id, + required String Function(T row) email, + }) => $ForGeneratedCode.insertValuesMapped( + table: this, + rows: rows, + mappings: [id, email], + ); + + /// Delete a single row from the `contacts` table, specified by + /// _primary key_. + /// + /// Returns a [DeleteSingle] statement on which `.execute()` must be + /// called for the row to be deleted. + /// + /// To delete multiple rows, using `.where()` to filter which rows + /// should be deleted. If you wish to delete all rows, use + /// `.where((_) => toExpr(true)).delete()`. + DeleteSingle delete(int id) => + $ForGeneratedCode.deleteSingle(byKey(id), _$Contact._$table); +} + +/// Extension methods for building queries against the `contacts` table. +extension QueryContactExt on Query<(Expr,)> { + /// Lookup a single row in `contacts` table using the _primary key_. + /// + /// Returns a [QuerySingle] object, which returns at-most one row, + /// when `.fetch()` is called. + QuerySingle<(Expr,)> byKey(int id) => + where((contact) => contact.id.equalsValue(id)).first; + + /// Update all rows in the `contacts` table matching this [Query]. + /// + /// The changes to be applied to each row matching this [Query] are + /// defined using the [updateBuilder], which is given an [Expr] + /// representation of the row being updated and a `set` function to + /// specify which fields should be updated. The result of the `set` + /// function should always be returned from the `updateBuilder`. + /// + /// Returns an [Update] statement on which `.execute()` must be called + /// for the rows to be updated. + /// + /// **Example:** decrementing `1` from the `value` field for each row + /// where `value > 0`. + /// ```dart + /// await db.mytable + /// .where((row) => row.value > toExpr(0)) + /// .update((row, set) => set( + /// value: row.value - toExpr(1), + /// )) + /// .execute(); + /// ``` + /// + /// > [!WARNING] + /// > The `updateBuilder` callback does not make the update, it builds + /// > the expressions for updating the rows. You should **never** invoke + /// > the `set` function more than once, and the result should always + /// > be returned immediately. + Update update( + UpdateSet Function( + Expr contact, + UpdateSet Function({Expr id, Expr email}) set, + ) + updateBuilder, + ) => $ForGeneratedCode.update( + this, + _$Contact._$table, + (contact) => updateBuilder( + contact, + ({Expr? id, Expr? email}) => + $ForGeneratedCode.buildUpdate([id, email]), + ), + ); + + /// Delete all rows in the `contacts` table matching this [Query]. + /// + /// Returns a [Delete] statement on which `.execute()` must be called + /// for the rows to be deleted. + Delete delete() => $ForGeneratedCode.delete(this, _$Contact._$table); +} + +/// Extension methods for building point queries against the `contacts` table. +extension QuerySingleContactExt on QuerySingle<(Expr,)> { + /// Update the row (if any) in the `contacts` table matching this + /// [QuerySingle]. + /// + /// The changes to be applied to the row matching this [QuerySingle] are + /// defined using the [updateBuilder], which is given an [Expr] + /// representation of the row being updated and a `set` function to + /// specify which fields should be updated. The result of the `set` + /// function should always be returned from the `updateBuilder`. + /// + /// Returns an [UpdateSingle] statement on which `.execute()` must be + /// called for the row to be updated. The resulting statement will + /// **not** fail, if there are no rows matching this query exists. + /// + /// **Example:** decrementing `1` from the `value` field the row with + /// `id = 1`. + /// ```dart + /// await db.mytable + /// .byKey(1) + /// .update((row, set) => set( + /// value: row.value - toExpr(1), + /// )) + /// .execute(); + /// ``` + /// + /// > [!WARNING] + /// > The `updateBuilder` callback does not make the update, it builds + /// > the expressions for updating the rows. You should **never** invoke + /// > the `set` function more than once, and the result should always + /// > be returned immediately. + UpdateSingle update( + UpdateSet Function( + Expr contact, + UpdateSet Function({Expr id, Expr email}) set, + ) + updateBuilder, + ) => $ForGeneratedCode.updateSingle( + this, + _$Contact._$table, + (contact) => updateBuilder( + contact, + ({Expr? id, Expr? email}) => + $ForGeneratedCode.buildUpdate([id, email]), + ), + ); + + /// Delete the row (if any) in the `contacts` table matching this [QuerySingle]. + /// + /// Returns a [DeleteSingle] statement on which `.execute()` must be called + /// for the row to be deleted. The resulting statement will **not** + /// fail, if there are no rows matching this query exists. + DeleteSingle delete() => + $ForGeneratedCode.deleteSingle(this, _$Contact._$table); +} + +/// Extension methods for expressions on a row in the `contacts` table. +extension ExpressionContactExt on Expr { + Expr get id => + $ForGeneratedCode.field(this, 0, $ForGeneratedCode.integer); + + Expr get email => + $ForGeneratedCode.field(this, 1, $ForGeneratedCode.text); +} + +extension ExpressionNullableContactExt on Expr { + Expr get id => + $ForGeneratedCode.field(this, 0, $ForGeneratedCode.integer); + + Expr get email => + $ForGeneratedCode.field(this, 1, $ForGeneratedCode.text); + + /// Check if the row is not `NULL`. + /// + /// This will check if _primary key_ fields in this row are `NULL`. + /// + /// If this is a reference lookup by subquery it might be more efficient + /// to check if the referencing field is `NULL`. + Expr isNotNull() => id.isNotNull(); + + /// Check if the row is `NULL`. + /// + /// This will check if _primary key_ fields in this row are `NULL`. + /// + /// If this is a reference lookup by subquery it might be more efficient + /// to check if the referencing field is `NULL`. + Expr isNull() => isNotNull().not(); +} + +/// `Table` conflict targets for use with `.onConflict`. +enum ContactConflict { + /// Conflict with an existing row that has a matching primary key. + /// + /// Thus, the other row has matching values for: + /// `id`. + primaryKey(['id']); + + const ContactConflict(this._fields); + + final List _fields; +} + +extension InsertContactExt on Insert { + /// Build an `INSERT` statement with an `ON CONFLICT` clause. + /// + /// The [target] argument specifies the _conflict target_ to be + /// handled. The _conflict target_ is always a `UNIQUE` constraint or + /// `PRIMARY KEY` constraint. + /// + /// If a row to be inserted violates the _conflict target_ constraint, + /// then the conflict action is triggered: + /// * `.doNothing()` to skip insertion of the new row, and, + /// * `.update((contact, excluded, set) => set(...))` to + /// update the conflicting row. + /// + /// If a row to be inserted violates a constraint other than the one + /// specified in _conflict target_ then the entire `INSERT` statement + /// will fail. + /// + /// This is equivalent to `INSERT ... ON CONFLICT (...)` in SQL. + InsertOnConflict onConflict(ContactConflict target) => + $ForGeneratedCode.insertOnConflict(this, target._fields); +} + +extension InsertOnConflictContactExt on InsertOnConflict { + /// Build an `INSERT` statement an [upsert-clause][1]. + /// + /// When a row to be inserted violates the `UNIQUE` or `PRIMARY KEY` + /// constraint previously specified as _conflict target_, the existing + /// row is updated using the expressions defined with the + /// [updateBuilder]. The [updateBuilder] is given 3 parameters: + /// * `contact` an [Expr] representing the existing row in + /// the database, + /// * `excluded` an [Expr] representing the row to be inserted in the + /// database, and, + /// * `set` a function to specify which fields should be updated and + /// build the [UpdateSet]. + /// + /// The result of the `set` function should always be immediately + /// returned from the [updateBuilder]. + /// + /// **Example:** Insert a counter with `count = 2` or increment the + /// existing row, if a `PRIMARY KEY` conflict occurs. + /// ```dart + /// await db.counters.insertValue( + /// name: 'my-counter', // primary key + /// count: 2, + /// ) + /// .onConflict(.primaryKey) + /// .update((counter, excluded, set) => set( + /// count: counter.count + excluded.count, + /// )) + /// .execute(); + /// ``` + /// + /// This is equivalent to + /// `INSERT ... ON CONFLICT (...) UPDATE SET ...` in SQL. + /// + /// > [!WARNING] + /// > The `updateBuilder` callback does not make the update, it builds + /// > the expressions for updating the rows. You should **never** invoke + /// > the `set` function more than once, and the result should always + /// > be returned immediately. + /// + /// [1]: https://www.sqlite.org/lang_upsert.html + Upsert update( + UpdateSet Function( + Expr contact, + Expr excluded, + UpdateSet Function({Expr id, Expr email}) set, + ) + updateBuilder, + ) => $ForGeneratedCode.updateOnConflict( + this, + (contact, excluded) => updateBuilder( + contact, + excluded, + ({Expr? id, Expr? email}) => + $ForGeneratedCode.buildUpdate([id, email]), + ), + ); +} + +extension InsertSingleContactExt on InsertSingle { + /// Build an `INSERT` statement with an `ON CONFLICT` clause. + /// + /// The [target] argument specifies the _conflict target_ to be + /// handled. The _conflict target_ is always a `UNIQUE` constraint or + /// `PRIMARY KEY` constraint. + /// + /// If a row to be inserted violates the _conflict target_ constraint, + /// then the conflict action is triggered: + /// * `.doNothing()` to skip insertion of the new row, and, + /// * `.update((contact, excluded, set) => set(...))` to + /// update the conflicting row. + /// + /// If a row to be inserted violates a constraint other than the one + /// specified in _conflict target_ then the entire `INSERT` statement + /// will fail. + /// + /// This is equivalent to `INSERT ... ON CONFLICT (...)` in SQL. + InsertOnConflictSingle onConflict(ContactConflict target) => + $ForGeneratedCode.insertOnConflictSingle(this, target._fields); +} + +extension InsertOnConflictSingleContactExt on InsertOnConflictSingle { + /// Build an `INSERT` statement an [upsert-clause][1]. + /// + /// When a row to be inserted violates the `UNIQUE` or `PRIMARY KEY` + /// constraint previously specified as _conflict target_, the existing + /// row is updated using the expressions defined with the + /// [updateBuilder]. The [updateBuilder] is given 3 parameters: + /// * `contact` an [Expr] representing the existing row in + /// the database, + /// * `excluded` an [Expr] representing the row to be inserted in the + /// database, and, + /// * `set` a function to specify which fields should be updated and + /// build the [UpdateSet]. + /// + /// The result of the `set` function should always be immediately + /// returned from the [updateBuilder]. + /// + /// **Example:** Insert a counter with `count = 2` or increment the + /// existing row, if a `PRIMARY KEY` conflict occurs. + /// ```dart + /// await db.counters.insertValue( + /// name: 'my-counter', // primary key + /// count: 2, + /// ) + /// .onConflict(.primaryKey) + /// .update((counter, excluded, set) => set( + /// count: counter.count + excluded.count, + /// )) + /// .execute(); + /// ``` + /// + /// This is equivalent to + /// `INSERT ... ON CONFLICT (...) UPDATE SET ...` in SQL. + /// + /// > [!WARNING] + /// > The `updateBuilder` callback does not make the update, it builds + /// > the expressions for updating the rows. You should **never** invoke + /// > the `set` function more than once, and the result should always + /// > be returned immediately. + /// + /// [1]: https://www.sqlite.org/lang_upsert.html + UpsertSingle update( + UpdateSet Function( + Expr contact, + Expr excluded, + UpdateSet Function({Expr id, Expr email}) set, + ) + updateBuilder, + ) => $ForGeneratedCode.updateOnConflictSingle( + this, + (contact, excluded) => updateBuilder( + contact, + excluded, + ({Expr? id, Expr? email}) => + $ForGeneratedCode.buildUpdate([id, email]), + ), + ); +} + +/// Extension methods for assertions on [Contact] using +/// [`package:checks`][1]. +/// +/// [1]: https://pub.dev/packages/checks +extension ContactChecks on Subject { + /// Create assertions on [Contact.id]. + Subject get id => has((m) => m.id, 'id'); + + /// Create assertions on [Contact.email]. + Subject get email => has((m) => m.email, 'email'); +} diff --git a/typed_sql/test/typed_sql/index/hash_index/hash_index_ddl_test.dart b/typed_sql/test/typed_sql/index/hash_index/hash_index_ddl_test.dart new file mode 100644 index 00000000..91ee794b --- /dev/null +++ b/typed_sql/test/typed_sql/index/hash_index/hash_index_ddl_test.dart @@ -0,0 +1,48 @@ +// Copyright 2026 Google LLC +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// https://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +import 'package:checks/checks.dart'; +import 'package:test/test.dart'; + +import '../index_ddl_helpers.dart'; +import 'hash_index_test.dart' hide main; + +void main() { + test('Postgres emits `USING HASH` for method: .hash indexes', () async { + final ddl = await capturePostgresDdl( + (adapter, db) => db.createTables(), + ); + if (ddl == null) return; + + check(ddl).contains('USING HASH ("email")'); + }); + + test('MySQL/MariaDB emits `USING HASH` for method: .hash indexes', () async { + final ddl = await captureMariadbDdl( + (adapter, db) => db.createTables(), + ); + if (ddl == null) return; + + check(ddl).contains('USING HASH'); + }); + + test('SQLite drops HASH and falls back to a plain index', () async { + final ddl = await captureSqliteDdl( + (adapter, db) => db.createTables(), + ); + + check(ddl).contains('CREATE INDEX'); + check(ddl).not((d) => d.contains('USING HASH')); + }); +} diff --git a/typed_sql/test/typed_sql/index/hash_index/hash_index_test.dart b/typed_sql/test/typed_sql/index/hash_index/hash_index_test.dart new file mode 100644 index 00000000..25f2481c --- /dev/null +++ b/typed_sql/test/typed_sql/index/hash_index/hash_index_test.dart @@ -0,0 +1,49 @@ +// Copyright 2026 Google LLC +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// https://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +import 'package:typed_sql/typed_sql.dart'; +import '../../testrunner.dart'; + +part 'hash_index_test.g.dart'; + +abstract final class AccountDatabase extends Schema { + Table get accounts; +} + +@PrimaryKey(['id']) +abstract final class Account extends Row { + @AutoIncrement() + int get id; + + @Index.field(method: .hash) + @SqlOverride.field(dialect: 'mysql', columnType: 'VARCHAR(255)') + String get email; +} + +void main() { + final r = TestRunner( + setup: (db) async { + await db.createTables(); + }, + ); + + r.addTest('createTables() succeeds with a HASH index', (db) async { + await db.accounts.insertValue(email: 'jane@example.com').execute(); + + final item = await db.accounts.first.fetch(); + check(item).isNotNull().email.equals('jane@example.com'); + }); + + r.run(); +} diff --git a/typed_sql/test/typed_sql/index/hash_index/hash_index_test.g.dart b/typed_sql/test/typed_sql/index/hash_index/hash_index_test.g.dart new file mode 100644 index 00000000..aeae1af8 --- /dev/null +++ b/typed_sql/test/typed_sql/index/hash_index/hash_index_test.g.dart @@ -0,0 +1,504 @@ +// GENERATED CODE - DO NOT MODIFY BY HAND + +part of 'hash_index_test.dart'; + +// ************************************************************************** +// Generator: _TypedSqlBuilder +// ************************************************************************** + +/// Extension methods for a [Database] operating on [AccountDatabase]. +extension AccountDatabaseSchema on Database { + static final _$tables = [_$Account._$table]; + + Table get accounts => + $ForGeneratedCode.declareTable(this, _$Account._$table); + + /// Create tables defined in [AccountDatabase]. + /// + /// Calling this on an empty database will create the tables + /// defined in [AccountDatabase]. In production it's often better to + /// use [createAccountDatabaseTables] and manage migrations using + /// external tools. + /// + /// This method is mostly useful for testing. + /// + /// > [!WARNING] + /// > If the database is **not empty** behavior is undefined, most + /// > likely this operation will fail. + Future createTables() async => + $ForGeneratedCode.createTables(context: this, tables: _$tables); +} + +/// Get SQL [DDL statements][1] for tables defined in [AccountDatabase]. +/// +/// This returns a SQL script with multiple DDL statements separated by `;` +/// using the specified [dialect]. +/// +/// Executing these statements in an empty database will create the tables +/// defined in [AccountDatabase]. In practice, this method is often used for +/// printing the DDL statements, such that migrations can be managed by +/// external tools. +/// +/// [1]: https://en.wikipedia.org/wiki/Data_definition_language +String createAccountDatabaseTables(SqlDialect dialect) => + $ForGeneratedCode.createTableSchema( + dialect: dialect, + tables: AccountDatabaseSchema._$tables, + ); + +final class _$Account extends Account { + _$Account._(this.id, this.email); + + @override + final int id; + + @override + final String email; + + static final _$table = $ForGeneratedCode.tableDefinition( + tableName: 'accounts', + columns: ['id', 'email'], + columnInfo: [ + $ForGeneratedCode.columnDefinition( + type: $ForGeneratedCode.integer, + isNotNull: true, + defaultValue: null, + autoIncrement: true, + overrides: [], + ), + $ForGeneratedCode.columnDefinition( + type: $ForGeneratedCode.text, + isNotNull: true, + defaultValue: null, + autoIncrement: false, + overrides: [ + ( + dialect: 'mysql', + columnType: 'VARCHAR(255)', + defaultValue: null, + collation: null, + ), + ], + ), + ], + primaryKey: ['id'], + unique: >[], + foreignKeys: [], + indexes: [ + $ForGeneratedCode.indexDefinition( + name: null, + sqlName: null, + columns: ['email'], + method: .hash, + covering: [], + ), + ], + readRow: _$Account._$fromDatabase, + ); + + static Account? _$fromDatabase(RowReader row) { + final id = row.readInt(); + final email = row.readString(); + if (id == null && email == null) { + return null; + } + return _$Account._(id!, email!); + } + + @override + String toString() => 'Account(id: "$id", email: "$email")'; +} + +/// Extension methods for table defined in [Account]. +extension TableAccountExt on Table { + /// Insert row into the `accounts` table. + /// + /// Returns a [InsertSingle] statement on which `.execute` must be + /// called for the row to be inserted. + InsertSingle insert({Expr? id, required Expr email}) => + $ForGeneratedCode.insertInto(table: this, values: [id, email]); + + /// Insert row into the `accounts` table. + /// + /// Returns a [InsertSingle] statement on which `.execute` must be + /// called for the row to be inserted. + InsertSingle insertValue({int? id, required String email}) => + $ForGeneratedCode.insertInto( + table: this, + values: [id?.asExpr, email.asExpr], + ); + + /// Bulk insert rows into the `accounts` table. + /// + /// This method takes an `Iterable` and requires that you provide + /// a _mapping function_ from `T` to each column to be inserted. + /// + /// If a mapping function is omitted, the _default value_ will be + /// inserted, or `NULL` if column is nullable and as no default value. + /// To explicitely insert `NULL`, use a _mapping function_ that maps + /// `T` to `null`. + /// + /// > [!NOTE] + /// > This method aims utilize database specific bulk insertion logic + /// > to ensure good performance. Database adapters may pipeline bulk + /// > insertions through multiple statements inside a transaction. + /// + /// Returns a [Insert] statement on which `.execute` must be + /// called for the rows to be inserted. + Insert insertValuesMapped( + Iterable rows, { + int Function(T row)? id, + required String Function(T row) email, + }) => $ForGeneratedCode.insertValuesMapped( + table: this, + rows: rows, + mappings: [id, email], + ); + + /// Delete a single row from the `accounts` table, specified by + /// _primary key_. + /// + /// Returns a [DeleteSingle] statement on which `.execute()` must be + /// called for the row to be deleted. + /// + /// To delete multiple rows, using `.where()` to filter which rows + /// should be deleted. If you wish to delete all rows, use + /// `.where((_) => toExpr(true)).delete()`. + DeleteSingle delete(int id) => + $ForGeneratedCode.deleteSingle(byKey(id), _$Account._$table); +} + +/// Extension methods for building queries against the `accounts` table. +extension QueryAccountExt on Query<(Expr,)> { + /// Lookup a single row in `accounts` table using the _primary key_. + /// + /// Returns a [QuerySingle] object, which returns at-most one row, + /// when `.fetch()` is called. + QuerySingle<(Expr,)> byKey(int id) => + where((account) => account.id.equalsValue(id)).first; + + /// Update all rows in the `accounts` table matching this [Query]. + /// + /// The changes to be applied to each row matching this [Query] are + /// defined using the [updateBuilder], which is given an [Expr] + /// representation of the row being updated and a `set` function to + /// specify which fields should be updated. The result of the `set` + /// function should always be returned from the `updateBuilder`. + /// + /// Returns an [Update] statement on which `.execute()` must be called + /// for the rows to be updated. + /// + /// **Example:** decrementing `1` from the `value` field for each row + /// where `value > 0`. + /// ```dart + /// await db.mytable + /// .where((row) => row.value > toExpr(0)) + /// .update((row, set) => set( + /// value: row.value - toExpr(1), + /// )) + /// .execute(); + /// ``` + /// + /// > [!WARNING] + /// > The `updateBuilder` callback does not make the update, it builds + /// > the expressions for updating the rows. You should **never** invoke + /// > the `set` function more than once, and the result should always + /// > be returned immediately. + Update update( + UpdateSet Function( + Expr account, + UpdateSet Function({Expr id, Expr email}) set, + ) + updateBuilder, + ) => $ForGeneratedCode.update( + this, + _$Account._$table, + (account) => updateBuilder( + account, + ({Expr? id, Expr? email}) => + $ForGeneratedCode.buildUpdate([id, email]), + ), + ); + + /// Delete all rows in the `accounts` table matching this [Query]. + /// + /// Returns a [Delete] statement on which `.execute()` must be called + /// for the rows to be deleted. + Delete delete() => $ForGeneratedCode.delete(this, _$Account._$table); +} + +/// Extension methods for building point queries against the `accounts` table. +extension QuerySingleAccountExt on QuerySingle<(Expr,)> { + /// Update the row (if any) in the `accounts` table matching this + /// [QuerySingle]. + /// + /// The changes to be applied to the row matching this [QuerySingle] are + /// defined using the [updateBuilder], which is given an [Expr] + /// representation of the row being updated and a `set` function to + /// specify which fields should be updated. The result of the `set` + /// function should always be returned from the `updateBuilder`. + /// + /// Returns an [UpdateSingle] statement on which `.execute()` must be + /// called for the row to be updated. The resulting statement will + /// **not** fail, if there are no rows matching this query exists. + /// + /// **Example:** decrementing `1` from the `value` field the row with + /// `id = 1`. + /// ```dart + /// await db.mytable + /// .byKey(1) + /// .update((row, set) => set( + /// value: row.value - toExpr(1), + /// )) + /// .execute(); + /// ``` + /// + /// > [!WARNING] + /// > The `updateBuilder` callback does not make the update, it builds + /// > the expressions for updating the rows. You should **never** invoke + /// > the `set` function more than once, and the result should always + /// > be returned immediately. + UpdateSingle update( + UpdateSet Function( + Expr account, + UpdateSet Function({Expr id, Expr email}) set, + ) + updateBuilder, + ) => $ForGeneratedCode.updateSingle( + this, + _$Account._$table, + (account) => updateBuilder( + account, + ({Expr? id, Expr? email}) => + $ForGeneratedCode.buildUpdate([id, email]), + ), + ); + + /// Delete the row (if any) in the `accounts` table matching this [QuerySingle]. + /// + /// Returns a [DeleteSingle] statement on which `.execute()` must be called + /// for the row to be deleted. The resulting statement will **not** + /// fail, if there are no rows matching this query exists. + DeleteSingle delete() => + $ForGeneratedCode.deleteSingle(this, _$Account._$table); +} + +/// Extension methods for expressions on a row in the `accounts` table. +extension ExpressionAccountExt on Expr { + Expr get id => + $ForGeneratedCode.field(this, 0, $ForGeneratedCode.integer); + + Expr get email => + $ForGeneratedCode.field(this, 1, $ForGeneratedCode.text); +} + +extension ExpressionNullableAccountExt on Expr { + Expr get id => + $ForGeneratedCode.field(this, 0, $ForGeneratedCode.integer); + + Expr get email => + $ForGeneratedCode.field(this, 1, $ForGeneratedCode.text); + + /// Check if the row is not `NULL`. + /// + /// This will check if _primary key_ fields in this row are `NULL`. + /// + /// If this is a reference lookup by subquery it might be more efficient + /// to check if the referencing field is `NULL`. + Expr isNotNull() => id.isNotNull(); + + /// Check if the row is `NULL`. + /// + /// This will check if _primary key_ fields in this row are `NULL`. + /// + /// If this is a reference lookup by subquery it might be more efficient + /// to check if the referencing field is `NULL`. + Expr isNull() => isNotNull().not(); +} + +/// `Table` conflict targets for use with `.onConflict`. +enum AccountConflict { + /// Conflict with an existing row that has a matching primary key. + /// + /// Thus, the other row has matching values for: + /// `id`. + primaryKey(['id']); + + const AccountConflict(this._fields); + + final List _fields; +} + +extension InsertAccountExt on Insert { + /// Build an `INSERT` statement with an `ON CONFLICT` clause. + /// + /// The [target] argument specifies the _conflict target_ to be + /// handled. The _conflict target_ is always a `UNIQUE` constraint or + /// `PRIMARY KEY` constraint. + /// + /// If a row to be inserted violates the _conflict target_ constraint, + /// then the conflict action is triggered: + /// * `.doNothing()` to skip insertion of the new row, and, + /// * `.update((account, excluded, set) => set(...))` to + /// update the conflicting row. + /// + /// If a row to be inserted violates a constraint other than the one + /// specified in _conflict target_ then the entire `INSERT` statement + /// will fail. + /// + /// This is equivalent to `INSERT ... ON CONFLICT (...)` in SQL. + InsertOnConflict onConflict(AccountConflict target) => + $ForGeneratedCode.insertOnConflict(this, target._fields); +} + +extension InsertOnConflictAccountExt on InsertOnConflict { + /// Build an `INSERT` statement an [upsert-clause][1]. + /// + /// When a row to be inserted violates the `UNIQUE` or `PRIMARY KEY` + /// constraint previously specified as _conflict target_, the existing + /// row is updated using the expressions defined with the + /// [updateBuilder]. The [updateBuilder] is given 3 parameters: + /// * `account` an [Expr] representing the existing row in + /// the database, + /// * `excluded` an [Expr] representing the row to be inserted in the + /// database, and, + /// * `set` a function to specify which fields should be updated and + /// build the [UpdateSet]. + /// + /// The result of the `set` function should always be immediately + /// returned from the [updateBuilder]. + /// + /// **Example:** Insert a counter with `count = 2` or increment the + /// existing row, if a `PRIMARY KEY` conflict occurs. + /// ```dart + /// await db.counters.insertValue( + /// name: 'my-counter', // primary key + /// count: 2, + /// ) + /// .onConflict(.primaryKey) + /// .update((counter, excluded, set) => set( + /// count: counter.count + excluded.count, + /// )) + /// .execute(); + /// ``` + /// + /// This is equivalent to + /// `INSERT ... ON CONFLICT (...) UPDATE SET ...` in SQL. + /// + /// > [!WARNING] + /// > The `updateBuilder` callback does not make the update, it builds + /// > the expressions for updating the rows. You should **never** invoke + /// > the `set` function more than once, and the result should always + /// > be returned immediately. + /// + /// [1]: https://www.sqlite.org/lang_upsert.html + Upsert update( + UpdateSet Function( + Expr account, + Expr excluded, + UpdateSet Function({Expr id, Expr email}) set, + ) + updateBuilder, + ) => $ForGeneratedCode.updateOnConflict( + this, + (account, excluded) => updateBuilder( + account, + excluded, + ({Expr? id, Expr? email}) => + $ForGeneratedCode.buildUpdate([id, email]), + ), + ); +} + +extension InsertSingleAccountExt on InsertSingle { + /// Build an `INSERT` statement with an `ON CONFLICT` clause. + /// + /// The [target] argument specifies the _conflict target_ to be + /// handled. The _conflict target_ is always a `UNIQUE` constraint or + /// `PRIMARY KEY` constraint. + /// + /// If a row to be inserted violates the _conflict target_ constraint, + /// then the conflict action is triggered: + /// * `.doNothing()` to skip insertion of the new row, and, + /// * `.update((account, excluded, set) => set(...))` to + /// update the conflicting row. + /// + /// If a row to be inserted violates a constraint other than the one + /// specified in _conflict target_ then the entire `INSERT` statement + /// will fail. + /// + /// This is equivalent to `INSERT ... ON CONFLICT (...)` in SQL. + InsertOnConflictSingle onConflict(AccountConflict target) => + $ForGeneratedCode.insertOnConflictSingle(this, target._fields); +} + +extension InsertOnConflictSingleAccountExt on InsertOnConflictSingle { + /// Build an `INSERT` statement an [upsert-clause][1]. + /// + /// When a row to be inserted violates the `UNIQUE` or `PRIMARY KEY` + /// constraint previously specified as _conflict target_, the existing + /// row is updated using the expressions defined with the + /// [updateBuilder]. The [updateBuilder] is given 3 parameters: + /// * `account` an [Expr] representing the existing row in + /// the database, + /// * `excluded` an [Expr] representing the row to be inserted in the + /// database, and, + /// * `set` a function to specify which fields should be updated and + /// build the [UpdateSet]. + /// + /// The result of the `set` function should always be immediately + /// returned from the [updateBuilder]. + /// + /// **Example:** Insert a counter with `count = 2` or increment the + /// existing row, if a `PRIMARY KEY` conflict occurs. + /// ```dart + /// await db.counters.insertValue( + /// name: 'my-counter', // primary key + /// count: 2, + /// ) + /// .onConflict(.primaryKey) + /// .update((counter, excluded, set) => set( + /// count: counter.count + excluded.count, + /// )) + /// .execute(); + /// ``` + /// + /// This is equivalent to + /// `INSERT ... ON CONFLICT (...) UPDATE SET ...` in SQL. + /// + /// > [!WARNING] + /// > The `updateBuilder` callback does not make the update, it builds + /// > the expressions for updating the rows. You should **never** invoke + /// > the `set` function more than once, and the result should always + /// > be returned immediately. + /// + /// [1]: https://www.sqlite.org/lang_upsert.html + UpsertSingle update( + UpdateSet Function( + Expr account, + Expr excluded, + UpdateSet Function({Expr id, Expr email}) set, + ) + updateBuilder, + ) => $ForGeneratedCode.updateOnConflictSingle( + this, + (account, excluded) => updateBuilder( + account, + excluded, + ({Expr? id, Expr? email}) => + $ForGeneratedCode.buildUpdate([id, email]), + ), + ); +} + +/// Extension methods for assertions on [Account] using +/// [`package:checks`][1]. +/// +/// [1]: https://pub.dev/packages/checks +extension AccountChecks on Subject { + /// Create assertions on [Account.id]. + Subject get id => has((m) => m.id, 'id'); + + /// Create assertions on [Account.email]. + Subject get email => has((m) => m.email, 'email'); +} diff --git a/typed_sql/test/typed_sql/index/index_ddl_helpers.dart b/typed_sql/test/typed_sql/index/index_ddl_helpers.dart new file mode 100644 index 00000000..e7a9139e --- /dev/null +++ b/typed_sql/test/typed_sql/index/index_ddl_helpers.dart @@ -0,0 +1,107 @@ +// Copyright 2026 Google LLC +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// https://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +import 'dart:io'; + +import 'package:test/test.dart'; +import 'package:typed_sql/src/adapter/mysql_adapter.dart' + show mysqlTestingAdapter; +import 'package:typed_sql/src/dialect/mysql_dialect.dart'; +import 'package:typed_sql/typed_sql.dart'; + +/// Runs [body] against a live Postgres test database, wrapped in a logging +/// [DatabaseAdapter], and returns the SQL text it logged (joined by `\n`). +/// +/// [body] is given both the raw [DatabaseAdapter] (to run bootstrap SQL, +/// e.g. `CREATE EXTENSION`, before/around [Database] calls) and the +/// constructed `Database`. +/// +/// Returns `null` (after calling [markTestSkipped]) if no local Postgres +/// test database is available. +Future capturePostgresDdl( + Future Function(DatabaseAdapter adapter, Database db) body, +) async { + final socketFile = File('.dart_tool/run/postgresql/.s.PGSQL.5432'); + if (!socketFile.existsSync() && + Platform.environment['POSTGRES_PORT'] == null) { + markTestSkipped('No local postgres test database available'); + return null; + } + + final logs = []; + final adapter = DatabaseAdapter.withLogging( + DatabaseAdapter.postgresTestDatabase( + host: socketFile.existsSync() ? socketFile.absolute.path : null, + port: int.tryParse(Platform.environment['POSTGRES_PORT'] ?? ''), + ), + logs.add, + ); + try { + await body(adapter, Database(adapter, SqlDialect.postgres())); + } finally { + await adapter.close(force: true); + } + return logs.join('\n'); +} + +/// Runs [body] against a fresh in-memory SQLite database, wrapped in a +/// logging [DatabaseAdapter], and returns the SQL text it logged (joined by +/// `\n`). +Future captureSqliteDdl( + Future Function(DatabaseAdapter adapter, Database db) body, +) async { + final logs = []; + final adapter = DatabaseAdapter.withLogging( + DatabaseAdapter.sqlite3TestDatabase(), + logs.add, + ); + try { + await body(adapter, Database(adapter, SqlDialect.sqlite())); + } finally { + await adapter.close(); + } + return logs.join('\n'); +} + +/// Runs [body] against a live MySQL/MariaDB test database, wrapped in a +/// logging [DatabaseAdapter], and returns the SQL text it logged (joined by +/// `\n`). +/// +/// Returns `null` (after calling [markTestSkipped]) if no local +/// MySQL/MariaDB test database is available. +Future captureMariadbDdl( + Future Function(DatabaseAdapter adapter, Database db) body, +) async { + final socketFile = File('.dart_tool/run/mariadb/mysqld.sock'); + if (!socketFile.existsSync() && + Platform.environment['MARIADB_PORT'] == null) { + markTestSkipped('No local mariadb test database available'); + return null; + } + + final logs = []; + final adapter = DatabaseAdapter.withLogging( + mysqlTestingAdapter( + host: socketFile.existsSync() ? socketFile.absolute.path : null, + port: int.tryParse(Platform.environment['MARIADB_PORT'] ?? ''), + ), + logs.add, + ); + try { + await body(adapter, Database(adapter, mysqlDialect())); + } finally { + await adapter.close(); + } + return logs.join('\n'); +} diff --git a/typed_sql/test/typed_sql/index/spgist_index/spgist_index_ddl_test.dart b/typed_sql/test/typed_sql/index/spgist_index/spgist_index_ddl_test.dart new file mode 100644 index 00000000..4ea7f86a --- /dev/null +++ b/typed_sql/test/typed_sql/index/spgist_index/spgist_index_ddl_test.dart @@ -0,0 +1,49 @@ +// Copyright 2026 Google LLC +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// https://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +import 'package:checks/checks.dart'; +import 'package:test/test.dart'; + +import '../index_ddl_helpers.dart'; +import 'spgist_index_test.dart' hide main; + +void main() { + test('Postgres emits `USING SPGIST` for method: .spgist indexes', () async { + final ddl = await capturePostgresDdl( + (adapter, db) => db.createTables(), + ); + if (ddl == null) return; + + check(ddl).contains('USING SPGIST ("name")'); + }); + + test('SQLite drops SPGIST and falls back to a plain index', () async { + final ddl = await captureSqliteDdl( + (adapter, db) => db.createTables(), + ); + + check(ddl).contains('CREATE INDEX'); + check(ddl).not((d) => d.contains('SPGIST')); + }); + + test('MySQL/MariaDB drops SPGIST and falls back to a plain index', () async { + final ddl = await captureMariadbDdl( + (adapter, db) => db.createTables(), + ); + if (ddl == null) return; + + check(ddl).contains('CREATE INDEX'); + check(ddl).not((d) => d.contains('SPGIST')); + }); +} diff --git a/typed_sql/test/typed_sql/index/spgist_index/spgist_index_test.dart b/typed_sql/test/typed_sql/index/spgist_index/spgist_index_test.dart new file mode 100644 index 00000000..50572ec4 --- /dev/null +++ b/typed_sql/test/typed_sql/index/spgist_index/spgist_index_test.dart @@ -0,0 +1,49 @@ +// Copyright 2026 Google LLC +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// https://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +import 'package:typed_sql/typed_sql.dart'; +import '../../testrunner.dart'; + +part 'spgist_index_test.g.dart'; + +abstract final class DirectoryDatabase extends Schema { + Table get entries; +} + +@PrimaryKey(['id']) +abstract final class Entry extends Row { + @AutoIncrement() + int get id; + + @Index.field(method: .spgist) + @SqlOverride.field(dialect: 'mysql', columnType: 'VARCHAR(255)') + String get name; +} + +void main() { + final r = TestRunner( + setup: (db) async { + await db.createTables(); + }, + ); + + r.addTest('createTables() succeeds with an SP-GiST index', (db) async { + await db.entries.insertValue(name: 'jane').execute(); + + final item = await db.entries.first.fetch(); + check(item).isNotNull().name.equals('jane'); + }); + + r.run(); +} diff --git a/typed_sql/test/typed_sql/index/spgist_index/spgist_index_test.g.dart b/typed_sql/test/typed_sql/index/spgist_index/spgist_index_test.g.dart new file mode 100644 index 00000000..9000f823 --- /dev/null +++ b/typed_sql/test/typed_sql/index/spgist_index/spgist_index_test.g.dart @@ -0,0 +1,504 @@ +// GENERATED CODE - DO NOT MODIFY BY HAND + +part of 'spgist_index_test.dart'; + +// ************************************************************************** +// Generator: _TypedSqlBuilder +// ************************************************************************** + +/// Extension methods for a [Database] operating on [DirectoryDatabase]. +extension DirectoryDatabaseSchema on Database { + static final _$tables = [_$Entry._$table]; + + Table get entries => + $ForGeneratedCode.declareTable(this, _$Entry._$table); + + /// Create tables defined in [DirectoryDatabase]. + /// + /// Calling this on an empty database will create the tables + /// defined in [DirectoryDatabase]. In production it's often better to + /// use [createDirectoryDatabaseTables] and manage migrations using + /// external tools. + /// + /// This method is mostly useful for testing. + /// + /// > [!WARNING] + /// > If the database is **not empty** behavior is undefined, most + /// > likely this operation will fail. + Future createTables() async => + $ForGeneratedCode.createTables(context: this, tables: _$tables); +} + +/// Get SQL [DDL statements][1] for tables defined in [DirectoryDatabase]. +/// +/// This returns a SQL script with multiple DDL statements separated by `;` +/// using the specified [dialect]. +/// +/// Executing these statements in an empty database will create the tables +/// defined in [DirectoryDatabase]. In practice, this method is often used for +/// printing the DDL statements, such that migrations can be managed by +/// external tools. +/// +/// [1]: https://en.wikipedia.org/wiki/Data_definition_language +String createDirectoryDatabaseTables(SqlDialect dialect) => + $ForGeneratedCode.createTableSchema( + dialect: dialect, + tables: DirectoryDatabaseSchema._$tables, + ); + +final class _$Entry extends Entry { + _$Entry._(this.id, this.name); + + @override + final int id; + + @override + final String name; + + static final _$table = $ForGeneratedCode.tableDefinition( + tableName: 'entries', + columns: ['id', 'name'], + columnInfo: [ + $ForGeneratedCode.columnDefinition( + type: $ForGeneratedCode.integer, + isNotNull: true, + defaultValue: null, + autoIncrement: true, + overrides: [], + ), + $ForGeneratedCode.columnDefinition( + type: $ForGeneratedCode.text, + isNotNull: true, + defaultValue: null, + autoIncrement: false, + overrides: [ + ( + dialect: 'mysql', + columnType: 'VARCHAR(255)', + defaultValue: null, + collation: null, + ), + ], + ), + ], + primaryKey: ['id'], + unique: >[], + foreignKeys: [], + indexes: [ + $ForGeneratedCode.indexDefinition( + name: null, + sqlName: null, + columns: ['name'], + method: .spgist, + covering: [], + ), + ], + readRow: _$Entry._$fromDatabase, + ); + + static Entry? _$fromDatabase(RowReader row) { + final id = row.readInt(); + final name = row.readString(); + if (id == null && name == null) { + return null; + } + return _$Entry._(id!, name!); + } + + @override + String toString() => 'Entry(id: "$id", name: "$name")'; +} + +/// Extension methods for table defined in [Entry]. +extension TableEntryExt on Table { + /// Insert row into the `entries` table. + /// + /// Returns a [InsertSingle] statement on which `.execute` must be + /// called for the row to be inserted. + InsertSingle insert({Expr? id, required Expr name}) => + $ForGeneratedCode.insertInto(table: this, values: [id, name]); + + /// Insert row into the `entries` table. + /// + /// Returns a [InsertSingle] statement on which `.execute` must be + /// called for the row to be inserted. + InsertSingle insertValue({int? id, required String name}) => + $ForGeneratedCode.insertInto( + table: this, + values: [id?.asExpr, name.asExpr], + ); + + /// Bulk insert rows into the `entries` table. + /// + /// This method takes an `Iterable` and requires that you provide + /// a _mapping function_ from `T` to each column to be inserted. + /// + /// If a mapping function is omitted, the _default value_ will be + /// inserted, or `NULL` if column is nullable and as no default value. + /// To explicitely insert `NULL`, use a _mapping function_ that maps + /// `T` to `null`. + /// + /// > [!NOTE] + /// > This method aims utilize database specific bulk insertion logic + /// > to ensure good performance. Database adapters may pipeline bulk + /// > insertions through multiple statements inside a transaction. + /// + /// Returns a [Insert] statement on which `.execute` must be + /// called for the rows to be inserted. + Insert insertValuesMapped( + Iterable rows, { + int Function(T row)? id, + required String Function(T row) name, + }) => $ForGeneratedCode.insertValuesMapped( + table: this, + rows: rows, + mappings: [id, name], + ); + + /// Delete a single row from the `entries` table, specified by + /// _primary key_. + /// + /// Returns a [DeleteSingle] statement on which `.execute()` must be + /// called for the row to be deleted. + /// + /// To delete multiple rows, using `.where()` to filter which rows + /// should be deleted. If you wish to delete all rows, use + /// `.where((_) => toExpr(true)).delete()`. + DeleteSingle delete(int id) => + $ForGeneratedCode.deleteSingle(byKey(id), _$Entry._$table); +} + +/// Extension methods for building queries against the `entries` table. +extension QueryEntryExt on Query<(Expr,)> { + /// Lookup a single row in `entries` table using the _primary key_. + /// + /// Returns a [QuerySingle] object, which returns at-most one row, + /// when `.fetch()` is called. + QuerySingle<(Expr,)> byKey(int id) => + where((entry) => entry.id.equalsValue(id)).first; + + /// Update all rows in the `entries` table matching this [Query]. + /// + /// The changes to be applied to each row matching this [Query] are + /// defined using the [updateBuilder], which is given an [Expr] + /// representation of the row being updated and a `set` function to + /// specify which fields should be updated. The result of the `set` + /// function should always be returned from the `updateBuilder`. + /// + /// Returns an [Update] statement on which `.execute()` must be called + /// for the rows to be updated. + /// + /// **Example:** decrementing `1` from the `value` field for each row + /// where `value > 0`. + /// ```dart + /// await db.mytable + /// .where((row) => row.value > toExpr(0)) + /// .update((row, set) => set( + /// value: row.value - toExpr(1), + /// )) + /// .execute(); + /// ``` + /// + /// > [!WARNING] + /// > The `updateBuilder` callback does not make the update, it builds + /// > the expressions for updating the rows. You should **never** invoke + /// > the `set` function more than once, and the result should always + /// > be returned immediately. + Update update( + UpdateSet Function( + Expr entry, + UpdateSet Function({Expr id, Expr name}) set, + ) + updateBuilder, + ) => $ForGeneratedCode.update( + this, + _$Entry._$table, + (entry) => updateBuilder( + entry, + ({Expr? id, Expr? name}) => + $ForGeneratedCode.buildUpdate([id, name]), + ), + ); + + /// Delete all rows in the `entries` table matching this [Query]. + /// + /// Returns a [Delete] statement on which `.execute()` must be called + /// for the rows to be deleted. + Delete delete() => $ForGeneratedCode.delete(this, _$Entry._$table); +} + +/// Extension methods for building point queries against the `entries` table. +extension QuerySingleEntryExt on QuerySingle<(Expr,)> { + /// Update the row (if any) in the `entries` table matching this + /// [QuerySingle]. + /// + /// The changes to be applied to the row matching this [QuerySingle] are + /// defined using the [updateBuilder], which is given an [Expr] + /// representation of the row being updated and a `set` function to + /// specify which fields should be updated. The result of the `set` + /// function should always be returned from the `updateBuilder`. + /// + /// Returns an [UpdateSingle] statement on which `.execute()` must be + /// called for the row to be updated. The resulting statement will + /// **not** fail, if there are no rows matching this query exists. + /// + /// **Example:** decrementing `1` from the `value` field the row with + /// `id = 1`. + /// ```dart + /// await db.mytable + /// .byKey(1) + /// .update((row, set) => set( + /// value: row.value - toExpr(1), + /// )) + /// .execute(); + /// ``` + /// + /// > [!WARNING] + /// > The `updateBuilder` callback does not make the update, it builds + /// > the expressions for updating the rows. You should **never** invoke + /// > the `set` function more than once, and the result should always + /// > be returned immediately. + UpdateSingle update( + UpdateSet Function( + Expr entry, + UpdateSet Function({Expr id, Expr name}) set, + ) + updateBuilder, + ) => $ForGeneratedCode.updateSingle( + this, + _$Entry._$table, + (entry) => updateBuilder( + entry, + ({Expr? id, Expr? name}) => + $ForGeneratedCode.buildUpdate([id, name]), + ), + ); + + /// Delete the row (if any) in the `entries` table matching this [QuerySingle]. + /// + /// Returns a [DeleteSingle] statement on which `.execute()` must be called + /// for the row to be deleted. The resulting statement will **not** + /// fail, if there are no rows matching this query exists. + DeleteSingle delete() => + $ForGeneratedCode.deleteSingle(this, _$Entry._$table); +} + +/// Extension methods for expressions on a row in the `entries` table. +extension ExpressionEntryExt on Expr { + Expr get id => + $ForGeneratedCode.field(this, 0, $ForGeneratedCode.integer); + + Expr get name => + $ForGeneratedCode.field(this, 1, $ForGeneratedCode.text); +} + +extension ExpressionNullableEntryExt on Expr { + Expr get id => + $ForGeneratedCode.field(this, 0, $ForGeneratedCode.integer); + + Expr get name => + $ForGeneratedCode.field(this, 1, $ForGeneratedCode.text); + + /// Check if the row is not `NULL`. + /// + /// This will check if _primary key_ fields in this row are `NULL`. + /// + /// If this is a reference lookup by subquery it might be more efficient + /// to check if the referencing field is `NULL`. + Expr isNotNull() => id.isNotNull(); + + /// Check if the row is `NULL`. + /// + /// This will check if _primary key_ fields in this row are `NULL`. + /// + /// If this is a reference lookup by subquery it might be more efficient + /// to check if the referencing field is `NULL`. + Expr isNull() => isNotNull().not(); +} + +/// `Table` conflict targets for use with `.onConflict`. +enum EntryConflict { + /// Conflict with an existing row that has a matching primary key. + /// + /// Thus, the other row has matching values for: + /// `id`. + primaryKey(['id']); + + const EntryConflict(this._fields); + + final List _fields; +} + +extension InsertEntryExt on Insert { + /// Build an `INSERT` statement with an `ON CONFLICT` clause. + /// + /// The [target] argument specifies the _conflict target_ to be + /// handled. The _conflict target_ is always a `UNIQUE` constraint or + /// `PRIMARY KEY` constraint. + /// + /// If a row to be inserted violates the _conflict target_ constraint, + /// then the conflict action is triggered: + /// * `.doNothing()` to skip insertion of the new row, and, + /// * `.update((entry, excluded, set) => set(...))` to + /// update the conflicting row. + /// + /// If a row to be inserted violates a constraint other than the one + /// specified in _conflict target_ then the entire `INSERT` statement + /// will fail. + /// + /// This is equivalent to `INSERT ... ON CONFLICT (...)` in SQL. + InsertOnConflict onConflict(EntryConflict target) => + $ForGeneratedCode.insertOnConflict(this, target._fields); +} + +extension InsertOnConflictEntryExt on InsertOnConflict { + /// Build an `INSERT` statement an [upsert-clause][1]. + /// + /// When a row to be inserted violates the `UNIQUE` or `PRIMARY KEY` + /// constraint previously specified as _conflict target_, the existing + /// row is updated using the expressions defined with the + /// [updateBuilder]. The [updateBuilder] is given 3 parameters: + /// * `entry` an [Expr] representing the existing row in + /// the database, + /// * `excluded` an [Expr] representing the row to be inserted in the + /// database, and, + /// * `set` a function to specify which fields should be updated and + /// build the [UpdateSet]. + /// + /// The result of the `set` function should always be immediately + /// returned from the [updateBuilder]. + /// + /// **Example:** Insert a counter with `count = 2` or increment the + /// existing row, if a `PRIMARY KEY` conflict occurs. + /// ```dart + /// await db.counters.insertValue( + /// name: 'my-counter', // primary key + /// count: 2, + /// ) + /// .onConflict(.primaryKey) + /// .update((counter, excluded, set) => set( + /// count: counter.count + excluded.count, + /// )) + /// .execute(); + /// ``` + /// + /// This is equivalent to + /// `INSERT ... ON CONFLICT (...) UPDATE SET ...` in SQL. + /// + /// > [!WARNING] + /// > The `updateBuilder` callback does not make the update, it builds + /// > the expressions for updating the rows. You should **never** invoke + /// > the `set` function more than once, and the result should always + /// > be returned immediately. + /// + /// [1]: https://www.sqlite.org/lang_upsert.html + Upsert update( + UpdateSet Function( + Expr entry, + Expr excluded, + UpdateSet Function({Expr id, Expr name}) set, + ) + updateBuilder, + ) => $ForGeneratedCode.updateOnConflict( + this, + (entry, excluded) => updateBuilder( + entry, + excluded, + ({Expr? id, Expr? name}) => + $ForGeneratedCode.buildUpdate([id, name]), + ), + ); +} + +extension InsertSingleEntryExt on InsertSingle { + /// Build an `INSERT` statement with an `ON CONFLICT` clause. + /// + /// The [target] argument specifies the _conflict target_ to be + /// handled. The _conflict target_ is always a `UNIQUE` constraint or + /// `PRIMARY KEY` constraint. + /// + /// If a row to be inserted violates the _conflict target_ constraint, + /// then the conflict action is triggered: + /// * `.doNothing()` to skip insertion of the new row, and, + /// * `.update((entry, excluded, set) => set(...))` to + /// update the conflicting row. + /// + /// If a row to be inserted violates a constraint other than the one + /// specified in _conflict target_ then the entire `INSERT` statement + /// will fail. + /// + /// This is equivalent to `INSERT ... ON CONFLICT (...)` in SQL. + InsertOnConflictSingle onConflict(EntryConflict target) => + $ForGeneratedCode.insertOnConflictSingle(this, target._fields); +} + +extension InsertOnConflictSingleEntryExt on InsertOnConflictSingle { + /// Build an `INSERT` statement an [upsert-clause][1]. + /// + /// When a row to be inserted violates the `UNIQUE` or `PRIMARY KEY` + /// constraint previously specified as _conflict target_, the existing + /// row is updated using the expressions defined with the + /// [updateBuilder]. The [updateBuilder] is given 3 parameters: + /// * `entry` an [Expr] representing the existing row in + /// the database, + /// * `excluded` an [Expr] representing the row to be inserted in the + /// database, and, + /// * `set` a function to specify which fields should be updated and + /// build the [UpdateSet]. + /// + /// The result of the `set` function should always be immediately + /// returned from the [updateBuilder]. + /// + /// **Example:** Insert a counter with `count = 2` or increment the + /// existing row, if a `PRIMARY KEY` conflict occurs. + /// ```dart + /// await db.counters.insertValue( + /// name: 'my-counter', // primary key + /// count: 2, + /// ) + /// .onConflict(.primaryKey) + /// .update((counter, excluded, set) => set( + /// count: counter.count + excluded.count, + /// )) + /// .execute(); + /// ``` + /// + /// This is equivalent to + /// `INSERT ... ON CONFLICT (...) UPDATE SET ...` in SQL. + /// + /// > [!WARNING] + /// > The `updateBuilder` callback does not make the update, it builds + /// > the expressions for updating the rows. You should **never** invoke + /// > the `set` function more than once, and the result should always + /// > be returned immediately. + /// + /// [1]: https://www.sqlite.org/lang_upsert.html + UpsertSingle update( + UpdateSet Function( + Expr entry, + Expr excluded, + UpdateSet Function({Expr id, Expr name}) set, + ) + updateBuilder, + ) => $ForGeneratedCode.updateOnConflictSingle( + this, + (entry, excluded) => updateBuilder( + entry, + excluded, + ({Expr? id, Expr? name}) => + $ForGeneratedCode.buildUpdate([id, name]), + ), + ); +} + +/// Extension methods for assertions on [Entry] using +/// [`package:checks`][1]. +/// +/// [1]: https://pub.dev/packages/checks +extension EntryChecks on Subject { + /// Create assertions on [Entry.id]. + Subject get id => has((m) => m.id, 'id'); + + /// Create assertions on [Entry.name]. + Subject get name => has((m) => m.name, 'name'); +} diff --git a/typed_sql/test/typed_sql/overrides/schema_snake_case/schema_snake_case_test.g.dart b/typed_sql/test/typed_sql/overrides/schema_snake_case/schema_snake_case_test.g.dart index 05a4a91c..e3f02b1d 100644 --- a/typed_sql/test/typed_sql/overrides/schema_snake_case/schema_snake_case_test.g.dart +++ b/typed_sql/test/typed_sql/overrides/schema_snake_case/schema_snake_case_test.g.dart @@ -148,11 +148,15 @@ final class _$SnakeUser extends SnakeUser { name: null, sqlName: null, columns: ['last_name'], + method: .btree, + covering: [], ), $ForGeneratedCode.indexDefinition( name: null, sqlName: null, columns: ['last_name', 'first_name'], + method: .btree, + covering: [], ), ], readRow: _$SnakeUser._$fromDatabase,