diff --git a/typed_sql/CHANGELOG.md b/typed_sql/CHANGELOG.md index a4e2c875..c9500bcd 100644 --- a/typed_sql/CHANGELOG.md +++ b/typed_sql/CHANGELOG.md @@ -1,3 +1,7 @@ +## 0.1.14 + * Support `JSON` condition operators (as PostgreSQL-only helper library). + + ## 0.1.13 * Support `CREATE INDEX` DDLs through `@Index` annotations. diff --git a/typed_sql/lib/postgres_helpers.dart b/typed_sql/lib/postgres_helpers.dart new file mode 100644 index 00000000..6697dc06 --- /dev/null +++ b/typed_sql/lib/postgres_helpers.dart @@ -0,0 +1,27 @@ +// 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. + +/// PostgreSQL-only extensions for `package:typed_sql`. +/// +/// Import this library _alongside_ `package:typed_sql/typed_sql.dart` to +/// get access to expressions that only work with PostgreSQL, such as JSONB +/// containment (`@>`, `<@`) and key-existence (`?`, `?|`, `?&`) operators. +/// +/// > [!WARNING] +/// > Queries using these operators will throw `UnsupportedError` if executed +/// > against SQLite or MySQL/MariaDB. Only import this library in code that +/// > specifically targets PostgreSQL. +library; + +export 'src/typed_sql.dart' show PostgresJsonConditions; diff --git a/typed_sql/lib/src/dialect/dialect.dart b/typed_sql/lib/src/dialect/dialect.dart index 784f5965..c964cdc9 100644 --- a/typed_sql/lib/src/dialect/dialect.dart +++ b/typed_sql/lib/src/dialect/dialect.dart @@ -53,7 +53,12 @@ export '../typed_sql.dart' ExpressionIsFalse, ExpressionIsNotDistinctFrom, ExpressionIsTrue, + ExpressionJsonContainedBy, + ExpressionJsonContains, ExpressionJsonExtract, + ExpressionJsonHasAllKeys, + ExpressionJsonHasAnyKey, + ExpressionJsonHasKey, ExpressionJsonRef, ExpressionJsonRefIndex, ExpressionJsonRefKey, diff --git a/typed_sql/lib/src/dialect/mysql_dialect.dart b/typed_sql/lib/src/dialect/mysql_dialect.dart index 2ffe7a34..e040df06 100644 --- a/typed_sql/lib/src/dialect/mysql_dialect.dart +++ b/typed_sql/lib/src/dialect/mysql_dialect.dart @@ -691,6 +691,14 @@ extension on ExpressionResolver { ExpressionJsonRef e => extractJsonRef(e), ExpressionJsonExtract(:final value) => 'CASE WHEN JSON_TYPE(${expr(value)}) = \'NULL\' THEN NULL ELSE JSON_UNQUOTE(${expr(value)}) END', + ExpressionJsonContains() || + ExpressionJsonContainedBy() || + ExpressionJsonHasKey() || + ExpressionJsonHasAnyKey() || + ExpressionJsonHasAllKeys() => throw UnsupportedError( + 'JSONB containment/existence operators are not supported by ' + 'MySQL/MariaDB', + ), }; String extractJsonRef(ExpressionJsonRef ref) { diff --git a/typed_sql/lib/src/dialect/postgres_dialect.dart b/typed_sql/lib/src/dialect/postgres_dialect.dart index b9fe7b73..47c93b54 100644 --- a/typed_sql/lib/src/dialect/postgres_dialect.dart +++ b/typed_sql/lib/src/dialect/postgres_dialect.dart @@ -653,6 +653,16 @@ extension on ExpressionResolver { CurrentTimestampExpression _ => '(NOW() AT TIME ZONE \'UTC\')', ExpressionJsonRef e => extractJsonRef(e), ExpressionJsonExtract(:final value) => '(${expr(value)} #>> \'{}\')', + ExpressionJsonContains(:final value, :final other) => + '(${expr(value)} @> ${expr(other)})', + ExpressionJsonContainedBy(:final value, :final other) => + '(${expr(value)} <@ ${expr(other)})', + ExpressionJsonHasKey(:final value, :final key) => + '(${expr(value)} ? ${_escapeStringLiteral(key)})', + ExpressionJsonHasAnyKey(:final value, :final keys) => + '(${expr(value)} ?| ARRAY[${keys.map(_escapeStringLiteral).join(', ')}]::text[])', + ExpressionJsonHasAllKeys(:final value, :final keys) => + '(${expr(value)} ?& ARRAY[${keys.map(_escapeStringLiteral).join(', ')}]::text[])', }; String extractJsonRef(ExpressionJsonRef ref) { diff --git a/typed_sql/lib/src/dialect/sqlite_dialect.dart b/typed_sql/lib/src/dialect/sqlite_dialect.dart index 55e43574..71b2d022 100644 --- a/typed_sql/lib/src/dialect/sqlite_dialect.dart +++ b/typed_sql/lib/src/dialect/sqlite_dialect.dart @@ -635,6 +635,13 @@ extension on ExpressionResolver { extractJsonRefAsText(value), ExpressionJsonExtract(:final value) => 'json_extract(${expr(value)}, \'\$\')', + ExpressionJsonContains() || + ExpressionJsonContainedBy() || + ExpressionJsonHasKey() || + ExpressionJsonHasAnyKey() || + ExpressionJsonHasAllKeys() => throw UnsupportedError( + 'JSONB containment/existence operators are not supported by SQLite', + ), }; String extractJsonRef(ExpressionJsonRef ref) { diff --git a/typed_sql/lib/src/typed_sql.expr.dart b/typed_sql/lib/src/typed_sql.expr.dart index 5067a30b..fd4a6b94 100644 --- a/typed_sql/lib/src/typed_sql.expr.dart +++ b/typed_sql/lib/src/typed_sql.expr.dart @@ -934,3 +934,58 @@ final class ExpressionJsonExtract extends SingleValueExpr { @override _ExprType get _type => ColumnType.text; } + +/// PostgreSQL `@>` JSON containment operator. +final class ExpressionJsonContains extends SingleValueExpr { + final Expr value; + final Expr other; + + ExpressionJsonContains._(this.value, this.other) : super._(); + + @override + final _type = ColumnType.boolean; +} + +/// PostgreSQL `<@` JSON containment operator. +final class ExpressionJsonContainedBy extends SingleValueExpr { + final Expr value; + final Expr other; + + ExpressionJsonContainedBy._(this.value, this.other) : super._(); + + @override + final _type = ColumnType.boolean; +} + +/// PostgreSQL `?` JSON key-existence operator. +final class ExpressionJsonHasKey extends SingleValueExpr { + final Expr value; + final String key; + + ExpressionJsonHasKey._(this.value, this.key) : super._(); + + @override + final _type = ColumnType.boolean; +} + +/// PostgreSQL `?|` JSON any-key-existence operator. +final class ExpressionJsonHasAnyKey extends SingleValueExpr { + final Expr value; + final List keys; + + ExpressionJsonHasAnyKey._(this.value, this.keys) : super._(); + + @override + final _type = ColumnType.boolean; +} + +/// PostgreSQL `?&` JSON all-keys-existence operator. +final class ExpressionJsonHasAllKeys extends SingleValueExpr { + final Expr value; + final List keys; + + ExpressionJsonHasAllKeys._(this.value, this.keys) : super._(); + + @override + final _type = ColumnType.boolean; +} diff --git a/typed_sql/lib/src/typed_sql.expr_ext.dart b/typed_sql/lib/src/typed_sql.expr_ext.dart index 2ec12bba..ab3484ea 100644 --- a/typed_sql/lib/src/typed_sql.expr_ext.dart +++ b/typed_sql/lib/src/typed_sql.expr_ext.dart @@ -660,6 +660,46 @@ extension ExpressionNullableJsonValue on Expr { CastExpression._(ExpressionJsonExtract._(this), ColumnType.boolean); } +/// PostgreSQL-only JSONB condition operators for [JsonValue] expressions. +/// +/// SQLite and MySQL/MariaDB throw `UnsupportedError` if asked to render +/// one of these, since neither has an equivalent operator. +extension PostgresJsonConditions on Expr { + /// {@template jsonContains} + /// Check if this JSON value contains [other], using the PostgreSQL `@>` + /// containment operator. + /// {@endtemplate} + Expr contains(Expr other) => + ExpressionJsonContains._(this, other); + + /// {@macro jsonContains} + Expr containsValue(JsonValue other) => contains(toExpr(other)); + + /// {@template jsonContainedBy} + /// Check if this JSON value is contained by [other], using the PostgreSQL + /// `<@` containment operator. + /// {@endtemplate} + Expr containedBy(Expr other) => + ExpressionJsonContainedBy._(this, other); + + /// {@macro jsonContainedBy} + Expr containedByValue(JsonValue other) => containedBy(toExpr(other)); + + /// Check if this JSON value is an object with the top-level key [key], + /// using the PostgreSQL `?` operator. + Expr hasKey(String key) => ExpressionJsonHasKey._(this, key); + + /// Check if this JSON value is an object with any of the top-level [keys], + /// using the PostgreSQL `?|` operator. + Expr hasAnyKey(List keys) => + ExpressionJsonHasAnyKey._(this, keys); + + /// Check if this JSON value is an object with all of the top-level [keys], + /// using the PostgreSQL `?&` operator. + Expr hasAllKeys(List keys) => + ExpressionJsonHasAllKeys._(this, keys); +} + /// Extension methods for [bool] expressions. extension ExpressionBool on Expr { /// {@template equals} diff --git a/typed_sql/lib/typed_sql.dart b/typed_sql/lib/typed_sql.dart index e6a06843..3378aa72 100644 --- a/typed_sql/lib/typed_sql.dart +++ b/typed_sql/lib/typed_sql.dart @@ -50,6 +50,11 @@ export 'src/typed_sql.dart' ExpressionEquals, ExpressionGreaterThan, ExpressionGreaterThanOrEqual, + ExpressionJsonContainedBy, + ExpressionJsonContains, + ExpressionJsonHasAllKeys, + ExpressionJsonHasAnyKey, + ExpressionJsonHasKey, ExpressionLessThan, ExpressionLessThanOrEqual, ExpressionNumAdd, @@ -83,6 +88,7 @@ export 'src/typed_sql.dart' OffsetClause, OrElseExpression, OrderByClause, + PostgresJsonConditions, QueryClause, ReturningClause, RowExpression, diff --git a/typed_sql/test/typed_sql/postgres_helpers/postgres_json_conditions_test.dart b/typed_sql/test/typed_sql/postgres_helpers/postgres_json_conditions_test.dart new file mode 100644 index 00000000..f7765886 --- /dev/null +++ b/typed_sql/test/typed_sql/postgres_helpers/postgres_json_conditions_test.dart @@ -0,0 +1,152 @@ +// 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:test/test.dart'; +import 'package:typed_sql/postgres_helpers.dart'; +import 'package:typed_sql/typed_sql.dart'; + +import '../testrunner.dart'; + +part 'postgres_json_conditions_test.g.dart'; + +const _notPostgres = + 'JSONB containment/existence operators are PostgreSQL-only, see ' + 'package:typed_sql/postgres_helpers.dart'; + +abstract final class ProductCatalog extends Schema { + Table get products; +} + +@PrimaryKey(['id']) +abstract final class Product extends Row { + @AutoIncrement() + int get id; + + String get name; + + JsonValue get metadata; +} + +void main() { + final r = TestRunner( + setup: (db) async { + await db.createTables(); + await db.products + .insertValue( + name: 'Gadget', + metadata: const JsonValue({'color': 'black', 'weight': 10}), + ) + .execute(); + await db.products + .insertValue( + name: 'Widget', + metadata: const JsonValue({'color': 'red'}), + ) + .execute(); + }, + ); + + r.addTest( + 'contains() filters using the `@>` containment operator', + (db) async { + final names = await db.products + .where( + (p) => + p.metadata.containsValue(const JsonValue({'color': 'black'})), + ) + .select((p) => (p.name,)) + .fetch(); + + check(names).single.equals('Gadget'); + }, + skipSqlite: _notPostgres, + skipMysql: _notPostgres, + ); + + r.addTest( + 'containedBy() filters using the `<@` containment operator', + (db) async { + final names = await db.products + .where( + (p) => p.metadata.containedByValue( + const JsonValue({'color': 'black', 'weight': 10, 'extra': true}), + ), + ) + .select((p) => (p.name,)) + .fetch(); + + check(names).single.equals('Gadget'); + }, + skipSqlite: _notPostgres, + skipMysql: _notPostgres, + ); + + r.addTest( + 'hasKey() filters using the `?` operator', + (db) async { + final names = await db.products + .where((p) => p.metadata.hasKey('weight')) + .select((p) => (p.name,)) + .fetch(); + + check(names).single.equals('Gadget'); + }, + skipSqlite: _notPostgres, + skipMysql: _notPostgres, + ); + + r.addTest( + 'hasAnyKey() filters using the `?|` operator', + (db) async { + final names = await db.products + .where((p) => p.metadata.hasAnyKey(['weight', 'nonsense'])) + .select((p) => (p.name,)) + .fetch(); + + check(names).single.equals('Gadget'); + }, + skipSqlite: _notPostgres, + skipMysql: _notPostgres, + ); + + r.addTest( + 'hasAllKeys() filters using the `?&` operator', + (db) async { + final names = await db.products + .where((p) => p.metadata.hasAllKeys(['color', 'weight'])) + .select((p) => (p.name,)) + .fetch(); + + check(names).single.equals('Gadget'); + }, + skipSqlite: _notPostgres, + skipMysql: _notPostgres, + ); + + r.run(); + + test('hasKey() throws UnsupportedError when compiled for SQLite', () async { + final adapter = DatabaseAdapter.sqlite3TestDatabase(); + final db = Database(adapter, SqlDialect.sqlite()); + try { + await db.createTables(); + + await check( + db.products.where((p) => p.metadata.hasKey('color')).fetch(), + ).throws(); + } finally { + await adapter.close(); + } + }); +} diff --git a/typed_sql/test/typed_sql/postgres_helpers/postgres_json_conditions_test.g.dart b/typed_sql/test/typed_sql/postgres_helpers/postgres_json_conditions_test.g.dart new file mode 100644 index 00000000..cb9d454d --- /dev/null +++ b/typed_sql/test/typed_sql/postgres_helpers/postgres_json_conditions_test.g.dart @@ -0,0 +1,534 @@ +// GENERATED CODE - DO NOT MODIFY BY HAND + +part of 'postgres_json_conditions_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); + + @override + final int id; + + @override + final String name; + + @override + final JsonValue metadata; + + static final _$table = $ForGeneratedCode.tableDefinition( + tableName: 'products', + columns: ['id', 'name', 'metadata'], + 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: [], + ), + ], + primaryKey: ['id'], + unique: >[], + foreignKeys: [], + indexes: [], + readRow: _$Product._$fromDatabase, + ); + + static Product? _$fromDatabase(RowReader row) { + final id = row.readInt(); + final name = row.readString(); + final metadata = row.readJsonValue(); + if (id == null && name == null && metadata == null) { + return null; + } + return _$Product._(id!, name!, metadata!); + } + + @override + String toString() => + 'Product(id: "$id", name: "$name", metadata: "$metadata")'; +} + +/// 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, + }) => $ForGeneratedCode.insertInto(table: this, values: [id, name, metadata]); + + /// 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, + }) => $ForGeneratedCode.insertInto( + table: this, + values: [id?.asExpr, name.asExpr, metadata.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, + }) => $ForGeneratedCode.insertValuesMapped( + table: this, + rows: rows, + mappings: [id, name, metadata], + ); + + /// 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, + }) + set, + ) + updateBuilder, + ) => $ForGeneratedCode.update( + this, + _$Product._$table, + (product) => updateBuilder( + product, + ({Expr? id, Expr? name, Expr? metadata}) => + $ForGeneratedCode.buildUpdate([id, name, metadata]), + ), + ); + + /// 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, + }) + set, + ) + updateBuilder, + ) => $ForGeneratedCode.updateSingle( + this, + _$Product._$table, + (product) => updateBuilder( + product, + ({Expr? id, Expr? name, Expr? metadata}) => + $ForGeneratedCode.buildUpdate([id, name, metadata]), + ), + ); + + /// 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); +} + +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); + + /// 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, + }) + set, + ) + updateBuilder, + ) => $ForGeneratedCode.updateOnConflict( + this, + (product, excluded) => updateBuilder( + product, + excluded, + ({Expr? id, Expr? name, Expr? metadata}) => + $ForGeneratedCode.buildUpdate([id, name, metadata]), + ), + ); +} + +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, + }) + set, + ) + updateBuilder, + ) => $ForGeneratedCode.updateOnConflictSingle( + this, + (product, excluded) => updateBuilder( + product, + excluded, + ({Expr? id, Expr? name, Expr? metadata}) => + $ForGeneratedCode.buildUpdate([id, name, metadata]), + ), + ); +} + +/// 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'); +}