ferrum-orm 0.1.13__tar.gz → 0.1.15__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (82) hide show
  1. {ferrum_orm-0.1.13 → ferrum_orm-0.1.15}/Cargo.lock +4 -4
  2. {ferrum_orm-0.1.13 → ferrum_orm-0.1.15}/Cargo.toml +1 -1
  3. {ferrum_orm-0.1.13 → ferrum_orm-0.1.15}/PKG-INFO +1 -1
  4. {ferrum_orm-0.1.13 → ferrum_orm-0.1.15}/crates/ferrum-core/src/ir/mod.rs +3 -0
  5. {ferrum_orm-0.1.13 → ferrum_orm-0.1.15}/crates/ferrum-sql/src/emit.rs +54 -17
  6. {ferrum_orm-0.1.13 → ferrum_orm-0.1.15}/pyproject.toml +1 -1
  7. {ferrum_orm-0.1.13 → ferrum_orm-0.1.15}/python/ferrum/__init__.py +1 -1
  8. {ferrum_orm-0.1.13 → ferrum_orm-0.1.15}/python/ferrum/drivers/postgres.py +43 -5
  9. {ferrum_orm-0.1.13 → ferrum_orm-0.1.15}/python/ferrum/ext/pgvector.py +13 -1
  10. {ferrum_orm-0.1.13 → ferrum_orm-0.1.15}/python/ferrum/models.py +29 -19
  11. {ferrum_orm-0.1.13 → ferrum_orm-0.1.15}/python/ferrum/queryset.py +45 -5
  12. {ferrum_orm-0.1.13 → ferrum_orm-0.1.15}/python/ferrum/relations.py +1 -0
  13. {ferrum_orm-0.1.13 → ferrum_orm-0.1.15}/LICENSE +0 -0
  14. {ferrum_orm-0.1.13 → ferrum_orm-0.1.15}/README.md +0 -0
  15. {ferrum_orm-0.1.13 → ferrum_orm-0.1.15}/crates/ferrum-core/Cargo.toml +0 -0
  16. {ferrum_orm-0.1.13 → ferrum_orm-0.1.15}/crates/ferrum-core/benches/hydrate.rs +0 -0
  17. {ferrum_orm-0.1.13 → ferrum_orm-0.1.15}/crates/ferrum-core/src/compile/mod.rs +0 -0
  18. {ferrum_orm-0.1.13 → ferrum_orm-0.1.15}/crates/ferrum-core/src/error.rs +0 -0
  19. {ferrum_orm-0.1.13 → ferrum_orm-0.1.15}/crates/ferrum-core/src/hydrate/mod.rs +0 -0
  20. {ferrum_orm-0.1.13 → ferrum_orm-0.1.15}/crates/ferrum-core/src/ir/metadata.rs +0 -0
  21. {ferrum_orm-0.1.13 → ferrum_orm-0.1.15}/crates/ferrum-core/src/lib.rs +0 -0
  22. {ferrum_orm-0.1.13 → ferrum_orm-0.1.15}/crates/ferrum-core/src/migrate/mod.rs +0 -0
  23. {ferrum_orm-0.1.13 → ferrum_orm-0.1.15}/crates/ferrum-pyo3/Cargo.toml +0 -0
  24. {ferrum_orm-0.1.13 → ferrum_orm-0.1.15}/crates/ferrum-pyo3/src/lib.rs +0 -0
  25. {ferrum_orm-0.1.13 → ferrum_orm-0.1.15}/crates/ferrum-sql/Cargo.toml +0 -0
  26. {ferrum_orm-0.1.13 → ferrum_orm-0.1.15}/crates/ferrum-sql/benches/compile.rs +0 -0
  27. {ferrum_orm-0.1.13 → ferrum_orm-0.1.15}/crates/ferrum-sql/src/dialect.rs +0 -0
  28. {ferrum_orm-0.1.13 → ferrum_orm-0.1.15}/crates/ferrum-sql/src/fts/mod.rs +0 -0
  29. {ferrum_orm-0.1.13 → ferrum_orm-0.1.15}/crates/ferrum-sql/src/fts/mssql.rs +0 -0
  30. {ferrum_orm-0.1.13 → ferrum_orm-0.1.15}/crates/ferrum-sql/src/fts/mysql.rs +0 -0
  31. {ferrum_orm-0.1.13 → ferrum_orm-0.1.15}/crates/ferrum-sql/src/fts/postgres.rs +0 -0
  32. {ferrum_orm-0.1.13 → ferrum_orm-0.1.15}/crates/ferrum-sql/src/fts/sqlite.rs +0 -0
  33. {ferrum_orm-0.1.13 → ferrum_orm-0.1.15}/crates/ferrum-sql/src/lib.rs +0 -0
  34. {ferrum_orm-0.1.13 → ferrum_orm-0.1.15}/python/ferrum/_native.pyi +0 -0
  35. {ferrum_orm-0.1.13 → ferrum_orm-0.1.15}/python/ferrum/cli/__init__.py +0 -0
  36. {ferrum_orm-0.1.13 → ferrum_orm-0.1.15}/python/ferrum/cli/app.py +0 -0
  37. {ferrum_orm-0.1.13 → ferrum_orm-0.1.15}/python/ferrum/cli/bootstrap.py +0 -0
  38. {ferrum_orm-0.1.13 → ferrum_orm-0.1.15}/python/ferrum/cli/init.py +0 -0
  39. {ferrum_orm-0.1.13 → ferrum_orm-0.1.15}/python/ferrum/cli/inspectdb_cmd.py +0 -0
  40. {ferrum_orm-0.1.13 → ferrum_orm-0.1.15}/python/ferrum/cli/makemigrations_cmd.py +0 -0
  41. {ferrum_orm-0.1.13 → ferrum_orm-0.1.15}/python/ferrum/cli/migrate_cmd.py +0 -0
  42. {ferrum_orm-0.1.13 → ferrum_orm-0.1.15}/python/ferrum/cli/migrations_cmd.py +0 -0
  43. {ferrum_orm-0.1.13 → ferrum_orm-0.1.15}/python/ferrum/cli/resetdb_cmd.py +0 -0
  44. {ferrum_orm-0.1.13 → ferrum_orm-0.1.15}/python/ferrum/cli/revert_cmd.py +0 -0
  45. {ferrum_orm-0.1.13 → ferrum_orm-0.1.15}/python/ferrum/cli/showmigrations_cmd.py +0 -0
  46. {ferrum_orm-0.1.13 → ferrum_orm-0.1.15}/python/ferrum/cli/sqlmigrate_cmd.py +0 -0
  47. {ferrum_orm-0.1.13 → ferrum_orm-0.1.15}/python/ferrum/config.py +0 -0
  48. {ferrum_orm-0.1.13 → ferrum_orm-0.1.15}/python/ferrum/connection.py +0 -0
  49. {ferrum_orm-0.1.13 → ferrum_orm-0.1.15}/python/ferrum/contrib/__init__.py +0 -0
  50. {ferrum_orm-0.1.13 → ferrum_orm-0.1.15}/python/ferrum/contrib/fastapi.py +0 -0
  51. {ferrum_orm-0.1.13 → ferrum_orm-0.1.15}/python/ferrum/drivers/__init__.py +0 -0
  52. {ferrum_orm-0.1.13 → ferrum_orm-0.1.15}/python/ferrum/drivers/mssql.py +0 -0
  53. {ferrum_orm-0.1.13 → ferrum_orm-0.1.15}/python/ferrum/drivers/mysql.py +0 -0
  54. {ferrum_orm-0.1.13 → ferrum_orm-0.1.15}/python/ferrum/drivers/protocol.py +0 -0
  55. {ferrum_orm-0.1.13 → ferrum_orm-0.1.15}/python/ferrum/drivers/sqlite.py +0 -0
  56. {ferrum_orm-0.1.13 → ferrum_orm-0.1.15}/python/ferrum/drivers/streaming.py +0 -0
  57. {ferrum_orm-0.1.13 → ferrum_orm-0.1.15}/python/ferrum/echo.py +0 -0
  58. {ferrum_orm-0.1.13 → ferrum_orm-0.1.15}/python/ferrum/errors.py +0 -0
  59. {ferrum_orm-0.1.13 → ferrum_orm-0.1.15}/python/ferrum/expressions.py +0 -0
  60. {ferrum_orm-0.1.13 → ferrum_orm-0.1.15}/python/ferrum/ext/__init__.py +0 -0
  61. {ferrum_orm-0.1.13 → ferrum_orm-0.1.15}/python/ferrum/ext/fts.py +0 -0
  62. {ferrum_orm-0.1.13 → ferrum_orm-0.1.15}/python/ferrum/hooks.py +0 -0
  63. {ferrum_orm-0.1.13 → ferrum_orm-0.1.15}/python/ferrum/migrations/__init__.py +0 -0
  64. {ferrum_orm-0.1.13 → ferrum_orm-0.1.15}/python/ferrum/migrations/base.py +0 -0
  65. {ferrum_orm-0.1.13 → ferrum_orm-0.1.15}/python/ferrum/migrations/drift.py +0 -0
  66. {ferrum_orm-0.1.13 → ferrum_orm-0.1.15}/python/ferrum/migrations/fts/__init__.py +0 -0
  67. {ferrum_orm-0.1.13 → ferrum_orm-0.1.15}/python/ferrum/migrations/fts/mssql.py +0 -0
  68. {ferrum_orm-0.1.13 → ferrum_orm-0.1.15}/python/ferrum/migrations/fts/mysql.py +0 -0
  69. {ferrum_orm-0.1.13 → ferrum_orm-0.1.15}/python/ferrum/migrations/fts/postgres.py +0 -0
  70. {ferrum_orm-0.1.13 → ferrum_orm-0.1.15}/python/ferrum/migrations/fts/sqlite.py +0 -0
  71. {ferrum_orm-0.1.13 → ferrum_orm-0.1.15}/python/ferrum/migrations/gates.py +0 -0
  72. {ferrum_orm-0.1.13 → ferrum_orm-0.1.15}/python/ferrum/migrations/introspect.py +0 -0
  73. {ferrum_orm-0.1.13 → ferrum_orm-0.1.15}/python/ferrum/migrations/ledger.py +0 -0
  74. {ferrum_orm-0.1.13 → ferrum_orm-0.1.15}/python/ferrum/migrations/loader.py +0 -0
  75. {ferrum_orm-0.1.13 → ferrum_orm-0.1.15}/python/ferrum/migrations/operations.py +0 -0
  76. {ferrum_orm-0.1.13 → ferrum_orm-0.1.15}/python/ferrum/migrations/orchestrator.py +0 -0
  77. {ferrum_orm-0.1.13 → ferrum_orm-0.1.15}/python/ferrum/migrations/tokens.py +0 -0
  78. {ferrum_orm-0.1.13 → ferrum_orm-0.1.15}/python/ferrum/observability.py +0 -0
  79. {ferrum_orm-0.1.13 → ferrum_orm-0.1.15}/python/ferrum/py.typed +0 -0
  80. {ferrum_orm-0.1.13 → ferrum_orm-0.1.15}/python/ferrum/registry.py +0 -0
  81. {ferrum_orm-0.1.13 → ferrum_orm-0.1.15}/python/ferrum/runtime.py +0 -0
  82. {ferrum_orm-0.1.13 → ferrum_orm-0.1.15}/python/ferrum/session.py +0 -0
@@ -223,7 +223,7 @@ checksum = "9f1f227452a390804cdb637b74a86990f2a7d7ba4b7d5693aac9b4dd6defd8d6"
223
223
 
224
224
  [[package]]
225
225
  name = "ferrum-core"
226
- version = "0.1.13"
226
+ version = "0.1.15"
227
227
  dependencies = [
228
228
  "criterion",
229
229
  "proptest",
@@ -234,7 +234,7 @@ dependencies = [
234
234
 
235
235
  [[package]]
236
236
  name = "ferrum-migrate"
237
- version = "0.1.13"
237
+ version = "0.1.15"
238
238
  dependencies = [
239
239
  "ferrum-core",
240
240
  "serde",
@@ -243,7 +243,7 @@ dependencies = [
243
243
 
244
244
  [[package]]
245
245
  name = "ferrum-pyo3"
246
- version = "0.1.13"
246
+ version = "0.1.15"
247
247
  dependencies = [
248
248
  "ferrum-core",
249
249
  "ferrum-sql",
@@ -255,7 +255,7 @@ dependencies = [
255
255
 
256
256
  [[package]]
257
257
  name = "ferrum-sql"
258
- version = "0.1.13"
258
+ version = "0.1.15"
259
259
  dependencies = [
260
260
  "criterion",
261
261
  "ferrum-core",
@@ -3,7 +3,7 @@ members = ["crates/ferrum-core", "crates/ferrum-sql", "crates/ferrum-pyo3"]
3
3
  resolver = "2"
4
4
 
5
5
  [workspace.package]
6
- version = "0.1.13"
6
+ version = "0.1.15"
7
7
  edition = "2021"
8
8
  authors = ["Ferrum Contributors"]
9
9
  license = "Apache-2.0"
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: ferrum-orm
3
- Version: 0.1.13
3
+ Version: 0.1.15
4
4
  Classifier: Development Status :: 3 - Alpha
5
5
  Classifier: Framework :: AsyncIO
6
6
  Classifier: Intended Audience :: Developers
@@ -208,6 +208,9 @@ pub struct JoinFieldRef {
208
208
  /// Operator allowlist copied from the remote field's ``FieldMeta`` (SQL-2).
209
209
  #[serde(default)]
210
210
  pub allowed_operators: Vec<String>,
211
+ /// Remote field type used for type-specific SQL operator emission.
212
+ #[serde(default)]
213
+ pub field_type: Option<metadata::FieldType>,
211
214
  }
212
215
 
213
216
  /// The SQL operation this IR node represents.
@@ -976,7 +976,7 @@ fn resolve_filter_column(
976
976
  joins: &[JoinSpec],
977
977
  table: &str,
978
978
  qualify_columns: bool,
979
- ) -> Result<String, CompileError> {
979
+ ) -> Result<(String, ferrum_core::ir::metadata::FieldType), CompileError> {
980
980
  if let Some(alias) = &filter.join_alias {
981
981
  let join =
982
982
  joins
@@ -995,13 +995,17 @@ fn resolve_filter_column(
995
995
  })?;
996
996
  let alias_q = dialect.quote_ident(&join.alias);
997
997
  let col_q = dialect.quote_ident(&remote.column);
998
- Ok(format!("{alias_q}.{col_q}"))
998
+ Ok((
999
+ format!("{alias_q}.{col_q}"),
1000
+ remote
1001
+ .field_type
1002
+ .unwrap_or(ferrum_core::ir::metadata::FieldType::Text),
1003
+ ))
999
1004
  } else {
1000
- Ok(qualify_base_column(
1001
- dialect,
1002
- table,
1003
- &metadata.fields[filter.field.index].column_name,
1004
- qualify_columns,
1005
+ let field = &metadata.fields[filter.field.index];
1006
+ Ok((
1007
+ qualify_base_column(dialect, table, &field.column_name, qualify_columns),
1008
+ field.field_type,
1005
1009
  ))
1006
1010
  }
1007
1011
  }
@@ -1018,12 +1022,15 @@ fn build_where_sql(
1018
1022
  let mut parts: Vec<String> = Vec::new();
1019
1023
 
1020
1024
  for filter in &ir.filters {
1021
- let col =
1025
+ let (col, field_type) =
1022
1026
  resolve_filter_column(dialect, metadata, filter, &ir.joins, table, qualify_columns)?;
1023
1027
  let (clause, param) = filter_clause(
1024
1028
  dialect,
1025
- metadata,
1026
- filter.field.index,
1029
+ FilterFieldContext {
1030
+ metadata,
1031
+ index: filter.field.index,
1032
+ field_type,
1033
+ },
1027
1034
  &col,
1028
1035
  &filter.operator,
1029
1036
  bound_params.len() + 1,
@@ -1125,12 +1132,15 @@ fn emit_predicate(
1125
1132
  )?
1126
1133
  )),
1127
1134
  Predicate::Filter { filter } => {
1128
- let col =
1135
+ let (col, field_type) =
1129
1136
  resolve_filter_column(dialect, metadata, filter, joins, table, qualify_columns)?;
1130
1137
  let (clause, param) = filter_clause(
1131
1138
  dialect,
1132
- metadata,
1133
- filter.field.index,
1139
+ FilterFieldContext {
1140
+ metadata,
1141
+ index: filter.field.index,
1142
+ field_type,
1143
+ },
1134
1144
  &col,
1135
1145
  &filter.operator,
1136
1146
  bound_params.len() + 1,
@@ -1154,10 +1164,16 @@ fn emit_predicate(
1154
1164
  /// The `match` operator maps to `@@ plainto_tsquery($N)` on `PostgreSQL` and
1155
1165
  /// falls back to `LIKE $N` on other dialects that lack full-text search.
1156
1166
  /// All other operators are mapped by `operator_to_sql`.
1167
+ #[derive(Clone, Copy)]
1168
+ struct FilterFieldContext<'a> {
1169
+ metadata: &'a ModelMetadata,
1170
+ index: usize,
1171
+ field_type: ferrum_core::ir::metadata::FieldType,
1172
+ }
1173
+
1157
1174
  fn filter_clause(
1158
1175
  dialect: Dialect,
1159
- metadata: &ModelMetadata,
1160
- field_index: usize,
1176
+ field: FilterFieldContext<'_>,
1161
1177
  col: &str,
1162
1178
  operator: &str,
1163
1179
  param_index: usize,
@@ -1166,8 +1182,29 @@ fn filter_clause(
1166
1182
  match operator {
1167
1183
  "is_null" => (format!("{col} IS NULL"), None),
1168
1184
  "is_not_null" => (format!("{col} IS NOT NULL"), None),
1169
- op if fts::is_fts_operator(op) => {
1170
- fts::emit_match(dialect, metadata, field_index, col, op, param_index, value)
1185
+ op if fts::is_fts_operator(op) => fts::emit_match(
1186
+ dialect,
1187
+ field.metadata,
1188
+ field.index,
1189
+ col,
1190
+ op,
1191
+ param_index,
1192
+ value,
1193
+ ),
1194
+ op if dialect == Dialect::Postgres
1195
+ && field.field_type == ferrum_core::ir::metadata::FieldType::Json =>
1196
+ {
1197
+ let placeholder = dialect.placeholder(param_index);
1198
+ let clause = match op {
1199
+ "contains" => format!("{col} @> {placeholder}::jsonb"),
1200
+ "has_key" => format!("{col} ? {placeholder}"),
1201
+ "has_any_keys" => format!("{col} ?| {placeholder}"),
1202
+ _ => {
1203
+ let sql_op = operator_to_sql(op, dialect);
1204
+ format!("{col} {sql_op} {placeholder}::jsonb")
1205
+ }
1206
+ };
1207
+ (clause, Some(value))
1171
1208
  }
1172
1209
  op => {
1173
1210
  let placeholder = dialect.placeholder(param_index);
@@ -4,7 +4,7 @@ build-backend = "maturin"
4
4
 
5
5
  [project]
6
6
  name = "ferrum-orm"
7
- version = "0.1.13"
7
+ version = "0.1.15"
8
8
  description = "Next-generation async ORM for Python with a Rust-powered core"
9
9
  license = { text = "Apache-2.0" }
10
10
  readme = "README.md"
@@ -6,7 +6,7 @@ Import paths are stable API; internal module paths are not.
6
6
 
7
7
  from __future__ import annotations
8
8
 
9
- __version__ = "0.1.13"
9
+ __version__ = "0.1.15"
10
10
  __all__ = [
11
11
  "Aggregate",
12
12
  "CreateExtension",
@@ -3,6 +3,7 @@
3
3
  from __future__ import annotations
4
4
 
5
5
  import contextlib
6
+ import json
6
7
  from collections.abc import AsyncGenerator
7
8
  from typing import Any
8
9
 
@@ -11,6 +12,41 @@ from ferrum.drivers.streaming import AsyncpgChunkStream
11
12
  from ferrum.errors import FerrumConfigError, FerrumConnectionError, map_db_error
12
13
 
13
14
 
15
+ def _encode_json_param(value: Any) -> str:
16
+ """Encode a Python value for asyncpg ``json`` / ``jsonb`` text codecs.
17
+
18
+ Ferrum's bind path already ``json.dumps`` JSON fields to text before the
19
+ driver (with SQL ``::jsonb`` casts). Accept pre-serialized strings so a
20
+ codec encoder never double-encodes those binds if a future path sends the
21
+ parameter as a native json/jsonb OID.
22
+ """
23
+ if isinstance(value, str):
24
+ return value
25
+ return json.dumps(value, default=str, separators=(",", ":"))
26
+
27
+
28
+ def _decode_json_param(value: str) -> Any:
29
+ """Decode asyncpg ``json`` / ``jsonb`` text into native Python objects."""
30
+ return json.loads(value)
31
+
32
+
33
+ async def _register_json_codecs(conn: Any) -> None:
34
+ """Make JSONB/JSON columns hydrate as ``dict`` / ``list``, not ``str``.
35
+
36
+ asyncpg's default is to return ``json``/``jsonb`` as text. Without this,
37
+ ``model_construct`` leaves JSON fields as strings and Pydantic consumers
38
+ that expect ``dict`` fail at the agent/MCP boundary.
39
+ """
40
+ for type_name in ("jsonb", "json"):
41
+ await conn.set_type_codec(
42
+ type_name,
43
+ schema="pg_catalog",
44
+ encoder=_encode_json_param,
45
+ decoder=_decode_json_param,
46
+ format="text",
47
+ )
48
+
49
+
14
50
  class _BoundConnection:
15
51
  """Execution surface pinned to a single raw connection inside a transaction.
16
52
 
@@ -139,13 +175,15 @@ class AsyncpgDriver:
139
175
  }
140
176
  if self._max_lifetime is not None:
141
177
  pool_kwargs["max_inactive_connection_lifetime"] = self._max_lifetime
142
- if self._statement_timeout_ms is not None:
143
- timeout_ms = self._statement_timeout_ms
144
178
 
145
- async def _init_conn(conn: Any) -> None:
146
- await conn.execute(f"SET statement_timeout = {timeout_ms}")
179
+ statement_timeout_ms = self._statement_timeout_ms
180
+
181
+ async def _init_conn(conn: Any) -> None:
182
+ await _register_json_codecs(conn)
183
+ if statement_timeout_ms is not None:
184
+ await conn.execute(f"SET statement_timeout = {statement_timeout_ms}")
147
185
 
148
- pool_kwargs["init"] = _init_conn
186
+ pool_kwargs["init"] = _init_conn
149
187
  try:
150
188
  self._pool = await asyncpg.create_pool(self._dsn, **pool_kwargs)
151
189
  except Exception as exc:
@@ -31,7 +31,19 @@ _METRIC_OPS: dict[str, tuple[str, str]] = {
31
31
  _VALID_METRICS: frozenset[str] = frozenset(_METRIC_OPS)
32
32
 
33
33
 
34
- def _encode_vector(value: list[float]) -> str:
34
+ def _encode_vector(value: list[float] | str) -> str:
35
+ """Encode a float list as a pgvector text literal.
36
+
37
+ Idempotent for an already-encoded ``"[f,f,...]"`` string. Ferrum's
38
+ ``nearest_to()`` / ``vector_search()`` bind a text literal plus
39
+ ``$N::vector``; when this codec is registered, asyncpg still runs the
40
+ encoder on that string — re-iterating characters would produce garbage
41
+ like ``"[[,-,0,.,1,...]]"`` and Postgres raises
42
+ ``InvalidTextRepresentationError``.
43
+ """
44
+ if isinstance(value, str):
45
+ # Already a pgvector text literal (or opaque text the server will cast).
46
+ return value
35
47
  return "[" + ",".join(str(v) for v in value) + "]"
36
48
 
37
49
 
@@ -97,8 +97,8 @@ _SUPPORTED_TYPES[TSVector] = "tsvector"
97
97
  # Operator allowlists per Ferrum field type (QUERY_ENGINE.md §4.2)
98
98
  # ---------------------------------------------------------------------------
99
99
  _ALLOWED_OPERATORS: dict[str, tuple[str, ...]] = {
100
- "int": ("eq", "gt", "gte", "lt", "lte", "in", "is_null", "ne", "range"),
101
- "big_int": ("eq", "gt", "gte", "lt", "lte", "in", "is_null", "ne", "range"),
100
+ "int": ("eq", "gt", "gte", "lt", "lte", "in", "is_null", "is_not_null", "ne", "range"),
101
+ "big_int": ("eq", "gt", "gte", "lt", "lte", "in", "is_null", "is_not_null", "ne", "range"),
102
102
  "text": (
103
103
  "eq",
104
104
  "iexact",
@@ -110,27 +110,35 @@ _ALLOWED_OPERATORS: dict[str, tuple[str, ...]] = {
110
110
  "iendswith",
111
111
  "in",
112
112
  "is_null",
113
+ "is_not_null",
113
114
  "ne",
114
115
  ),
115
- "bool": ("eq", "is_null", "ne"),
116
- "float": ("eq", "gt", "gte", "lt", "lte", "in", "is_null", "ne", "range"),
117
- "decimal": ("eq", "gt", "gte", "lt", "lte", "in", "is_null", "ne", "range"),
118
- "datetime": ("eq", "gt", "gte", "lt", "lte", "is_null", "ne", "range"),
119
- "date": ("eq", "gt", "gte", "lt", "lte", "is_null", "ne", "range"),
120
- "time": ("eq", "gt", "gte", "lt", "lte", "is_null", "ne", "range"),
121
- "uuid": ("eq", "in", "is_null", "ne"),
122
- "bytes": ("eq", "in", "is_null", "ne"),
116
+ "bool": ("eq", "is_null", "is_not_null", "ne"),
117
+ "float": ("eq", "gt", "gte", "lt", "lte", "in", "is_null", "is_not_null", "ne", "range"),
118
+ "decimal": ("eq", "gt", "gte", "lt", "lte", "in", "is_null", "is_not_null", "ne", "range"),
119
+ "datetime": ("eq", "gt", "gte", "lt", "lte", "is_null", "is_not_null", "ne", "range"),
120
+ "date": ("eq", "gt", "gte", "lt", "lte", "is_null", "is_not_null", "ne", "range"),
121
+ "time": ("eq", "gt", "gte", "lt", "lte", "is_null", "is_not_null", "ne", "range"),
122
+ "uuid": ("eq", "in", "is_null", "is_not_null", "ne"),
123
+ "bytes": ("eq", "in", "is_null", "is_not_null", "ne"),
123
124
  # JSONB: containment + key existence operators (PostgreSQL @>, ?, ?|)
124
- "json": ("eq", "is_null", "contains", "has_key", "has_any_keys"),
125
- "vector": ("is_null",),
126
- "tsvector": ("match", "match_phrase", "match_websearch", "match_boolean", "is_null"),
125
+ "json": ("eq", "is_null", "is_not_null", "contains", "has_key", "has_any_keys"),
126
+ "vector": ("is_null", "is_not_null"),
127
+ "tsvector": (
128
+ "match",
129
+ "match_phrase",
130
+ "match_websearch",
131
+ "match_boolean",
132
+ "is_null",
133
+ "is_not_null",
134
+ ),
127
135
  # PostgreSQL array types — array containment and overlap operators
128
- "array_text": ("eq", "is_null", "contains", "contained_by", "overlap"),
129
- "array_int": ("eq", "is_null", "contains", "contained_by", "overlap"),
130
- "array_uuid": ("eq", "is_null", "contains", "contained_by", "overlap"),
131
- "array_float": ("eq", "is_null", "contains", "contained_by", "overlap"),
136
+ "array_text": ("eq", "is_null", "is_not_null", "contains", "contained_by", "overlap"),
137
+ "array_int": ("eq", "is_null", "is_not_null", "contains", "contained_by", "overlap"),
138
+ "array_uuid": ("eq", "is_null", "is_not_null", "contains", "contained_by", "overlap"),
139
+ "array_float": ("eq", "is_null", "is_not_null", "contains", "contained_by", "overlap"),
132
140
  # Enum (TEXT + CHECK constraint) — equality and membership operators
133
- "enum": ("eq", "is_null", "ne", "in"),
141
+ "enum": ("eq", "is_null", "is_not_null", "ne", "in"),
134
142
  }
135
143
 
136
144
  # Allowlist for ON DELETE actions in FK constraints (SQL injection guard).
@@ -907,7 +915,9 @@ def _build_metadata(cls: type[_PydanticBaseModel]) -> ModelMetadata:
907
915
  column_name=column_name,
908
916
  python_type_name=python_type_name,
909
917
  field_type=db_type,
910
- allowed_operators=_ALLOWED_OPERATORS.get(db_type, ("eq", "is_null", "ne")),
918
+ allowed_operators=_ALLOWED_OPERATORS.get(
919
+ db_type, ("eq", "is_null", "is_not_null", "ne")
920
+ ),
911
921
  nullable=nullable,
912
922
  pk=is_pk,
913
923
  max_length=ferrum_extras.get("max_length"),
@@ -43,7 +43,7 @@ from ferrum.expressions import Q, args_to_q
43
43
 
44
44
  if TYPE_CHECKING:
45
45
  from ferrum.connection import Connection, Transaction
46
- from ferrum.models import Model, ModelMetadata
46
+ from ferrum.models import FieldMeta, Model, ModelMetadata
47
47
 
48
48
  # Terminals accept an open Connection or an active Transaction interchangeably:
49
49
  # both expose the ``dialect`` / ``_require_driver()`` surface the terminals use.
@@ -225,6 +225,17 @@ def _encode_field_bind_value(field: Any, value: object) -> dict[str, object]: #
225
225
  return _encode_bind_value(_prepare_field_value(field, value))
226
226
 
227
227
 
228
+ def _encode_filter_bind_value(
229
+ field: FieldMeta,
230
+ operator: str,
231
+ value: object,
232
+ ) -> dict[str, object]:
233
+ """Encode a lookup value according to its field and operator."""
234
+ if field.field_type == "json" and operator not in {"has_key", "has_any_keys"}:
235
+ return _encode_field_bind_value(field, value)
236
+ return _encode_bind_value(value)
237
+
238
+
228
239
  def _decode_bound_param(param: str | dict[str, Any]) -> object:
229
240
  """Decode one compiled BindValue to a Python value for the driver.
230
241
 
@@ -337,6 +348,24 @@ def _parse_lookup(lookup: str) -> tuple[str, str]:
337
348
  return lookup, "eq"
338
349
 
339
350
 
351
+ def _normalize_null_lookup(operator: str, value: object) -> tuple[str, object]:
352
+ """Map Django-style ``__is_null=True/False`` onto ``is_null`` / ``is_not_null``.
353
+
354
+ The SQL emitter treats ``is_null`` / ``is_not_null`` as nullary operators and
355
+ ignores the bound value. Without this rewrite, ``field__is_null=False`` would
356
+ still emit ``IS NULL``.
357
+ """
358
+ if operator == "is_null":
359
+ if value is False:
360
+ return "is_not_null", None
361
+ return "is_null", None
362
+ if operator == "is_not_null":
363
+ if value is False:
364
+ return "is_null", None
365
+ return "is_not_null", None
366
+ return operator, value
367
+
368
+
340
369
  def _fk_oto_relations(metadata: ModelMetadata) -> dict[str, Any]:
341
370
  """Return ``{relation_name: RelationMeta}`` for forward FK / OneToOne only."""
342
371
  return {r.field_name: r for r in metadata.relations if r.kind in ("fk", "one_to_one")}
@@ -444,12 +473,16 @@ def _filter_dict_to_ir(
444
473
  ) -> dict[str, Any]:
445
474
  """Convert one normalized filter dict to a compiler-ready IR leaf."""
446
475
  field_name: str = flt["field"]
447
- operator: str = flt["operator"]
476
+ operator, value = _normalize_null_lookup(flt["operator"], flt["value"])
448
477
  _validate_lookup(field_name, operator, metadata, field_index=field_index)
449
478
  return {
450
479
  "field": {"index": field_index[field_name], "name": field_name},
451
480
  "operator": operator,
452
- "value": _encode_bind_value(flt["value"]),
481
+ "value": _encode_filter_bind_value(
482
+ metadata.fields[field_index[field_name]],
483
+ operator,
484
+ value,
485
+ ),
453
486
  }
454
487
 
455
488
 
@@ -466,13 +499,20 @@ def _kwargs_to_ir_filters(
466
499
  leaves: list[dict[str, Any]] = []
467
500
  filter_joins: dict[str, set[str]] = {}
468
501
  for lookup, value in kwargs.items():
469
- field_name, operator, join_alias, target_index, _target_meta = _resolve_lookup(
502
+ field_name, operator, join_alias, target_index, target_meta = _resolve_lookup(
470
503
  lookup, metadata, field_index
471
504
  )
505
+ # Validate against the caller's operator (``is_null``), then rewrite
506
+ # ``is_null=False`` → ``is_not_null`` for the SQL emitter.
507
+ operator, value = _normalize_null_lookup(operator, value)
472
508
  filt: dict[str, Any] = {
473
509
  "field": {"index": target_index[field_name], "name": field_name},
474
510
  "operator": operator,
475
- "value": _encode_bind_value(value),
511
+ "value": _encode_filter_bind_value(
512
+ target_meta.fields[target_index[field_name]],
513
+ operator,
514
+ value,
515
+ ),
476
516
  }
477
517
  if join_alias is not None:
478
518
  filt["join_alias"] = join_alias
@@ -191,6 +191,7 @@ def build_join_ir(
191
191
  "name": f.name,
192
192
  "column": f.column_name,
193
193
  "allowed_operators": list(f.allowed_operators),
194
+ "field_type": f.field_type,
194
195
  }
195
196
  for i, f in enumerate(remote_meta.fields)
196
197
  if remote_field_names is None or f.name in remote_field_names
File without changes
File without changes