Repository navigation
Conversation
The engine already derives order and match terms, and the Go SDK uses them. Python's query always asks for equality. ADR-0003 records the call shape for the other operators: eq, lt, lte, gt, gte, between, and matches, with query removed. Match answers can contain rows that do not match, so matches returns an object with target_limit and verify. ADR-0002 now points to ADR-0003 where its op= plan was replaced. Refs #1341 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The engine already derives order terms, but Python's query always asked for equality. Cipher now has eq, lt, lte, gt and gte, as ADR-0003 decides, and query is removed. Core passes the operator to the engine. Cipher checks a field against the operators the engine lists for it, not against the marker's indexes, so the two cannot disagree. Refs #1341 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The engine refuses a between term for an EQL order type, with the reason operator_not_answered, and the Go SDK has no Between for these types either. Python leaves between out and stays at Go's level. A range search uses gte and lte. Building the range in Python would copy EQL's range format with nothing to keep the copy correct. Refs #1341 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The engine answers a match on a TextMatch field, but the answer can hold rows that do not contain the text. Cipher.matches returns a MatchQuery, as ADR-0003 decides: its term goes in the SQL with @@, verify removes the false positives after decrypt, and target_limit sizes the fetch from the stored filters' histogram, or returns None when no finite fetch can be promised. MatchQuery keeps the search text for verify, and its repr never shows it. Refs #1341 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
verify reads the search text and each decrypted value into Rust before it checks them. Those copies are plaintext. The heap test now calls the verify binding that Cipher.verify uses, and fails if a block freed during the call still holds the text. Refs #1341 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The SQLAlchemy layer will encrypt each statement's parameters in one batch. An UPDATE contains only the changed columns, plus keys such as the primary key for the WHERE. encrypt takes whole models, so it cannot take those rows. encrypt_rows takes dicts and builds a plan for the fields they contain. The engine refuses a row that leaves out a field of the plan, and a value from the smaller plan opens and searches under the full plan. Every row in one call must contain the same declared fields, so that all values are checked before the one batch of key requests. Refs #1341 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The SQLAlchemy layer will decrypt the objects a query loads, in one batch per class, and write the plaintext back to their attributes. It needs the values, not new model instances, and a query can load only some of the columns. decrypt_rows takes dicts of stored values and returns dicts of plaintext, with the same rules as encrypt_rows. It passes dict to the engine in place of the model class, so it needs no change to the extension. The two methods share the row-shape check and the plan for each set of fields. Refs #1341 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
With the SDK alone, an app calls encrypt before each insert and decrypt after each select. configure_encryption(engine, classes) moves both into SQLAlchemy hooks, so the app reads and writes plaintext attributes. A column type would encrypt one value at a time, with one key request each. The hooks work in batches instead. before_execute encrypts the parameters of each INSERT or UPDATE with one encrypt_rows call. do_orm_execute decrypts the objects that the load and refresh events recorded, with one decrypt_rows call for each class, and sets them with set_committed_value so that they are not saved again. A statement with an encrypted value inside its SQL is refused, because the hook can change only the parameters, and the value would be stored as plaintext. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
An app had to build each query term with cipher.eq and write the cast in the SQL itself. eql_type_map gives every encrypted field an EqlType column, read from its marker, so a model names no SQL type. The type's ==, <, <=, >, and >= make the term with the Cipher method of the same meaning and cast it to the marker's query type. Every other operator that would send a value, such as like, in_, and !=, raises QueryError when the query is built. The value would reach the database as plaintext, and could never match an EQL value. configure_encryption now refuses an encrypted column whose type is not its marker's EQL type. Without the type map, SQLAlchemy would make it an ordinary text column, with no error. Refs #1341 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
An app that moves to encrypted columns needs migrations that create the EQL columns and the database indexes a search uses. Alembic's autogenerate could not do either alone. A mapped class now gets a database index for each EQL index of each encrypted field, such as hash (eql_v4.eq_term(email)) for TextEq. It is added when the class is mapped, not when a column joins a table: Alembic's add_column creates the indexes of a temporary table, so an index added there was created twice. Two hooks for env.py. render_item writes EqlType into a migration with its imports. process_revision_directives writes a change of an existing column to an EQL type as an ALTER with USING col::jsonb::<type>, which PostgreSQL needs. The cast changes an empty column and refuses one with plaintext rows, which keeps them. It also drops the type change Alembic finds between an EQL type and itself: it compares eql_v4_text_eq from the database with public.eql_v4_text_eq from the model as text. Refs #1341 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The SQLAlchemy version of the customer directory called encrypt before each insert and decrypt after each select, and built the search term by hand. With configure_encryption, its endpoints use Session as any SQLAlchemy app does. It is the nearest copy of putting the layer into an existing app. The hooks check only the encrypted fields. cipher.encrypt also refused a created_at with no time zone, so the request model now uses Pydantic's AwareDatetime to keep that refusal. Refs #1341 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The write hook sent only encrypted fields to encrypt_rows, so a passthrough value was not checked: a datetime with no time zone reached PostgreSQL, where cipher.encrypt refuses it. Every declared field now goes through encrypt_rows, which checks a passthrough value and returns it unchanged. The example no longer needs Pydantic's AwareDatetime. != becomes NOT (col = term), and in_ one equality term for each value, joined with OR. Neither sends a value as plaintext. in_ with a subquery is still refused. render_item wrote stackencrypt.Json, a name the package does not export. A marker that is not exported is written from stackencrypt._eql. Refs #1341 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…columns The SDK added a hash index on eq_term(email) even when the model, like the example, already had a unique btree index on the same term. Alembic would then create both. Now an index the model declares on the same term of the same column replaces the SDK's index for that term. A unique constraint on an encrypted column compares the stored EQL values. Each one has new random ciphertext, so the constraint never finds a duplicate. configure_encryption refuses it, and points to a unique index on eq_term instead. The example declares the unique index of schema.sql, and gives plan the text type, so Alembic finds no index or plan difference with the table. Refs #1341 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The layer made several choices that apps will depend on: batch hooks instead of a column type, an explicit list of classes, refusing anything that would store or send plaintext, column types and indexes read from the markers, and two Alembic hooks. ADR-0004 records each one with the option it was chosen over, and what was run to check it. ADR-0002 left open whether the SQLAlchemy layer names a field by the class attribute. It does, and ADR-0002 now points to ADR-0004. Refs #1341 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The read hook decrypted only the objects a query loads. A session query of columns, such as select(Customer.email, Customer.plan), returned EQL values, though it is ordinary ORM code. The hook now finds the result columns that are encrypted fields, through attributes and labels, and decrypts them with one decrypt_rows call for each class and set of fields. A query on a connection is still left to the app, which calls cipher.decrypt_rows: a connection has no hook that can change a result. Refs #1341 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
A match index is a Bloom filter, so the database can return rows that do not hold the text. ADR-0003 says the SQLAlchemy layer should hide this. .match(text) on a field with a match index is col @@ term, and the session hook runs verify on the rows after the query and removes those that fail. Removing rows after the query is right only in a session's select, with the match as a condition of the WHERE joined with AND, and whole objects of the class in the result. Anything else, such as or_, not_, a count, an update, or a query on a connection, raises QueryError before it is sent: it would return, count, or change rows that do not hold the text. A LIMIT is applied before the rows are removed, so a page can be short. ADR-0004 records the choice and the two other options. Refs #1341 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
G6 said the SQLAlchemy layer was a later, separate design. It now exists, and it is a second way to encrypt and decrypt. The entry goes through the principle's four tests with the evidence for each: the need, the shared engine calls, the guarantees kept, and the owners. It also names the one difference, one decrypt_rows call for each class instead of one for the whole result, and the untested echo=True log. Refs #1341 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The decorator refused Optional, so a nullable column could only be declared with a wrong type hint, and encrypt still refused None. A row with a missing value could not be stored. Most columns of an app being ported are nullable. Annotated[Optional[T], Marker] and T | None are now accepted. The plan gives the field "null": "sql", so the engine stores nothing for None and the column holds SQL NULL; decrypt reads it back as None. A query term for None is refused, and points to IS NULL. eql_type_map maps the Optional hints too, so SQLAlchemy makes the column nullable. "sql" shows which rows have no value. "sealed" would hide that, but only for fixed-width types with an equality index alone; it can come later. The ADR-0001 amendment records why. Refs #1341 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
A search box that looks in several text fields is an or_ of match searches. The read hook refused every match inside an or_, so an app had to run one query for each field and join the rows in Python, which stops the database sorting and paging the combined result. A row that passes verify always passes @@, so the database returns every row that any branch would keep. The hook now accepts an or_ whose branches are all match searches, or and_ of them, and keeps a row when any branch passes verify. That gives exactly the rows verify would keep over the whole table. An or_ with any other condition, and a match under not_, are still refused. Refs #1341 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
In an outer join, a row with no related object has None in that object's place. With AND alone such a row never came back, because its @@ is NULL. With an or_ of matches, another branch can bring it back, and _verify then read the field from None and raised AttributeError. A missing object now counts as a NULL value: its search fails for that row, and another branch can still keep it. Refs #1341 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
A bare ORDER BY on an EQL v4 column sorted in a wrong order, with no error. EQL v4 gives its column types comparisons but no default sort order, so PostgreSQL sorted the stored JSON. On 98, 120, 150 and 175 it gave 98, 150, 175, 120. The EQL v4 on next behaves the same. The layer now sorts an encrypted column by eql_v4.ord_term(col), or ord_term_ore for an ore_block marker. That sorts by the plaintext, and the SDK's index on the term serves the sort. asc, desc, and the nulls orderings go through the column type. A bare order_by(col) and sqlalchemy.asc(col) do not, so both hooks rewrite the ORDER BY. A column with no order index cannot be sorted, and raises QueryError. ADR-0004 decision 9 records the EQL gap and the options. Refs #1341 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Rule A asked for a marker on every annotated attribute, relationships included. A list relationship could not be marked Ignore, because SQLAlchemy reads the type directly inside Mapped, so an app removed the annotation and lost the type. Its hint also had to resolve when the decorator ran, so the target class had to come first. A relationship is never a column, so leaving it out cannot store plaintext. The decorator now finds the relationships in the class's SQLAlchemy mapper, which lists them when the class statement runs, and leaves them out of the plan without resolving their hints. Every column still needs a marker, as Rule A says. Refs #1341 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The engine hook rewrote the ORDER BY only for selects without the ORM mark, and left the rest to the session hook. But SQLAlchemy marks a select as ORM whenever it names a mapped class or attribute, even when it runs on a connection, where the session hook does not run. So conn.execute(select(T.level).order_by(T.level)) sorted the stored JSON. The engine hook now rewrites every select. A select from a session reaches it already rewritten, and is left as it is. Refs #1341 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The ORDER BY rewrite took only columns whose table is a Table, and read
only the top select. So a bare order_by on an aliased class, or in a
subquery, still sorted the stored JSON. It now takes any column of an
EQL type, and rewrites every select in the statement, copying it only
when an ORDER BY changes.
Copying a statement made SQLAlchemy compare columns with ==, which
showed two bugs in the column type. Its None check compared a SQL
expression for truth and raised TypeError, and it treated another
column as a value to encrypt. A comparison with an expression that
holds no value, such as another column, now passes through. One that
holds a value, such as literal("x"), is refused: the value would reach
the database as plaintext.
Refs #1341
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
order_by(col.label("x")) and order_by("x") sent ORDER BY x, which
sorts by the selected column: the stored JSON, in a wrong order.
SQLAlchemy keeps a label reference in the ORDER BY, not the column, so
the rewrite did not see it.
The rewrite now collects each select's labelled encrypted columns by
name, and replaces a label reference, by object or by name, with the
order term of the column it names.
Refs #1341
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
order_by("level") and desc("level") sent ORDER BY laps.level, which
sorts the stored JSON. SQLAlchemy matches a string to a column only
when it compiles the statement, so the rewrite did not see a column.
The rewrite now looks the name up as SQLAlchemy does: the labels, then
the selected columns, then the columns of the FROM tables. A name that
belongs to an encrypted column becomes its order term. A name that two
FROM tables share, and that is not selected, raises QueryError,
because the rewrite cannot tell which column is meant.
Refs #1341
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The name lookup for a string in an ORDER BY kept only encrypted
columns. So a plaintext label or column with the name of an encrypted
FROM column did not replace it, and the rewrite sorted by the
encrypted column instead: select(T.rank.label("level")).order_by("level")
sorted by T.level, with no error.
The lookup now keeps every candidate, plaintext ones included, in
SQLAlchemy's order: FROM columns, then selected columns, then labels.
Only a name that resolves to an encrypted column is rewritten.
Refs #1341
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
configure_encryption(engine, classes) installed the write hook on one engine, and refused a second call for a class. An app that writes through more than one engine, such as an app engine, a test engine, and a worker run twice in one test, had no way to cover them, and a missed engine wrote without encryption. configure_encryption(classes) now installs the write hook once, on SQLAlchemy's Engine class, so every engine runs it, including one made later. A table is found by its Table object, not its name, so another database's table of the same name is not encrypted. A repeat call for a configured class does nothing; it still refuses a changed class, or a second class for the same table. The package is not released, so the signature change breaks no user. Refs #1341 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
configure_encryption checked each table constraint with `column in constraint.columns`. A column collection accepts only a string key with `in`. So SQLAlchemy raised ArgumentError for a class with an encrypted field and any UniqueConstraint on its table. That includes the one that unique=True makes for a plaintext column. The check now uses contains_column. An encrypted column in a composite UniqueConstraint is now refused with declaration_unique_column, the same as a column declared unique=True. A unique plaintext column beside an encrypted one is accepted. Refs #1341 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
|
|
Important Review skippedAuto reviews are disabled on base/target branches other than the default branch. Please check the settings in the CodeRabbit UI or the
|
This comment has been minimized.
This comment has been minimized.
cipherstash-agent
left a comment
There was a problem hiding this comment.
Review attestation
Reviewed 63327b0 · posted from Claude Code · claude-opus-5-5 · effort medium
Sources: code-review · claude-opus-5-5
Skills: code-review, rust-security, review-cross-check, review-post
Blocking (1)
languages/python/python/stackencrypt/sqlalchemy.py:579a sealed value written inline under another bind name reaches PostgreSQL as plaintext
Findings (10): 1 critical, 5 major, 4 minor — 10 inline, 0 below.
Every finding was reproduced at 63327b0 (SQLAlchemy 2.1.8, with a stubbed _engine). rust-security found nothing in the Rust changes under languages/python/crates/.
Generated by Claude Code
| as plaintext.""" | ||
| inline = clause.compile(dialect=conn.dialect).params | ||
| for name in sorted(sealed): | ||
| if inline.get(name) is not None and not any(name in row for row in rows): |
There was a problem hiding this comment.
blocking · critical The check only looks for a compiled param named exactly after the field, and _encrypt_parameters only encrypts row keys equal to field names. So insert(Customer).values([{"email": ...}, ...]) (email_m0), values(email=func.lower(...)), values(email=bindparam("new_email")) with executemany rows, case(..., else_=...), and on_conflict_do_update(set_={"email": ...}) all send the plaintext to the driver with no refusal and no encryption. Decide per sealed column from the statement's own values (_values, _multi_values, _ordered_values, the ON CONFLICT set), and refuse any bound value that is not a field-named parameter the hook encrypts.
Source: code-review · claude-opus-5-5
Generated by Claude Code
| state.statement = _sorted_by_terms(state.statement) | ||
| # Freezing reads every row, so every load and refresh event has fired. | ||
| options = {_VERIFIED: True} if searches else {} | ||
| result = state.invoke_statement(execution_options=options).freeze() |
There was a problem hiding this comment.
major .freeze() runs on every ORM select in every session, configured class or not. It makes select(Customer, Order).options(joinedload(Customer.orders))).unique().all() raise InvalidRequestError inside the freeze, makes a single-entity joinedload silently return duplicate parents instead of raising, and loads the whole table under yield_per/stream_results. Return early when no configured mapper is involved, and decrypt per chunk instead of freezing.
Source: code-review · claude-opus-5-5
Generated by Claude Code
| pending = state.session.info.pop(_PENDING, []) | ||
| groups: Dict[Tuple[type, FrozenSet[str]], List[object]] = {} | ||
| for target, names in pending: | ||
| cipher, sealed = _CONFIGURED[type(target)] |
There was a problem hiding this comment.
major The load/refresh listeners (lines 448–449) lack propagate=True, and _CONFIGURED[type(target)] here and at 627 is an exact-type lookup. A single-table subclass Vip(Customer) is encrypted on write (matched by Table) but loads with raw EQL JSON in email, and configure_encryption([Vip]) is refused (declaration_not_decorated / declaration_already_configured); _checked_matches (667–672) also refuses .match() on select(Vip). Listen with propagate=True and resolve the class through the mapper's table or the MRO.
Source: code-review · claude-opus-5-5
Generated by Claude Code
| if field.marker is Passthrough: | ||
| continue | ||
| column = mapper.columns[field.name] | ||
| unique = column.unique or any( |
There was a problem hiding this comment.
major The unique refusal misses Index("ux_email", "email", unique=True) and a primary key on an encrypted column: both are accepted, and with fresh ciphertext per value they never detect a duplicate. Also refuse any index.unique index whose expressions include the bare column, and any PrimaryKeyConstraint containing it.
Source: code-review · claude-opus-5-5
Generated by Claude Code
| ``order_by(col)``, or ``sqlalchemy.asc(col)``, does not go through the | ||
| column type's operators, so the hooks rewrite it here. The statement is | ||
| copied only when an ORDER BY changes.""" | ||
| if not any( |
There was a problem hiding this comment.
major Only Select._order_by_clauses is rewritten. A bare encrypted column in union(...).order_by("level"), row_number().over(order_by=Customer.level), aggregate_order_by(...), or percentile_disc(...).within_group(Customer.level) still sorts by the stored JSON with no error. Refuse a bare encrypted column in those positions; a UNION's ORDER BY cannot simply be rewritten to ord_term.
Source: code-review · claude-opus-5-5
Generated by Claude Code
| @@ -293,11 +358,19 @@ def _read_fields(cls: type, hints: Dict[str, Any]) -> List[_ReadField]: | |||
| names = _library_field_names(cls) | |||
There was a problem hiding this comment.
major Relationships are filtered only when names come from the hints. For a MappedAsDataclass model, dataclasses.fields includes orders = relationship(...), its hint was dropped by _hints, so it fails with declaration_no_marker — even when marked Ignore, which was accepted before this PR. Compute _relationships(cls) before the branch and filter both branches.
Source: code-review · claude-opus-5-5
Generated by Claude Code
|
|
||
| probe.__annotations__ = {name: hint} | ||
| try: | ||
| hints.update(get_type_hints(probe, global_names, dict(vars(base)), include_extras=True)) |
There was a problem hiding this comment.
minor For a function, get_type_hints(probe, globalns, localns) searches the class dict before the module, the reverse of get_type_hints(cls). With from __future__ import annotations and a relationship present, date: Mapped[Annotated[date, Passthrough]] resolves to the mapped attribute and fails declaration_type_mismatch. Swap to get_type_hints(probe, dict(vars(base)), global_names, ...) here and at line 434.
Source: code-review · claude-opus-5-5
Generated by Claude Code
| if kind not in _INDEXES: | ||
| continue | ||
| function, method = _INDEXES[kind] | ||
| name = f"ix_{table.name}_{column.name}_{kind}" |
There was a problem hiding this comment.
minor The index name is never truncated: table customer_subscription_events with column billing_contact_email_address gives a 64-character name, and CreateIndex raises IdentifierError, failing create_all and the migration. Truncate names over 63 characters with a short hash suffix.
Source: code-review · claude-opus-5-5
Generated by Claude Code
| continue | ||
| # The model declares an index on this term itself, such as a | ||
| # unique btree on eq_term: that one serves the search. | ||
| if f"eql_v4.{function}({column.name})" in declared: |
There was a problem hiding this comment.
minor _index_term lowercases and strips quotes, but this side keeps column.name as written, so a column Email with its own text('eql_v4.eq_term("Email")') index never matches and a redundant ix_customers_Email_eq is added. Normalise this string the same way.
Source: code-review · claude-opus-5-5
Generated by Claude Code
| ``insert(table).values(email=...)``: the hook can only change the | ||
| parameters passed beside the statement, so the value would be stored | ||
| as plaintext.""" | ||
| inline = clause.compile(dialect=conn.dialect).params |
There was a problem hiding this comment.
minor Every INSERT/UPDATE on a configured table is compiled a second time here, outside SQLAlchemy's compiled cache, just to read .params; and a session select of an unconfigured class walks the statement tree four times (_matches_in and _sorted_by_terms in both hooks). Read the statement's values directly and skip the walks when no configured table is involved.
Source: code-review · claude-opus-5-5
Generated by Claude Code
CI failed in three jobs. Two causes, both in the tests. The offline jobs have no CipherStash profile. The module-scoped scores fixture called configure_encryption before the function-scoped credentials fixture set the fake credentials, and one declaration case built a Cipher with no credentials fixture. Both now ask for credentials. They passed locally only because of a ~/.cipherstash profile. The live job sets EQL_TEST_DATABASE_URL as a libpq keyword string. psycopg reads it, but SQLAlchemy's create_engine reads only a URL. A new helper, sqlalchemy_url, turns either form into a SQLAlchemy URL for psycopg 3. Both live SQLAlchemy test files use it. Refs #1341 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The Optional commit added Value::Null. The fuzz target's match on (kind, value) had no arm for it, so the fuzz crate no longer compiled. The target now generates a Null input and expects Scalar::Null back. stackencrypt-core now depends on zeroize. The fuzz crate's Cargo.lock did not list it. Nothing keeps that lock in step with the workspace lock (review thread 1511-F6). Refs #1341 Co-Authored-By: Claude Opus 5.5 noreply@anthropic.com
Summary
This PR lets a SQLAlchemy app store and search encrypted columns with no encrypt or decrypt calls in the app code. One setup call,
configure_encryption([Customer]), does the setup. It also adds the SDK calls that the layer uses: order queries, match search, andencrypt_rowsanddecrypt_rowsfor partial rows.Changes
SDK (
stackencrypt):lt,lte,gt, andgtequery methods, one for each order operator (ADR-0003).matches,verify, andtarget_limit. The heap hygiene test coversverify.encrypt_rowsanddecrypt_rows, for rows that hold only some of a model's fields.SQLAlchemy layer (
stackencrypt.sqlalchemy, ADR-0004):Engineclass encrypts every INSERT and UPDATE for the configured tables. It makes one ZeroKMS request for each statement.Sessiondecrypts the objects and columns that a query loads.==,!=,<,<=,>,>=,in_,not_in, and.match()work on encrypted columns. Anor_of match searches works too.ORDER BYon an encrypted column sorts by its order term. Without this, PostgreSQL sorts the stored JSON, and the order is wrong.configure_encryptionrefuses setups that cannot work. Examples are a unique encrypted column and two classes that map the same table.OptionalstoresNoneas SQL NULL. A relationship attribute needs no marker.Example:
app_sqlalchemy.pynow usesconfigure_encryptionin place of the hand-written code.Verification
Related
Refs #1341
Review notes
betweenis not included. It waits for an engine change.sa.table(...)is not encrypted. PostgreSQL refuses it, because the column type does not match.🤖 Generated with Claude Code