diff --git a/rust/src/database/postgres/value.rs b/rust/src/database/postgres/value.rs index 95c43ea227..e03f20c319 100644 --- a/rust/src/database/postgres/value.rs +++ b/rust/src/database/postgres/value.rs @@ -271,6 +271,22 @@ impl ToSql for PgValue { tid_to_sql(v, buf)?; Ok(IsNull::No) } + (PgValue::Text(v), &Type::JSON) => { + // A JSON document passed as text and bound to a `json` column / + // `?::json` cast (e.g. a custom profile field value). `json`'s + // wire format is just the raw text. + buf.extend_from_slice(v.as_bytes()); + Ok(IsNull::No) + } + (PgValue::Text(v), &Type::JSONB | &Type::JSONPATH) => { + // `jsonb` and `jsonpath` share a wire format: a one-byte version + // header (1) then the text. A `jsonpath` param is bound as text, + // e.g. `JSONB_PATH_EXISTS(fields, ?)` when reading a custom + // profile field. + buf.extend_from_slice(&[1u8]); + buf.extend_from_slice(v.as_bytes()); + Ok(IsNull::No) + } (PgValue::Bytea(v), &Type::BYTEA) => { bytea_to_sql(v, buf); Ok(IsNull::No) @@ -323,8 +339,10 @@ impl ToSql for PgValue { } fn accepts(ty: &Type) -> bool { - // Scalars, plus arrays of a supported scalar element type. + // Scalars, `json`/`jsonb`/`jsonpath` (a `Text` document binds to these), + // plus arrays of a supported scalar element type. accepts_column_type(ty) + || matches!(*ty, Type::JSON | Type::JSONB | Type::JSONPATH) || matches!(ty.kind(), Kind::Array(element) if accepts_column_type(element)) } @@ -692,6 +710,20 @@ mod tests { }); } + #[test] + fn to_sql_encodes_text_as_json_and_jsonb() { + // A JSON document passed as text binds to a `json` column verbatim, and + // to `jsonb` with the one-byte version header prepended. + assert_eq!( + encode(&PgValue::Text("[1, 2]".into()), &Type::JSON).0, + b"[1, 2]".to_vec() + ); + assert_eq!( + encode(&PgValue::Text("[1, 2]".into()), &Type::JSONB).0, + b"\x01[1, 2]".to_vec() + ); + } + #[test] fn to_sql_encodes_arrays() { // An `INT8[]` array encodes without error and produces a non-empty diff --git a/synapse/storage/databases/main/profile.py b/synapse/storage/databases/main/profile.py index 9b787e19a3..f5479b3888 100644 --- a/synapse/storage/databases/main/profile.py +++ b/synapse/storage/databases/main/profile.py @@ -455,12 +455,10 @@ class ProfileWorkerStore(SQLBaseStore): self._check_profile_size(txn, user_id, field_name, new_value) if isinstance(self.database_engine, PostgresEngine): - from psycopg2.extras import Json - # Note that the || jsonb operator is not recursive, any duplicate # keys will be taken from the second value. sql = """ - INSERT INTO profiles (user_id, full_user_id, fields) VALUES (?, ?, JSON_BUILD_OBJECT(?, ?::jsonb)) + INSERT INTO profiles (user_id, full_user_id, fields) VALUES (?, ?, JSON_BUILD_OBJECT(?::text, ?::jsonb)) ON CONFLICT (user_id) DO UPDATE SET full_user_id = EXCLUDED.full_user_id, fields = COALESCE(profiles.fields, '{}'::jsonb) || EXCLUDED.fields """ @@ -471,9 +469,11 @@ class ProfileWorkerStore(SQLBaseStore): user_id.localpart, user_id.to_string(), field_name, - # Pass as a JSON object since we have passing bytes disabled - # at the database driver. - Json(json.loads(canonical_value)), + # The field value as a JSON document; the `?::jsonb` cast + # in the query turns it into jsonb. Passed as text rather + # than raw bytes, since binding bytes is disabled at the + # database driver. + canonical_value.decode("utf-8"), ), ) else: