Skip to content

Commit 19f5f3e

Browse files
committed
feat: a null has an equality term, and a sealed null sits beside one
vitaminc-normalize 0.8 derives an equality term for a null: one term per context, shared by every null, typed or plain. #1327 dropped it to keep the old behaviour; this keeps it (#1298). - stack-encrypt: `EqualityTerm` of a `None`, and `IndexSpec::Equality` on a null, now derive that term. No other index has one. - Data plans: `"null": "sealed"` takes a field whose only index is equality, on the field or on the EQL type it names. The null is sealed as long as a value and stores the null term beside it, so the absent rows are one equality group, as any repeated value is, and the column holds no SQL NULL. An `eq` query whose input is a null derives that term (`query_term`, `record::query`); a null query of any other field or operator is the new `Reason::NullQuery`. - eql-bindings: a type whose only term is equality's (`DateEq`, `IntegerEq`, ...) and its query twin take an `Option` of the plaintext, as storage-only types do, and the by-name dispatch encrypts, opens and queries them through it. - Go: stashgen defaults to `null=sealed` for a fixed-width field whose only index is equality, and generates `EqNull`, the `eq` query of nil, through the new `gensupport.Field.NullTerm`. Verified in PostgreSQL: `=` with the query of a null finds exactly the rows of a DateEq column whose value was a sealed null. Refs #1298. Claude-Session: https://claude.ai/code/session_01XQN1QpaWVDGjHEg39KEJF3
1 parent 539db18 commit 19f5f3e

37 files changed

Lines changed: 769 additions & 269 deletions

File tree

‎.changeset/eql-bindings-optional-storage-only.md‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,4 +2,4 @@
22
"@cipherstash/eql": patch
33
---
44

5-
`eql-bindings` (the Rust crate released with this package): a storage-only EQL v4 type (`Date`, `Integer`, `Text`, and the other types with no term) now also implements `EncryptFrom` and `DecryptInto` for an `Option` of its plaintext. A `None` seals as a typed null as long as a value (vitaminc 0.8), so an optional column does not show which rows are absent, and opens as `None`. A value seals exactly as before. The by-name dispatch (`encryption::targets`) reads and opens these types through the `Option`, so a null frame encrypts and a stored null decrypts to a null; a type with a term still refuses a null. eql-bindings' own `Date`, `Timestamp` and `Decimal` plaintexts declare their fixed width to vitaminc, so their `None` is padded too. The SQL bundle and the TypeScript types do not change.
5+
`eql-bindings` (the Rust crate released with this package): an EQL v4 type whose terms a null has, a storage-only type (`Date`, `Integer`, `Text`, ...) or one whose only term is equality's (`DateEq`, `IntegerEq`, `TextEq`, ...), now also implements `EncryptFrom` and `DecryptInto` for an `Option` of its plaintext, and its query twin `EncryptFrom` for one. A `None` seals as a typed null as long as a value (vitaminc 0.8), so an optional column does not show which rows are absent, and opens as `None`; its `hm` is the one term every null shares, and the query of a `None` finds the stored nulls with `=`. A value seals exactly as before. The by-name dispatch (`encryption::targets`) reads, opens and queries these types through the `Option`; a type with an order or match term still refuses a null. eql-bindings' own `Date`, `Timestamp` and `Decimal` plaintexts declare their fixed width to vitaminc, so their `None` is padded too. The SQL bundle and the TypeScript types do not change.

‎languages/golang/CHANGELOG.md‎

Lines changed: 9 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -20,12 +20,15 @@ listed under **Breaking**. Releases are tagged `languages/golang/vX.Y.Z`.
2020
so the column does not show which rows have no value; `null=sql` stores
2121
nothing, so the field's columns are SQL `NULL` and its member in the
2222
generated type is a pointer. `stashgen` picks `null=sealed` for a field
23-
with no index whose type has a fixed width (a bool, a number,
24-
`encrypt.Date`, `time.Time`, `encrypt.Decimal`, the 128-bit integers),
25-
and asks for `null=sql` to be written on any other: a null has no index
26-
term yet, and text shows its length. A stored null read into a field that
27-
is not a pointer is an error from `Decrypt`. `-model` does not map a
28-
`null=sql` field yet.
23+
whose type has a fixed width (a bool, a number, `encrypt.Date`,
24+
`time.Time`, `encrypt.Decimal`, the 128-bit integers) and whose only
25+
index, if any, is equality, and asks for `null=sql` to be written on any
26+
other: a null has an equality term and no other, and text shows its
27+
length. Beside equality a sealed null stores the term every nil of the
28+
field shares, and a generated `EqNull` method derives its `eq` query,
29+
which finds the rows whose value is absent. A stored null read into a
30+
field that is not a pointer is an error from `Decrypt`. `-model` does not
31+
map a `null=sql` field yet.
2932

3033
### Breaking
3134

‎languages/golang/cmd/stashgen/README.md‎

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -113,11 +113,12 @@ A sealed field declared over a pointer to one scalar (`*encrypt.Date`, `*int32`,
113113

114114
| `null=` | Stored for `nil` | What a reader of the column sees |
115115
|---|---|---|
116-
| `sealed` | a sealed null, as long as a sealed value of the field's type | nothing: an absent value looks like any other |
116+
| `sealed` | a sealed null, as long as a sealed value of the field's type, and beside an equality index the one term every null of the field shares | nothing in the ciphertext; in an equality column, that the absent rows are one group, as rows holding one repeated value are |
117117
| `sql` | nothing: the field's columns are `NULL`, and its member in the generated type is `nil` | which rows have no value |
118118

119-
A sealed null hides that the value is absent only where its length matches a value's and nothing beside it shows it, so it is the default for a field with no index whose type has a fixed width: a `bool`, a number, `encrypt.Date`, `time.Time`, `encrypt.Decimal`, `encrypt.Int128` and `encrypt.Uint128`, sealed with `encrypt` or into an EQL type with no index (`Date`, `Integer`, `Boolean`, ...).
120-
A field with an index, and a field of text or bytes, has to write `null=sql`: a null has no index term yet, and text shows its length, so a sealed null would hide nothing there, and `stashgen` will not choose a visible `NULL` for you.
119+
A sealed null hides that the value is absent only where its length matches a value's and no index beside it shows it, so it is the default for a field whose type has a fixed width (a `bool`, a number, `encrypt.Date`, `time.Time`, `encrypt.Decimal`, `encrypt.Int128` and `encrypt.Uint128`) and whose only index, if it has one, is `equality`: sealed with `encrypt`, `encrypt,index=equality`, or into an EQL type with no index or with equality alone (`Date`, `DateEq`, `Integer`, `IntegerEq`, ...).
120+
A null has an equality term, so its rows are one equality group and a query finds them: the generated `EqNull` derives the `eq` query of `nil`, which matches every row whose value is absent.
121+
A null has no order or match term, so a field with any other index, and a field of text or bytes, has to write `null=sql`: an order or match column would show which values are absent, and text shows its length, and `stashgen` will not choose a visible `NULL` for you.
121122
`null=sealed` on such a field stops `stashgen` with the reason.
122123

123124
A `null=sql` field's member in the generated type is a pointer (`*eql.DateEq`, `*EncryptedUserScore`), `nil` when the value was; a `null=sealed` field's is the same type as a non-optional field's, and its column is never `NULL`.

‎languages/golang/encrypt/gensupport/declaration.go‎

Lines changed: 5 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -86,8 +86,9 @@ type NullMode string
8686
const (
8787
// NullSealed seals nil like a value, as a null as long as a value of
8888
// the field's kind, so the stored record does not show which rows are
89-
// absent. Decrypt gives nil. The field derives no index, and its kind
90-
// has a fixed width.
89+
// absent. Decrypt gives nil. An equality index stores the term every nil
90+
// shares, which [Field.NullTerm] queries; the field has no other index,
91+
// and its kind has a fixed width.
9192
NullSealed NullMode = "sealed"
9293
// NullSQL stores nothing for nil: the field's columns hold SQL NULL,
9394
// which shows that the value is absent. Decrypt gives nil.
@@ -170,7 +171,8 @@ func (d Declaration) EncryptInto(name string, kind Kind, eqlType string) Declara
170171

171172
// Null says how the named field, declared over a pointer, stores nil. The
172173
// engine decides whether the field can, when the declaration is first used:
173-
// a sealed null needs a field with no index, of a kind of fixed width.
174+
// a sealed null needs a field whose only index is equality, of a kind of
175+
// fixed width.
174176
func (d Declaration) Null(name string, mode NullMode) Declaration {
175177
if d.err != nil {
176178
return d

‎languages/golang/encrypt/gensupport/field.go‎

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -100,6 +100,18 @@ func (f Field[T]) Range(ctx context.Context, c *encrypt.Cipher, operator string,
100100
return terms[0], terms[1], nil
101101
}
102102

103+
// NullTerm derives an exact operator's term for nil: for eq on a field that
104+
// stores nil as a sealed null, the term every such nil shares, so it finds
105+
// the rows whose value is absent. The engine refuses it for any other field
106+
// or operator: nothing else stores a null's term.
107+
func (f Field[T]) NullTerm(ctx context.Context, c *encrypt.Cipher, operator string) ([]byte, error) {
108+
terms, err := f.query(ctx, c, operator, nil, 1)
109+
if err != nil {
110+
return nil, err
111+
}
112+
return terms[0], nil
113+
}
114+
103115
// query derives an exact operator's terms, and refuses an answer of another
104116
// shape than the generated method's: a probabilistic term has a query type
105117
// of its own (Matches). Each operand is converted to the field's kind as a

‎languages/golang/encrypt/guest/tests/native_ops.rs‎

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -352,15 +352,16 @@ fn unsupported_term_inputs_are_encoding_errors() {
352352
let ctx = encode(s("f"));
353353

354354
// Bools have no equality domain and no order term; match is text-only;
355-
// containers have no term semantics; kinds outside the table and empty
356-
// contexts are rejected.
355+
// containers have no term semantics; a null has an equality term
356+
// (#1298) and no order term; kinds outside the table and empty contexts
357+
// are rejected.
357358
for (value, kind) in [
358359
(Tree::Bool(true), TERM_EQUALITY),
359360
(Tree::Bool(true), TERM_ORE),
360361
(Tree::Bool(false), TERM_OPE),
361362
(Tree::UInt32(1), TERM_MATCH),
362363
(Tree::Array(vec![]), TERM_ORE),
363-
(Tree::Null, TERM_EQUALITY),
364+
(Tree::Null, TERM_ORE),
364365
(Tree::UInt32(1), 99),
365366
] {
366367
assert_eq!(

‎languages/golang/encrypt/internal/testusers/people.go‎

Lines changed: 13 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -6,18 +6,20 @@ import "github.com/cipherstash/stack/languages/golang/encrypt"
66
//go:generate go run github.com/cipherstash/stack/languages/golang/cmd/stashgen -type Birthday -name Birthday
77

88
// Person has optional fields, each declared over a pointer, in both ways a
9-
// field stores nil (#1298): DOB and Age as a sealed null, as long as a
10-
// value, which the generator picks for a type of fixed width with no index;
11-
// Born, Score and Nick as SQL NULL, which an indexed field and text have to
12-
// ask for.
9+
// field stores nil (#1298). DOB, Born and Age store it as a sealed null, as
10+
// long as a value, which the generator picks for a type of fixed width whose
11+
// only index is equality: Born's and Age's equality terms of nil are the one
12+
// term every nil shares, which EqNull queries. Joined, Score and Nick store
13+
// SQL NULL, which an order index and text have to ask for.
1314
type Person struct {
14-
_ struct{} `stash:"context=people"`
15-
ID int64 `stash:"id,passthrough"`
16-
DOB *encrypt.Date `stash:"dob,encrypt_into=Date"`
17-
Born *encrypt.Date `stash:"born,encrypt_into=DateEq,null=sql"`
18-
Age *int32 `stash:"age,encrypt"`
19-
Score *int64 `stash:"score,encrypt,index=ore,null=sql"`
20-
Nick *string `stash:"nick,encrypt,null=sql"`
15+
_ struct{} `stash:"context=people"`
16+
ID int64 `stash:"id,passthrough"`
17+
DOB *encrypt.Date `stash:"dob,encrypt_into=Date"`
18+
Born *encrypt.Date `stash:"born,encrypt_into=DateEq"`
19+
Joined *encrypt.Date `stash:"joined,encrypt_into=DateEq,null=sql"`
20+
Age *int32 `stash:"age,encrypt,index=equality"`
21+
Score *int64 `stash:"score,encrypt,index=ore,null=sql"`
22+
Nick *string `stash:"nick,encrypt,null=sql"`
2123
}
2224

2325
// Birthday reads Person's dob column into a field that is not a pointer,

‎languages/golang/encrypt/internal/testusers/person_stash.go‎

Lines changed: 94 additions & 42 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

0 commit comments

Comments
 (0)