Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
31 commits
Select commit Hold shift + click to select a range
0130815
docs(python): record one method for each query operator
yujiyokoo Nov 10, 2026
e99ea85
feat(python): one query method for each order operator
yujiyokoo Nov 10, 2026
b0ad533
docs(python): between waits for the engine
yujiyokoo Nov 10, 2026
deacf8a
feat(python): match search with matches, verify and target_limit
yujiyokoo Nov 11, 2026
31cfe64
test(python): heap hygiene covers verify
yujiyokoo Nov 11, 2026
283b118
feat(python): encrypt_rows for rows that contain only some fields
yujiyokoo Nov 11, 2026
cd69444
feat(python): decrypt_rows, the reverse of encrypt_rows
yujiyokoo Nov 11, 2026
3c6cc63
feat(python): configure_encryption for the SQLAlchemy ORM
yujiyokoo Nov 11, 2026
c899f61
feat(python): search encrypted columns through the SQLAlchemy ORM
yujiyokoo Nov 11, 2026
feb54db
feat(python): Alembic migrations for encrypted columns and their indexes
yujiyokoo Nov 11, 2026
9965ad6
refactor(python): the SQLAlchemy example uses configure_encryption
yujiyokoo Nov 11, 2026
022f67f
feat(python): !=, in_ and passthrough checks in the SQLAlchemy layer
yujiyokoo Nov 11, 2026
6185df7
feat(python): a declared index replaces the SDK's; refuse unique EQL …
yujiyokoo Nov 11, 2026
b6bb749
docs(python): record the SQLAlchemy layer's design in ADR-0004
yujiyokoo Nov 11, 2026
403617d
feat(python): decrypt the encrypted columns of a session query
yujiyokoo Nov 11, 2026
53a2f1a
feat(python): match search through the SQLAlchemy ORM
yujiyokoo Nov 11, 2026
5c691fa
docs(python): scorecard G6 covers the SQLAlchemy layer
yujiyokoo Nov 11, 2026
360d9f7
feat(python): a field declared Optional stores None as SQL NULL
yujiyokoo Nov 11, 2026
496b793
feat(python): an or_ of match searches through the SQLAlchemy ORM
yujiyokoo Nov 11, 2026
c555603
fix(python): an or_ of matches across an outer join no longer crashes
yujiyokoo Nov 11, 2026
69bb475
fix(python): sort encrypted columns by their order term
yujiyokoo Nov 11, 2026
8e70626
feat(python): a SQLAlchemy relationship needs no marker
yujiyokoo Nov 11, 2026
7073225
fix(python): rewrite ORDER BY in an ORM select run on a connection
yujiyokoo Nov 11, 2026
ef420d4
fix(python): sort by the order term in aliases and subqueries
yujiyokoo Nov 11, 2026
951983f
fix(python): sort a labelled encrypted column by its order term
yujiyokoo Nov 11, 2026
21ef41d
fix(python): sort a column named by a string by its order term
yujiyokoo Nov 11, 2026
6770900
fix(python): a plaintext label hides an encrypted column in ORDER BY
yujiyokoo Nov 11, 2026
42a3de9
feat(python)!: configure_encryption covers every engine in a process
yujiyokoo Nov 11, 2026
63327b0
fix(python): refuse an encrypted column inside a UniqueConstraint
yujiyokoo Nov 12, 2026
9c90527
test(python): run the SQLAlchemy tests with no profile and a keyword DSN
yujiyokoo Nov 12, 2026
20291ff
fix(python): cover Value::Null in the value_frame fuzz target
yujiyokoo Nov 12, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion AGENTS.md

Large diffs are not rendered by default.

4 changes: 4 additions & 0 deletions languages/python/.gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -4,3 +4,7 @@
*.so
*.pyd
.pytest_cache/
# The CS_* credentials for `mise run test:live`, if `stash env` writes them
# here. The file contains a client key and an access key, so it must never
# be committed.
.stackencrypt-live.env
195 changes: 173 additions & 22 deletions languages/python/README.md

Large diffs are not rendered by default.

1 change: 1 addition & 0 deletions languages/python/crates/core/fuzz/Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

Original file line number Diff line number Diff line change
Expand Up @@ -71,6 +71,7 @@ enum Host {
Date(i64),
Timestamp(i128),
Decimal { mantissa: i128, scale: u32 },
Null,
}

impl Host {
Expand All @@ -87,6 +88,7 @@ impl Host {
mantissa: *mantissa,
scale: *scale,
},
Self::Null => Value::Null,
}
}
}
Expand All @@ -107,6 +109,7 @@ fn expected(kind: Option<Kind>, value: Value<'_>) -> Scalar {
mantissa,
scale: u8::try_from(scale).expect("an accepted scale"),
},
(_, Value::Null) => Scalar::Null,
}
}

Expand Down
116 changes: 116 additions & 0 deletions languages/python/crates/core/src/decrypt.rs
Original file line number Diff line number Diff line change
Expand Up @@ -459,13 +459,15 @@ pub(crate) mod tests {
Field {
name: name.to_owned(),
eql: Some((eql.to_owned(), kind)),
nullable: false,
}
}

fn passthrough(name: &str) -> Field {
Field {
name: name.to_owned(),
eql: None,
nullable: false,
}
}

Expand Down Expand Up @@ -533,6 +535,7 @@ pub(crate) mod tests {
row.passthrough(field, Value::Int(*i)).unwrap()
}
Column::Passthrough(other) => panic!("not an age: {other:?}"),
Column::Null => panic!("no field here is nullable"),
}
}
rows.push(row);
Expand Down Expand Up @@ -717,4 +720,117 @@ pub(crate) mod tests {
assert_eq!(refused.code, "decrypt_kind_mismatch", "{refused:?}");
assert_eq!(refused.field.as_deref(), Some("email"));
}

/// `people` with a nullable sealed field and a nullable passthrough.
fn people() -> ModelPlan {
ModelPlan::new(
"people",
vec![
Field {
name: "city".to_owned(),
eql: Some(("TextEq".to_owned(), Kind::String)),
nullable: true,
},
Field {
name: "email".to_owned(),
eql: Some(("TextEq".to_owned(), Kind::String)),
nullable: false,
},
Field {
name: "note".to_owned(),
eql: None,
nullable: true,
},
],
)
.unwrap()
}

fn people_rows<'a>(rows: &[(Value<'a>, &'a str, Value<'a>)]) -> ValueFrame {
ValueFrame::of(Source(
rows.iter()
.map(|(city, email, note)| {
vec![
("city", Some(Kind::String), *city),
("email", Some(Kind::String), Value::Text(email)),
("note", None, *note),
]
})
.collect(),
))
.unwrap()
}

#[test]
fn a_null_in_a_nullable_field_is_stored_as_sql_null_and_opens_as_null() {
let cipher = cipher();
let plan = people();
let source = people_rows(&[
(Value::Null, "a@example.com", Value::Null),
(Value::Text("Leeds"), "b@example.com", Value::Text("hi")),
]);

let stored = run(encrypt_with(&cipher.default_keyset(), &plan, source, 2)).unwrap();

assert!(matches!(stored[0][0], Column::Null));
assert!(matches!(
stored[0][2],
Column::Null | Column::Passthrough(Scalar::Null)
));
assert!(matches!(stored[1][0], Column::Eql(_)));

let mut rows = Rows::default();
for columns in &stored {
let mut row = Row::new();
for (field, column) in plan.fields().iter().zip(columns) {
match column {
Column::Eql(json) => row.eql(&plan, field, json.as_bytes()).unwrap(),
Column::Passthrough(Scalar::Text(text)) => {
row.passthrough(field, Value::Text(text)).unwrap()
}
// A passthrough is always in the row, as a null here.
Column::Passthrough(Scalar::Null) => {
row.passthrough(field, Value::Null).unwrap()
}
// A nullable EQL field's SQL NULL: no entry.
Column::Null if field.eql.is_some() => {}
Column::Null => row.passthrough(field, Value::Null).unwrap(),
Column::Passthrough(other) => panic!("not a note: {other:?}"),
}
}
rows.push(row);
}
let opened = run(decrypt_with(&cipher, &plan, rows)).unwrap();

assert_eq!(
opened,
vec![
vec![
Scalar::Null,
Scalar::Text("a@example.com".to_owned()),
Scalar::Null
],
vec![
Scalar::Text("Leeds".to_owned()),
Scalar::Text("b@example.com".to_owned()),
Scalar::Text("hi".to_owned()),
],
]
);
}

#[test]
fn a_null_in_a_field_that_is_not_nullable_is_refused() {
let cipher = cipher();
let source = ValueFrame::of(Source(vec![vec![
("city", Some(Kind::String), Value::Text("Leeds")),
("email", Some(Kind::String), Value::Null),
("note", None, Value::Text("hi")),
]]))
.unwrap();

let refused = run(encrypt_with(&cipher.default_keyset(), &people(), source, 1));

assert!(refused.is_err(), "{refused:?}");
}
}
23 changes: 18 additions & 5 deletions languages/python/crates/core/src/encrypt.rs
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,9 @@ pub struct Field {
pub name: String,
/// The EQL type and its kind, or `None` for a passthrough.
pub eql: Option<(String, Kind)>,
/// Whether the field can be missing: Python's `Optional`. A missing
/// value is written as a null, and comes back as [`Column::Null`].
pub nullable: bool,
}

/// A model's plan, built once per `Cipher` and read on every call.
Expand All @@ -53,7 +56,11 @@ impl ModelPlan {
.eql
.as_ref()
.map_or(plan::Column::Passthrough, |(name, _)| {
plan::Column::Eql(name)
if field.nullable {
plan::Column::NullableEql(name)
} else {
plan::Column::Eql(name)
}
});
(field.name.as_str(), column)
})
Expand Down Expand Up @@ -90,6 +97,8 @@ pub enum Column {
Eql(String),
/// The echoed value of a passthrough field.
Passthrough(Scalar),
/// A nullable field's missing value: SQL `NULL`, Python's `None`.
Null,
}

/// Encrypt `rows` models, written as one frame, under `plan`. Returns one
Expand Down Expand Up @@ -152,10 +161,14 @@ fn read_row(plan: &ModelPlan, row: StackCipherText) -> Result<Vec<Column>, Refus
};
let mut columns = Vec::with_capacity(plan.fields.len());
for field in &plan.fields {
let (_, outputs) = entries
.iter()
.find(|(name, _)| name == &field.name)
.ok_or_else(|| engine_fault("a row lacks a declared field"))?;
let Some((_, outputs)) = entries.iter().find(|(name, _)| name == &field.name) else {
// A nullable EQL field's null produces no entry.
if field.nullable {
columns.push(Column::Null);
continue;
}
return Err(engine_fault("a row lacks a declared field"));
};
let CipherText::Map(outputs) = outputs else {
return Err(engine_fault("a field is not a map of outputs"));
};
Expand Down
16 changes: 15 additions & 1 deletion languages/python/crates/core/src/plan.rs
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,10 @@ use crate::targets;
pub enum Column<'a> {
/// A field sealed as the EQL type of this name, such as `TextEq`.
Eql(&'a str),
/// As [`Column::Eql`], for a field that can be missing. The engine
/// stores nothing for a null (`"null": "sql"`), so the column holds SQL
/// `NULL`, and opens a missing value as a null.
NullableEql(&'a str),
/// A field stored as it is.
Passthrough,
}
Expand Down Expand Up @@ -90,10 +94,20 @@ struct Spec<'a> {

impl Serialize for Spec<'_> {
fn serialize<S: Serializer>(&self, serializer: S) -> Result<S::Ok, S::Error> {
let mut spec = serializer.serialize_struct("Spec", 2)?;
// The frame needs each container's exact length.
let len = if matches!(self.column, Column::NullableEql(_)) {
3
} else {
2
};
let mut spec = serializer.serialize_struct("Spec", len)?;
spec.serialize_field("context", &self.context)?;
match self.column {
Column::Eql(name) => spec.serialize_field("target", name)?,
Column::NullableEql(name) => {
spec.serialize_field("target", name)?;
spec.serialize_field("null", "sql")?;
}
Column::Passthrough => spec.serialize_field("outputs", &["passthrough"])?,
}
spec.end()
Expand Down
Loading
Loading