Skip to content

feat(python): query an equality term - #1510

Draft
yujiyokoo wants to merge 2 commits into
feat/python-sdk-decryptfrom
feat/python-sdk-query
Draft

yujiyokoo wants to merge 2 commits into
feat/python-sdk-decryptfrom
feat/python-sdk-query

Conversation

@yujiyokoo

Copy link
Copy Markdown
Contributor

Summary

Cipher.query now makes an equality query term, so a program can search an encrypted column. EQL (Encrypt Query Language) is CipherStash's set of PostgreSQL types for encrypted values. The term is a str. The SQL casts it to the field's query type, and EQL's = compares it with the stored values. After the first operation of the process, a query sends no request to CipherStash.

term = cipher.query("email", "a@example.com")
cur.execute("SELECT * FROM users WHERE email = %s::eql_v4.query_text_eq", (term,))

Changes

  • Python API: Cipher.query(field, value) returns the term. The new QueryError is a StashError.
  • Field checks, before any request: a name that is not a declared field is query_unknown_field. A field whose marker has no eq index is query_unsupported. Examples are Text, Passthrough, IntegerOrd, and TextMatch. The message lists the fields with an equality query.
  • Value checks, before any request: the same as for encrypt. Both calls now use one function, field_value.
  • Core: crates/core/src/query.rs calls the engine's query_term with the eq operator, as Go's Cipher.QueryTerm does. It checks the field's operators from the plan before it builds the client.
  • Design: ADR-0002 records the call shape. The field is named by a string. A class attribute such as User.email does not exist on a plain dataclass. Core does not change the user's class. The later SQLAlchemy layer may accept the attribute.
  • README: a new section, "Search by equality".
  • AGENTS.md: the live job description now includes query, and the design line lists ADR-0002.

Verification

  • pytest on Python 3.14 and 3.9: 281 passed, 19 skipped. The skips are the 17 live tests and 2 gate-probe tests.
  • mise run test:cargo: 30 core tests and 5 extension tests passed.
  • clippy, cargo fmt --check, and mypy: no findings. pyright: 0 errors and the 3 earlier warnings.
  • pnpm run test:scripts: 83 of 83 files pass.
  • A core test shows that the term has the stored value's hm key, the equality term, and its i. The term has no ciphertext c.
  • The live tests have not run on this machine. This machine has no CipherStash credentials. The first run is the live job on this PR.

Related

Closes #1391. Refs #1341.

Review notes

  • The live tests cover three acceptance criteria: one row found out of two, EXPLAIN using the eq_term index, and 100 queries with no request.
  • The index uses USING hash, as EQL's own docs do. The EQL test that I copied the EXPLAIN approach from uses a btree index. So the live job is the first check of the hash index with EXPLAIN.
  • The term's bytes are copied once per call. QueryTerm in stack-encrypt has no accessor that gives up its operand. A comment in the code says this.
  • The decrypt test helpers are now pub(crate), so the query tests can reuse them.

🤖 Generated with Claude Code

yujiyokoo and others added 2 commits November 3, 2026 00:02
Cipher.query(field, value) returns the equality query term as a str.
The SQL casts it to the marker's query_sql_domain, such as
eql_v4.query_text_eq, and EQL's = compares it with the stored eq_term.
The term comes from the index key in the loaded keyset, so a query
makes no request once the client is built.

The field is named by a string. ADR-0002 records why: a class
attribute such as User.email does not exist on a plain dataclass, and
core does not change the user's class. The later SQLAlchemy layer may
accept the attribute.

Cipher.query raises QueryError before the extension is called:
query_unknown_field for a name that is not a declared field, and
query_unsupported for a Text or Passthrough field. The value is
checked as encrypt checks it. The two now share one function.

The live tests search one row out of two, check that EXPLAIN uses the
eq_term index, and check that 100 queries make no request.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Cipher.query accepted every marker with a query type. The engine
answers equality only through the eq index. So a field such as
IntegerOrd or TextMatch passed the check, built the client, loaded the
keyset, and then failed as a plain StashError.

Cipher.query now checks for the eq index, and raises QueryError
(query_unsupported) before any request. The error message lists only
the fields with an equality query.

core::query checks the same from the plan, with no I/O, before it
builds the client. The extension maps query_* codes to QueryError.

Quote the ADR-0002 ticket value, so YAML does not read it as a
comment. Add a comment on the copy of the term's bytes: QueryTerm has
no accessor that gives up its operand.

Co-Authored-By: Claude Opus 5.5 noreply@anthropic.com
@coderabbitai

coderabbitai Bot commented Nov 2, 2026

Copy link
Copy Markdown
Contributor

Important

Draft PR not reviewed

Draft PRs are not automatically reviewed by default.

  • Trigger a manual review

To automatically review draft PRs, update your CodeRabbit configuration:

reviews:
  auto_review:
    drafts: true

@changeset-bot

changeset-bot Bot commented Nov 2, 2026

Copy link
Copy Markdown

⚠️ No Changeset found

Latest commit: 7585c57

Merging this PR will not cause a version bump for any packages. If these changes should not result in a new version, you're good to go. If these changes should result in a version bump, you need to add a changeset.

This PR includes no changesets

When changesets are added to this PR, you'll see the packages that this PR includes changesets for and the associated semver types

Click here to learn what changesets are, and how to add one.

Click here if you're a maintainer who wants to add a changeset to this PR

@blacksmith-sh

blacksmith-sh Bot commented Nov 2, 2026

Copy link
Copy Markdown
Contributor

Found 2 test failures on Blacksmith runners:

Failures

Test View Logs
test_encrypt_live/test_a_sqlalchemy_class_is_built_without_its_ignored_relationship View Logs
test_encrypt_live/
test_rows_read_back_with_dict_row_decrypt_into_the_models[SqlAlchemyUser]
View Logs

Fix with [code]smith
Need help on this PR? Tag @codesmith-bot with what you need.

This branch has not been deployed

No deployments
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant