ferrum-orm 0.1.13__tar.gz → 0.1.14__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.
- {ferrum_orm-0.1.13 → ferrum_orm-0.1.14}/Cargo.lock +4 -4
- {ferrum_orm-0.1.13 → ferrum_orm-0.1.14}/Cargo.toml +1 -1
- {ferrum_orm-0.1.13 → ferrum_orm-0.1.14}/PKG-INFO +1 -1
- {ferrum_orm-0.1.13 → ferrum_orm-0.1.14}/crates/ferrum-core/src/ir/mod.rs +3 -0
- {ferrum_orm-0.1.13 → ferrum_orm-0.1.14}/crates/ferrum-sql/src/emit.rs +54 -17
- {ferrum_orm-0.1.13 → ferrum_orm-0.1.14}/pyproject.toml +1 -1
- {ferrum_orm-0.1.13 → ferrum_orm-0.1.14}/python/ferrum/__init__.py +1 -1
- {ferrum_orm-0.1.13 → ferrum_orm-0.1.14}/python/ferrum/ext/pgvector.py +13 -1
- {ferrum_orm-0.1.13 → ferrum_orm-0.1.14}/python/ferrum/models.py +29 -19
- {ferrum_orm-0.1.13 → ferrum_orm-0.1.14}/python/ferrum/queryset.py +45 -5
- {ferrum_orm-0.1.13 → ferrum_orm-0.1.14}/python/ferrum/relations.py +1 -0
- {ferrum_orm-0.1.13 → ferrum_orm-0.1.14}/LICENSE +0 -0
- {ferrum_orm-0.1.13 → ferrum_orm-0.1.14}/README.md +0 -0
- {ferrum_orm-0.1.13 → ferrum_orm-0.1.14}/crates/ferrum-core/Cargo.toml +0 -0
- {ferrum_orm-0.1.13 → ferrum_orm-0.1.14}/crates/ferrum-core/benches/hydrate.rs +0 -0
- {ferrum_orm-0.1.13 → ferrum_orm-0.1.14}/crates/ferrum-core/src/compile/mod.rs +0 -0
- {ferrum_orm-0.1.13 → ferrum_orm-0.1.14}/crates/ferrum-core/src/error.rs +0 -0
- {ferrum_orm-0.1.13 → ferrum_orm-0.1.14}/crates/ferrum-core/src/hydrate/mod.rs +0 -0
- {ferrum_orm-0.1.13 → ferrum_orm-0.1.14}/crates/ferrum-core/src/ir/metadata.rs +0 -0
- {ferrum_orm-0.1.13 → ferrum_orm-0.1.14}/crates/ferrum-core/src/lib.rs +0 -0
- {ferrum_orm-0.1.13 → ferrum_orm-0.1.14}/crates/ferrum-core/src/migrate/mod.rs +0 -0
- {ferrum_orm-0.1.13 → ferrum_orm-0.1.14}/crates/ferrum-pyo3/Cargo.toml +0 -0
- {ferrum_orm-0.1.13 → ferrum_orm-0.1.14}/crates/ferrum-pyo3/src/lib.rs +0 -0
- {ferrum_orm-0.1.13 → ferrum_orm-0.1.14}/crates/ferrum-sql/Cargo.toml +0 -0
- {ferrum_orm-0.1.13 → ferrum_orm-0.1.14}/crates/ferrum-sql/benches/compile.rs +0 -0
- {ferrum_orm-0.1.13 → ferrum_orm-0.1.14}/crates/ferrum-sql/src/dialect.rs +0 -0
- {ferrum_orm-0.1.13 → ferrum_orm-0.1.14}/crates/ferrum-sql/src/fts/mod.rs +0 -0
- {ferrum_orm-0.1.13 → ferrum_orm-0.1.14}/crates/ferrum-sql/src/fts/mssql.rs +0 -0
- {ferrum_orm-0.1.13 → ferrum_orm-0.1.14}/crates/ferrum-sql/src/fts/mysql.rs +0 -0
- {ferrum_orm-0.1.13 → ferrum_orm-0.1.14}/crates/ferrum-sql/src/fts/postgres.rs +0 -0
- {ferrum_orm-0.1.13 → ferrum_orm-0.1.14}/crates/ferrum-sql/src/fts/sqlite.rs +0 -0
- {ferrum_orm-0.1.13 → ferrum_orm-0.1.14}/crates/ferrum-sql/src/lib.rs +0 -0
- {ferrum_orm-0.1.13 → ferrum_orm-0.1.14}/python/ferrum/_native.pyi +0 -0
- {ferrum_orm-0.1.13 → ferrum_orm-0.1.14}/python/ferrum/cli/__init__.py +0 -0
- {ferrum_orm-0.1.13 → ferrum_orm-0.1.14}/python/ferrum/cli/app.py +0 -0
- {ferrum_orm-0.1.13 → ferrum_orm-0.1.14}/python/ferrum/cli/bootstrap.py +0 -0
- {ferrum_orm-0.1.13 → ferrum_orm-0.1.14}/python/ferrum/cli/init.py +0 -0
- {ferrum_orm-0.1.13 → ferrum_orm-0.1.14}/python/ferrum/cli/inspectdb_cmd.py +0 -0
- {ferrum_orm-0.1.13 → ferrum_orm-0.1.14}/python/ferrum/cli/makemigrations_cmd.py +0 -0
- {ferrum_orm-0.1.13 → ferrum_orm-0.1.14}/python/ferrum/cli/migrate_cmd.py +0 -0
- {ferrum_orm-0.1.13 → ferrum_orm-0.1.14}/python/ferrum/cli/migrations_cmd.py +0 -0
- {ferrum_orm-0.1.13 → ferrum_orm-0.1.14}/python/ferrum/cli/resetdb_cmd.py +0 -0
- {ferrum_orm-0.1.13 → ferrum_orm-0.1.14}/python/ferrum/cli/revert_cmd.py +0 -0
- {ferrum_orm-0.1.13 → ferrum_orm-0.1.14}/python/ferrum/cli/showmigrations_cmd.py +0 -0
- {ferrum_orm-0.1.13 → ferrum_orm-0.1.14}/python/ferrum/cli/sqlmigrate_cmd.py +0 -0
- {ferrum_orm-0.1.13 → ferrum_orm-0.1.14}/python/ferrum/config.py +0 -0
- {ferrum_orm-0.1.13 → ferrum_orm-0.1.14}/python/ferrum/connection.py +0 -0
- {ferrum_orm-0.1.13 → ferrum_orm-0.1.14}/python/ferrum/contrib/__init__.py +0 -0
- {ferrum_orm-0.1.13 → ferrum_orm-0.1.14}/python/ferrum/contrib/fastapi.py +0 -0
- {ferrum_orm-0.1.13 → ferrum_orm-0.1.14}/python/ferrum/drivers/__init__.py +0 -0
- {ferrum_orm-0.1.13 → ferrum_orm-0.1.14}/python/ferrum/drivers/mssql.py +0 -0
- {ferrum_orm-0.1.13 → ferrum_orm-0.1.14}/python/ferrum/drivers/mysql.py +0 -0
- {ferrum_orm-0.1.13 → ferrum_orm-0.1.14}/python/ferrum/drivers/postgres.py +0 -0
- {ferrum_orm-0.1.13 → ferrum_orm-0.1.14}/python/ferrum/drivers/protocol.py +0 -0
- {ferrum_orm-0.1.13 → ferrum_orm-0.1.14}/python/ferrum/drivers/sqlite.py +0 -0
- {ferrum_orm-0.1.13 → ferrum_orm-0.1.14}/python/ferrum/drivers/streaming.py +0 -0
- {ferrum_orm-0.1.13 → ferrum_orm-0.1.14}/python/ferrum/echo.py +0 -0
- {ferrum_orm-0.1.13 → ferrum_orm-0.1.14}/python/ferrum/errors.py +0 -0
- {ferrum_orm-0.1.13 → ferrum_orm-0.1.14}/python/ferrum/expressions.py +0 -0
- {ferrum_orm-0.1.13 → ferrum_orm-0.1.14}/python/ferrum/ext/__init__.py +0 -0
- {ferrum_orm-0.1.13 → ferrum_orm-0.1.14}/python/ferrum/ext/fts.py +0 -0
- {ferrum_orm-0.1.13 → ferrum_orm-0.1.14}/python/ferrum/hooks.py +0 -0
- {ferrum_orm-0.1.13 → ferrum_orm-0.1.14}/python/ferrum/migrations/__init__.py +0 -0
- {ferrum_orm-0.1.13 → ferrum_orm-0.1.14}/python/ferrum/migrations/base.py +0 -0
- {ferrum_orm-0.1.13 → ferrum_orm-0.1.14}/python/ferrum/migrations/drift.py +0 -0
- {ferrum_orm-0.1.13 → ferrum_orm-0.1.14}/python/ferrum/migrations/fts/__init__.py +0 -0
- {ferrum_orm-0.1.13 → ferrum_orm-0.1.14}/python/ferrum/migrations/fts/mssql.py +0 -0
- {ferrum_orm-0.1.13 → ferrum_orm-0.1.14}/python/ferrum/migrations/fts/mysql.py +0 -0
- {ferrum_orm-0.1.13 → ferrum_orm-0.1.14}/python/ferrum/migrations/fts/postgres.py +0 -0
- {ferrum_orm-0.1.13 → ferrum_orm-0.1.14}/python/ferrum/migrations/fts/sqlite.py +0 -0
- {ferrum_orm-0.1.13 → ferrum_orm-0.1.14}/python/ferrum/migrations/gates.py +0 -0
- {ferrum_orm-0.1.13 → ferrum_orm-0.1.14}/python/ferrum/migrations/introspect.py +0 -0
- {ferrum_orm-0.1.13 → ferrum_orm-0.1.14}/python/ferrum/migrations/ledger.py +0 -0
- {ferrum_orm-0.1.13 → ferrum_orm-0.1.14}/python/ferrum/migrations/loader.py +0 -0
- {ferrum_orm-0.1.13 → ferrum_orm-0.1.14}/python/ferrum/migrations/operations.py +0 -0
- {ferrum_orm-0.1.13 → ferrum_orm-0.1.14}/python/ferrum/migrations/orchestrator.py +0 -0
- {ferrum_orm-0.1.13 → ferrum_orm-0.1.14}/python/ferrum/migrations/tokens.py +0 -0
- {ferrum_orm-0.1.13 → ferrum_orm-0.1.14}/python/ferrum/observability.py +0 -0
- {ferrum_orm-0.1.13 → ferrum_orm-0.1.14}/python/ferrum/py.typed +0 -0
- {ferrum_orm-0.1.13 → ferrum_orm-0.1.14}/python/ferrum/registry.py +0 -0
- {ferrum_orm-0.1.13 → ferrum_orm-0.1.14}/python/ferrum/runtime.py +0 -0
- {ferrum_orm-0.1.13 → ferrum_orm-0.1.14}/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.
|
|
226
|
+
version = "0.1.14"
|
|
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.
|
|
237
|
+
version = "0.1.14"
|
|
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.
|
|
246
|
+
version = "0.1.14"
|
|
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.
|
|
258
|
+
version = "0.1.14"
|
|
259
259
|
dependencies = [
|
|
260
260
|
"criterion",
|
|
261
261
|
"ferrum-core",
|
|
@@ -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(
|
|
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
|
-
|
|
1001
|
-
|
|
1002
|
-
table,
|
|
1003
|
-
|
|
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
|
-
|
|
1026
|
-
|
|
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
|
-
|
|
1133
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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);
|
|
@@ -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": (
|
|
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(
|
|
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
|
|
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":
|
|
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,
|
|
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":
|
|
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
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|