Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions typed_sql/CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -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.

Expand Down
27 changes: 27 additions & 0 deletions typed_sql/lib/postgres_helpers.dart
Original file line number Diff line number Diff line change
@@ -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;
5 changes: 5 additions & 0 deletions typed_sql/lib/src/dialect/dialect.dart
Original file line number Diff line number Diff line change
Expand Up @@ -53,7 +53,12 @@ export '../typed_sql.dart'
ExpressionIsFalse,
ExpressionIsNotDistinctFrom,
ExpressionIsTrue,
ExpressionJsonContainedBy,
ExpressionJsonContains,
ExpressionJsonExtract,
ExpressionJsonHasAllKeys,
ExpressionJsonHasAnyKey,
ExpressionJsonHasKey,
ExpressionJsonRef,
ExpressionJsonRefIndex,
ExpressionJsonRefKey,
Expand Down
8 changes: 8 additions & 0 deletions typed_sql/lib/src/dialect/mysql_dialect.dart
Original file line number Diff line number Diff line change
Expand Up @@ -691,6 +691,14 @@ extension on ExpressionResolver<SqlContext> {
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) {
Expand Down
10 changes: 10 additions & 0 deletions typed_sql/lib/src/dialect/postgres_dialect.dart
Original file line number Diff line number Diff line change
Expand Up @@ -653,6 +653,16 @@ extension on ExpressionResolver<SqlContext> {
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) {
Expand Down
7 changes: 7 additions & 0 deletions typed_sql/lib/src/dialect/sqlite_dialect.dart
Original file line number Diff line number Diff line change
Expand Up @@ -635,6 +635,13 @@ extension on ExpressionResolver<SqlContext> {
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) {
Expand Down
55 changes: 55 additions & 0 deletions typed_sql/lib/src/typed_sql.expr.dart
Original file line number Diff line number Diff line change
Expand Up @@ -934,3 +934,58 @@ final class ExpressionJsonExtract extends SingleValueExpr<String?> {
@override
_ExprType<String?> get _type => ColumnType.text;
}

/// PostgreSQL `@>` JSON containment operator.
final class ExpressionJsonContains extends SingleValueExpr<bool?> {
final Expr<JsonValue?> value;
final Expr<JsonValue?> other;

ExpressionJsonContains._(this.value, this.other) : super._();

@override
final _type = ColumnType.boolean;
}

/// PostgreSQL `<@` JSON containment operator.
final class ExpressionJsonContainedBy extends SingleValueExpr<bool?> {
final Expr<JsonValue?> value;
final Expr<JsonValue?> other;

ExpressionJsonContainedBy._(this.value, this.other) : super._();

@override
final _type = ColumnType.boolean;
}

/// PostgreSQL `?` JSON key-existence operator.
final class ExpressionJsonHasKey extends SingleValueExpr<bool?> {
final Expr<JsonValue?> 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<bool?> {
final Expr<JsonValue?> value;
final List<String> keys;

ExpressionJsonHasAnyKey._(this.value, this.keys) : super._();

@override
final _type = ColumnType.boolean;
}

/// PostgreSQL `?&` JSON all-keys-existence operator.
final class ExpressionJsonHasAllKeys extends SingleValueExpr<bool?> {
final Expr<JsonValue?> value;
final List<String> keys;

ExpressionJsonHasAllKeys._(this.value, this.keys) : super._();

@override
final _type = ColumnType.boolean;
}
40 changes: 40 additions & 0 deletions typed_sql/lib/src/typed_sql.expr_ext.dart
Original file line number Diff line number Diff line change
Expand Up @@ -660,6 +660,46 @@ extension ExpressionNullableJsonValue on Expr<JsonValue?> {
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<JsonValue?> {
/// {@template jsonContains}
/// Check if this JSON value contains [other], using the PostgreSQL `@>`
/// containment operator.
/// {@endtemplate}
Expr<bool?> contains(Expr<JsonValue?> other) =>
ExpressionJsonContains._(this, other);

/// {@macro jsonContains}
Expr<bool?> containsValue(JsonValue other) => contains(toExpr(other));

/// {@template jsonContainedBy}
/// Check if this JSON value is contained by [other], using the PostgreSQL
/// `<@` containment operator.
/// {@endtemplate}
Expr<bool?> containedBy(Expr<JsonValue?> other) =>
ExpressionJsonContainedBy._(this, other);

/// {@macro jsonContainedBy}
Expr<bool?> 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<bool?> 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<bool?> hasAnyKey(List<String> keys) =>
ExpressionJsonHasAnyKey._(this, keys);

/// Check if this JSON value is an object with all of the top-level [keys],
/// using the PostgreSQL `?&` operator.
Expr<bool?> hasAllKeys(List<String> keys) =>
ExpressionJsonHasAllKeys._(this, keys);
}

/// Extension methods for [bool] expressions.
extension ExpressionBool on Expr<bool> {
/// {@template equals}
Expand Down
6 changes: 6 additions & 0 deletions typed_sql/lib/typed_sql.dart
Original file line number Diff line number Diff line change
Expand Up @@ -50,6 +50,11 @@ export 'src/typed_sql.dart'
ExpressionEquals,
ExpressionGreaterThan,
ExpressionGreaterThanOrEqual,
ExpressionJsonContainedBy,
ExpressionJsonContains,
ExpressionJsonHasAllKeys,
ExpressionJsonHasAnyKey,
ExpressionJsonHasKey,
ExpressionLessThan,
ExpressionLessThanOrEqual,
ExpressionNumAdd,
Expand Down Expand Up @@ -83,6 +88,7 @@ export 'src/typed_sql.dart'
OffsetClause,
OrElseExpression,
OrderByClause,
PostgresJsonConditions,
QueryClause,
ReturningClause,
RowExpression,
Expand Down
Original file line number Diff line number Diff line change
@@ -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<Product> 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<ProductCatalog>(
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<ProductCatalog>(adapter, SqlDialect.sqlite());
try {
await db.createTables();

await check(
db.products.where((p) => p.metadata.hasKey('color')).fetch(),
).throws<UnsupportedError>();
} finally {
await adapter.close();
}
});
}
Loading
Loading