diff --git a/languages/golang/CHANGELOG.md b/languages/golang/CHANGELOG.md index c2fc099f1..c0c98d873 100644 --- a/languages/golang/CHANGELOG.md +++ b/languages/golang/CHANGELOG.md @@ -92,9 +92,14 @@ listed under **Breaking**. Releases are tagged `languages/golang/vX.Y.Z`. `index=ope` gets `Lt`, `Lte`, `Gt`, `Gte` and `Between`. Text orders by its folded form (ADR-0002: case and accents folded, under Unicode 16), so `Café` and `cafe` tie and `alice` orders before `Zoe`. +- **Ordering methods on floats.** A `float32` or `float64` field with + `index=ore` or `index=ope` gets `Lt`, `Lte`, `Gt`, `Gte` and `Between`. + Floats order as PostgreSQL's `float8` does (ADR-0002): `-0.0` ties `0.0`, + and every NaN is one value, above `+Inf`. A `float32` orders as the + `float64` it widens to. - **stashgen notices an index no generated method queries**: an `ore` or - `ope` index on bytes or a float (the engine answers the orderings over - integers and text only), or the second of two order indexes on a field. + `ope` index on bytes (the engine answers the orderings over integers, + floats and text only), or the second of two order indexes on a field. The index is still stored; the notice is on stderr and in the file. ### Changed diff --git a/languages/golang/cmd/stashgen/README.md b/languages/golang/cmd/stashgen/README.md index 731ecbe90..40dd3753a 100644 --- a/languages/golang/cmd/stashgen/README.md +++ b/languages/golang/cmd/stashgen/README.md @@ -124,7 +124,7 @@ For `-type User`, the file `user_stash.go` holds: - `Fields`, with one entry for each sealed field. An opaque struct, or a struct with no sealed field outside an opaque one, gets no `Fields`: nothing in it is sealed on its own, so nothing can be queried. An entry encrypts one value, and has one query method for each operator the engine answers on the field: `Eq`, `Lt`, `Lte`, `Gt`, `Gte`, `Between` or `Matches`. - stashgen asks the embedded engine which operators each field answers, and which index answers each one, so it holds no copy of those rules: an equality index answers `Eq`, a match index on a string `Matches`, and an ORE or OPE index on an integer or a string the four orderings and `Between`, through ORE when the field declares both. A string orders by its folded form: case and accents folded, under Unicode 16. + stashgen asks the embedded engine which operators each field answers, and which index answers each one, so it holds no copy of those rules: an equality index answers `Eq`, a match index on a string `Matches`, and an ORE or OPE index on an integer, a float or a string the four orderings and `Between`, through ORE when the field declares both. A float orders as PostgreSQL's `float8` does (`-0.0` ties `0.0`, NaN sorts last), and a string by its folded form: case and accents folded, under Unicode 16. `encrypt_into=TextEq` answers `Eq`, which returns the type's query value, `eql.TextEqQuery`. `encrypt_into=TextMatch` answers `Matches`, which returns an `encrypt.EQLMatchQuery`: the `eql.TextMatchQuery` operand, with `Verify` and `TargetLimit`. The query of `Matches` carries `Verify` and `TargetLimit`, because its answer can hold rows that do not match; an exact operator's query carries neither. diff --git a/languages/golang/encrypt/README.md b/languages/golang/encrypt/README.md index fba25aafb..603dd025a 100644 --- a/languages/golang/encrypt/README.md +++ b/languages/golang/encrypt/README.md @@ -236,14 +236,14 @@ or a type defined over one (`type Email string`): | `uint16`, `uint32`, `uint64`, `uint` | the same, unsigned | `equality`, `ore`, `ope` | the same | | `int8`, `uint8` (`byte`) | an 8-bit integer | `equality` | `Eq` | | `encrypt.Int128`, `encrypt.Uint128` | a 128-bit integer | `equality`, `ore`, `ope` | the same | -| `float32`, `float64` | a float of its width | `equality`, `ore`, `ope` | `Eq` | +| `float32`, `float64` | a float of its width | `equality`, `ore`, `ope` | `Eq`, `Lt`, `Lte`, `Gt`, `Gte`, `Between` | | `encrypt.Decimal` | a decimal: a 96-bit coefficient and a scale up to 28 | `equality` | `Eq` | | `encrypt.Date` | a calendar date | `equality` | `Eq` | | `time.Time` | a timestamp, to the nanosecond, in UTC | `equality` | `Eq` | Each query method needs its index declared. An `ore` or `ope` index on bytes -or a float stores its term, but the engine answers no ordering through it -yet, so such a field has no `Lt` (see [Queries](#queries)). +stores its term, but the engine answers no ordering through it, so such a +field has no `Lt` (see [Queries](#queries)). `encrypt.Date`, `Int128`, `Uint128` and `Decimal` are vitaminc's `vcvalue` types; build them with `encrypt.DateOf`, `ParseInt128`, `ParseUint128` and @@ -261,8 +261,8 @@ a few known rows resolve to the values, as a `bool`'s would say which rows are true. Equality follows vitaminc's normalisation: an integer equals itself at every width, decimals compare at any scale (`1.50 = 1.5`), and a timestamp compares to the microsecond. An order index on -a decimal, date or timestamp is refused for now: those order terms wait for -new cllw-ore and ore-rs releases (#1236). +a decimal, date or timestamp is refused for now: the engine derives no +order term for those kinds yet. A `match` index stores text with no token (an empty string, separator-only text, or text shorter than the n-gram length of 3 characters) as the empty @@ -320,7 +320,12 @@ range matches nothing. So `Between("b", "A")` matches nothing, since `A` folds to `a`, below `b`. Text holding a code point Unicode 16 does not assign has no fold, and its query or write is refused with `encrypt.ErrTerm`. -An order index on bytes or a float stores its term but answers no ordering +Floats order as PostgreSQL's `float8` does (ADR-0002): `-0.0` and `0.0` are +one value, and every NaN is one value, above `+Inf`, so `Between(math.NaN(), +math.Inf(1))` matches nothing. A `float32` orders as the `float64` it +widens to. + +An order index on bytes stores its term but answers no ordering operator, so such a field has no `Lt`; nor does the `ope` index of a field that declares `ore` too, since `ore` answers the orderings. stashgen prints a notice for each such index, on stderr and in the generated file: the terms are stored, and only your own code can compare diff --git a/languages/golang/encrypt/doc.go b/languages/golang/encrypt/doc.go index ad71f813d..d463252d3 100644 --- a/languages/golang/encrypt/doc.go +++ b/languages/golang/encrypt/doc.go @@ -154,9 +154,10 @@ // Eq returns an [EqualityTerm] (or, for a field with encrypt_into, the EQL // type's query value), an ordering an [OreTerm] or [OpeTerm], and Between a // [Range] of two. Their answers are exact. The orderings are answered on -// integers and on text, which orders by its folded form (case and accents -// folded, under Unicode 16), so Café and cafe tie and alice orders before -// Zoe. Matches returns a [MatchQuery], +// integers, on floats, which order as PostgreSQL's float8 does (-0.0 ties +// 0.0, NaN sorts last), and on text, which orders by its folded form (case +// and accents folded, under Unicode 16), so Café and cafe tie and alice +// orders before Zoe. Matches returns a [MatchQuery], // whose answer can hold rows that do not match: size the fetch with // [MatchQuery.TargetLimit], then decrypt the rows and keep those // [MatchQuery.Verify] reports true, one call into the engine for the page. diff --git a/languages/golang/encrypt/internal/testusers/kinds_stash.go b/languages/golang/encrypt/internal/testusers/kinds_stash.go index 36ba4e8e9..bf9a3be67 100644 --- a/languages/golang/encrypt/internal/testusers/kinds_stash.go +++ b/languages/golang/encrypt/internal/testusers/kinds_stash.go @@ -18,11 +18,6 @@ const _ = gensupport.GeneratedVersion1 // Kinds prints its sealed fields in the clear: it has no String or LogValue // method. Write them, or run stashgen with -redact. -// F64: stores ore terms that no generated method queries: the engine answers no -// query through the ore index on this field (float64): the orderings are -// answered over integers and text only. Drop the index unless your own code -// compares the stored terms. - type EncryptedKinds struct { S EncryptedKindsS E EncryptedKindsE @@ -878,6 +873,31 @@ func (f KindsF64Field) Encrypt(ctx context.Context, c *encrypt.Cipher, v float64 return EncryptedKindsF64{Ciphertext: out.Ciphertext, Ore: out.Ore}, nil } +func (f KindsF64Field) Lt(ctx context.Context, c *encrypt.Cipher, v float64) (encrypt.OreTerm, error) { + term, err := f.field.Term(ctx, c, "lt", v) + return encrypt.OreTerm(term), err +} + +func (f KindsF64Field) Lte(ctx context.Context, c *encrypt.Cipher, v float64) (encrypt.OreTerm, error) { + term, err := f.field.Term(ctx, c, "lte", v) + return encrypt.OreTerm(term), err +} + +func (f KindsF64Field) Gt(ctx context.Context, c *encrypt.Cipher, v float64) (encrypt.OreTerm, error) { + term, err := f.field.Term(ctx, c, "gt", v) + return encrypt.OreTerm(term), err +} + +func (f KindsF64Field) Gte(ctx context.Context, c *encrypt.Cipher, v float64) (encrypt.OreTerm, error) { + term, err := f.field.Term(ctx, c, "gte", v) + return encrypt.OreTerm(term), err +} + +func (f KindsF64Field) Between(ctx context.Context, c *encrypt.Cipher, lo, hi float64) (encrypt.Range[encrypt.OreTerm], error) { + low, high, err := f.field.Range(ctx, c, "between", lo, hi) + return encrypt.Range[encrypt.OreTerm]{Low: low, High: high}, err +} + type KindsByField struct { field gensupport.Field[[]byte] } diff --git a/languages/golang/encrypt/internal/testusers/probe_stash.go b/languages/golang/encrypt/internal/testusers/probe_stash.go index 3ab83bafc..8b9a0237c 100644 --- a/languages/golang/encrypt/internal/testusers/probe_stash.go +++ b/languages/golang/encrypt/internal/testusers/probe_stash.go @@ -38,12 +38,22 @@ const _ = gensupport.GeneratedVersion1 // B: stores ore terms that no generated method queries: the engine answers no // query through the ore index on this field ([]byte): the orderings are -// answered over integers and text only. Drop the index unless your own code -// compares the stored terms. +// answered over integers, floats and text only. Drop the index unless your own +// code compares the stored terms. // B: stores ope terms that no generated method queries: the engine answers no // query through the ope index on this field ([]byte): the orderings are -// answered over integers and text only. Drop the index unless your own code +// answered over integers, floats and text only. Drop the index unless your own +// code compares the stored terms. + +// F64: stores ope terms that no generated method queries: the engine answers no +// query through the ope index on this field (float64): the ore index already +// answers lt and the other orderings. Drop the index unless your own code +// compares the stored terms. + +// F32: stores ope terms that no generated method queries: the engine answers no +// query through the ope index on this field (float32): the ore index already +// answers lt and the other orderings. Drop the index unless your own code // compares the stored terms. type EncryptedProbe struct { @@ -52,6 +62,8 @@ type EncryptedProbe struct { I64 EncryptedProbeI64 S EncryptedProbeS B EncryptedProbeB + F64 EncryptedProbeF64 + F32 EncryptedProbeF32 } type EncryptedProbeU32 struct { @@ -84,12 +96,24 @@ type EncryptedProbeB struct { Ope encrypt.OpeTerm } +type EncryptedProbeF64 struct { + Ciphertext encrypt.Ciphertext + Ore encrypt.OreTerm + Ope encrypt.OpeTerm +} + +type EncryptedProbeF32 struct { + Ciphertext encrypt.Ciphertext + Ore encrypt.OreTerm + Ope encrypt.OpeTerm +} + func (e EncryptedProbe) String() string { - return gensupport.Redacted("EncryptedProbe", nil, "U32", "U64", "I64", "S", "B") + return gensupport.Redacted("EncryptedProbe", nil, "U32", "U64", "I64", "S", "B", "F64", "F32") } func (e EncryptedProbe) LogValue() slog.Value { - return gensupport.RedactedLog(nil, "U32", "U64", "I64", "S", "B") + return gensupport.RedactedLog(nil, "U32", "U64", "I64", "S", "B", "F64", "F32") } // Stops compiling when Probe gains, loses, reorders or retypes a field. @@ -102,6 +126,8 @@ type probeShape struct { I64 int64 S string B []byte + F64 float64 + F32 float32 } var probeDeclaration = gensupport.Declare("prop"). @@ -109,7 +135,9 @@ var probeDeclaration = gensupport.Declare("prop"). EncryptIndex("u64", gensupport.Uint64, encrypt.Ore, encrypt.Ope). EncryptIndex("i64", gensupport.Int64, encrypt.Ore, encrypt.Ope). EncryptIndex("s", gensupport.String, encrypt.Ore, encrypt.Ope). - EncryptIndex("b", gensupport.Bytes, encrypt.Ore, encrypt.Ope) + EncryptIndex("b", gensupport.Bytes, encrypt.Ore, encrypt.Ope). + EncryptIndex("f64", gensupport.Float64, encrypt.Ore, encrypt.Ope). + EncryptIndex("f32", gensupport.Float32, encrypt.Ore, encrypt.Ope) var probeCodec = gensupport.New(gensupport.Generated[Probe, EncryptedProbe]{ TypeName: "Probe", @@ -122,6 +150,8 @@ var probeCodec = gensupport.New(gensupport.Generated[Probe, EncryptedProbe]{ "i64": v.I64, "s": v.S, "b": v.B, + "f64": v.F64, + "f32": v.F32, } }, Seal: func(rec gensupport.Record) (EncryptedProbe, error) { @@ -151,6 +181,16 @@ var probeCodec = gensupport.New(gensupport.Generated[Probe, EncryptedProbe]{ Ore: rec["b"].Ore, Ope: rec["b"].Ope, } + e.F64 = EncryptedProbeF64{ + Ciphertext: rec["f64"].Ciphertext, + Ore: rec["f64"].Ore, + Ope: rec["f64"].Ope, + } + e.F32 = EncryptedProbeF32{ + Ciphertext: rec["f32"].Ciphertext, + Ore: rec["f32"].Ore, + Ope: rec["f32"].Ope, + } return e, nil }, Open: func(e EncryptedProbe) gensupport.Record { @@ -160,6 +200,8 @@ var probeCodec = gensupport.New(gensupport.Generated[Probe, EncryptedProbe]{ "i64": {Ciphertext: e.I64.Ciphertext, Ore: e.I64.Ore, Ope: e.I64.Ope}, "s": {Ciphertext: e.S.Ciphertext, Ore: e.S.Ore, Ope: e.S.Ope}, "b": {Ciphertext: e.B.Ciphertext, Ore: e.B.Ore, Ope: e.B.Ope}, + "f64": {Ciphertext: e.F64.Ciphertext, Ore: e.F64.Ore, Ope: e.F64.Ope}, + "f32": {Ciphertext: e.F32.Ciphertext, Ore: e.F32.Ore, Ope: e.F32.Ope}, } }, Value: func(e EncryptedProbe, vals gensupport.Values) (Probe, error) { @@ -180,6 +222,12 @@ var probeCodec = gensupport.New(gensupport.Generated[Probe, EncryptedProbe]{ if v.B, err = gensupport.Get[[]byte](vals, "b"); err != nil { return Probe{}, err } + if v.F64, err = gensupport.Get[float64](vals, "f64"); err != nil { + return Probe{}, err + } + if v.F32, err = gensupport.Get[float32](vals, "f32"); err != nil { + return Probe{}, err + } return v, nil }, }) @@ -203,12 +251,16 @@ var ProbeFields = struct { I64 ProbeI64Field S ProbeSField B ProbeBField + F64 ProbeF64Field + F32 ProbeF32Field }{ U32: ProbeU32Field{gensupport.NewField[uint32](probeDeclaration, "u32")}, U64: ProbeU64Field{gensupport.NewField[uint64](probeDeclaration, "u64")}, I64: ProbeI64Field{gensupport.NewField[int64](probeDeclaration, "i64")}, S: ProbeSField{gensupport.NewField[string](probeDeclaration, "s")}, B: ProbeBField{gensupport.NewField[[]byte](probeDeclaration, "b")}, + F64: ProbeF64Field{gensupport.NewField[float64](probeDeclaration, "f64")}, + F32: ProbeF32Field{gensupport.NewField[float32](probeDeclaration, "f32")}, } type ProbeU32Field struct { @@ -370,3 +422,77 @@ func (f ProbeBField) Encrypt(ctx context.Context, c *encrypt.Cipher, v []byte) ( } return EncryptedProbeB{Ciphertext: out.Ciphertext, Ore: out.Ore, Ope: out.Ope}, nil } + +type ProbeF64Field struct { + field gensupport.Field[float64] +} + +func (f ProbeF64Field) Encrypt(ctx context.Context, c *encrypt.Cipher, v float64) (EncryptedProbeF64, error) { + out, err := f.field.Encrypt(ctx, c, v) + if err != nil { + return EncryptedProbeF64{}, err + } + return EncryptedProbeF64{Ciphertext: out.Ciphertext, Ore: out.Ore, Ope: out.Ope}, nil +} + +func (f ProbeF64Field) Lt(ctx context.Context, c *encrypt.Cipher, v float64) (encrypt.OreTerm, error) { + term, err := f.field.Term(ctx, c, "lt", v) + return encrypt.OreTerm(term), err +} + +func (f ProbeF64Field) Lte(ctx context.Context, c *encrypt.Cipher, v float64) (encrypt.OreTerm, error) { + term, err := f.field.Term(ctx, c, "lte", v) + return encrypt.OreTerm(term), err +} + +func (f ProbeF64Field) Gt(ctx context.Context, c *encrypt.Cipher, v float64) (encrypt.OreTerm, error) { + term, err := f.field.Term(ctx, c, "gt", v) + return encrypt.OreTerm(term), err +} + +func (f ProbeF64Field) Gte(ctx context.Context, c *encrypt.Cipher, v float64) (encrypt.OreTerm, error) { + term, err := f.field.Term(ctx, c, "gte", v) + return encrypt.OreTerm(term), err +} + +func (f ProbeF64Field) Between(ctx context.Context, c *encrypt.Cipher, lo, hi float64) (encrypt.Range[encrypt.OreTerm], error) { + low, high, err := f.field.Range(ctx, c, "between", lo, hi) + return encrypt.Range[encrypt.OreTerm]{Low: low, High: high}, err +} + +type ProbeF32Field struct { + field gensupport.Field[float32] +} + +func (f ProbeF32Field) Encrypt(ctx context.Context, c *encrypt.Cipher, v float32) (EncryptedProbeF32, error) { + out, err := f.field.Encrypt(ctx, c, v) + if err != nil { + return EncryptedProbeF32{}, err + } + return EncryptedProbeF32{Ciphertext: out.Ciphertext, Ore: out.Ore, Ope: out.Ope}, nil +} + +func (f ProbeF32Field) Lt(ctx context.Context, c *encrypt.Cipher, v float32) (encrypt.OreTerm, error) { + term, err := f.field.Term(ctx, c, "lt", v) + return encrypt.OreTerm(term), err +} + +func (f ProbeF32Field) Lte(ctx context.Context, c *encrypt.Cipher, v float32) (encrypt.OreTerm, error) { + term, err := f.field.Term(ctx, c, "lte", v) + return encrypt.OreTerm(term), err +} + +func (f ProbeF32Field) Gt(ctx context.Context, c *encrypt.Cipher, v float32) (encrypt.OreTerm, error) { + term, err := f.field.Term(ctx, c, "gt", v) + return encrypt.OreTerm(term), err +} + +func (f ProbeF32Field) Gte(ctx context.Context, c *encrypt.Cipher, v float32) (encrypt.OreTerm, error) { + term, err := f.field.Term(ctx, c, "gte", v) + return encrypt.OreTerm(term), err +} + +func (f ProbeF32Field) Between(ctx context.Context, c *encrypt.Cipher, lo, hi float32) (encrypt.Range[encrypt.OreTerm], error) { + low, high, err := f.field.Range(ctx, c, "between", lo, hi) + return encrypt.Range[encrypt.OreTerm]{Low: low, High: high}, err +} diff --git a/languages/golang/encrypt/internal/testusers/queryprofile_stash.go b/languages/golang/encrypt/internal/testusers/queryprofile_stash.go index dc29d65bf..af27d39b4 100644 --- a/languages/golang/encrypt/internal/testusers/queryprofile_stash.go +++ b/languages/golang/encrypt/internal/testusers/queryprofile_stash.go @@ -20,6 +20,7 @@ type EncryptedQueryProfile struct { Email EncryptedQueryProfileEmail Age EncryptedQueryProfileAge Name EncryptedQueryProfileName + Score EncryptedQueryProfileScore } type EncryptedQueryProfileEmail struct { @@ -38,12 +39,17 @@ type EncryptedQueryProfileName struct { Match encrypt.MatchTerm } +type EncryptedQueryProfileScore struct { + Ciphertext encrypt.Ciphertext + Ore encrypt.OreTerm +} + func (e EncryptedQueryProfile) String() string { - return gensupport.Redacted("EncryptedQueryProfile", nil, "Email", "Age", "Name") + return gensupport.Redacted("EncryptedQueryProfile", nil, "Email", "Age", "Name", "Score") } func (e EncryptedQueryProfile) LogValue() slog.Value { - return gensupport.RedactedLog(nil, "Email", "Age", "Name") + return gensupport.RedactedLog(nil, "Email", "Age", "Name", "Score") } // Stops compiling when QueryProfile gains, loses, reorders or retypes a field. @@ -54,12 +60,14 @@ type queryProfileShape struct { Email string Age uint32 Name string + Score float64 } var queryProfileDeclaration = gensupport.Declare("users"). EncryptIndex("email", gensupport.String, encrypt.Equality). EncryptIndex("age", gensupport.Uint32, encrypt.Equality, encrypt.Ore). - EncryptIndex("name", gensupport.String, encrypt.Match()) + EncryptIndex("name", gensupport.String, encrypt.Match()). + EncryptIndex("score", gensupport.Float64, encrypt.Ore) var queryProfileCodec = gensupport.New(gensupport.Generated[QueryProfile, EncryptedQueryProfile]{ TypeName: "QueryProfile", @@ -70,6 +78,7 @@ var queryProfileCodec = gensupport.New(gensupport.Generated[QueryProfile, Encryp "email": v.Email, "age": v.Age, "name": v.Name, + "score": v.Score, } }, Seal: func(rec gensupport.Record) (EncryptedQueryProfile, error) { @@ -87,6 +96,10 @@ var queryProfileCodec = gensupport.New(gensupport.Generated[QueryProfile, Encryp Ciphertext: rec["name"].Ciphertext, Match: rec["name"].Match, } + e.Score = EncryptedQueryProfileScore{ + Ciphertext: rec["score"].Ciphertext, + Ore: rec["score"].Ore, + } return e, nil }, Open: func(e EncryptedQueryProfile) gensupport.Record { @@ -94,6 +107,7 @@ var queryProfileCodec = gensupport.New(gensupport.Generated[QueryProfile, Encryp "email": {Ciphertext: e.Email.Ciphertext, Equality: e.Email.Equality}, "age": {Ciphertext: e.Age.Ciphertext, Equality: e.Age.Equality, Ore: e.Age.Ore}, "name": {Ciphertext: e.Name.Ciphertext, Match: e.Name.Match}, + "score": {Ciphertext: e.Score.Ciphertext, Ore: e.Score.Ore}, } }, Value: func(e EncryptedQueryProfile, vals gensupport.Values) (QueryProfile, error) { @@ -108,6 +122,9 @@ var queryProfileCodec = gensupport.New(gensupport.Generated[QueryProfile, Encryp if v.Name, err = gensupport.Get[string](vals, "name"); err != nil { return QueryProfile{}, err } + if v.Score, err = gensupport.Get[float64](vals, "score"); err != nil { + return QueryProfile{}, err + } return v, nil }, }) @@ -129,10 +146,12 @@ var QueryProfileFields = struct { Email QueryProfileEmailField Age QueryProfileAgeField Name QueryProfileNameField + Score QueryProfileScoreField }{ Email: QueryProfileEmailField{gensupport.NewField[string](queryProfileDeclaration, "email")}, Age: QueryProfileAgeField{gensupport.NewField[uint32](queryProfileDeclaration, "age")}, Name: QueryProfileNameField{gensupport.NewField[string](queryProfileDeclaration, "name")}, + Score: QueryProfileScoreField{gensupport.NewField[float64](queryProfileDeclaration, "score")}, } type QueryProfileEmailField struct { @@ -211,3 +230,40 @@ func (f QueryProfileNameField) Encrypt(ctx context.Context, c *encrypt.Cipher, v func (f QueryProfileNameField) Matches(ctx context.Context, c *encrypt.Cipher, v string) (encrypt.MatchQuery[string], error) { return gensupport.Matches(ctx, f.field, c, v) } + +type QueryProfileScoreField struct { + field gensupport.Field[float64] +} + +func (f QueryProfileScoreField) Encrypt(ctx context.Context, c *encrypt.Cipher, v float64) (EncryptedQueryProfileScore, error) { + out, err := f.field.Encrypt(ctx, c, v) + if err != nil { + return EncryptedQueryProfileScore{}, err + } + return EncryptedQueryProfileScore{Ciphertext: out.Ciphertext, Ore: out.Ore}, nil +} + +func (f QueryProfileScoreField) Lt(ctx context.Context, c *encrypt.Cipher, v float64) (encrypt.OreTerm, error) { + term, err := f.field.Term(ctx, c, "lt", v) + return encrypt.OreTerm(term), err +} + +func (f QueryProfileScoreField) Lte(ctx context.Context, c *encrypt.Cipher, v float64) (encrypt.OreTerm, error) { + term, err := f.field.Term(ctx, c, "lte", v) + return encrypt.OreTerm(term), err +} + +func (f QueryProfileScoreField) Gt(ctx context.Context, c *encrypt.Cipher, v float64) (encrypt.OreTerm, error) { + term, err := f.field.Term(ctx, c, "gt", v) + return encrypt.OreTerm(term), err +} + +func (f QueryProfileScoreField) Gte(ctx context.Context, c *encrypt.Cipher, v float64) (encrypt.OreTerm, error) { + term, err := f.field.Term(ctx, c, "gte", v) + return encrypt.OreTerm(term), err +} + +func (f QueryProfileScoreField) Between(ctx context.Context, c *encrypt.Cipher, lo, hi float64) (encrypt.Range[encrypt.OreTerm], error) { + low, high, err := f.field.Range(ctx, c, "between", lo, hi) + return encrypt.Range[encrypt.OreTerm]{Low: low, High: high}, err +} diff --git a/languages/golang/encrypt/internal/testusers/users.go b/languages/golang/encrypt/internal/testusers/users.go index 6676b941b..812291128 100644 --- a/languages/golang/encrypt/internal/testusers/users.go +++ b/languages/golang/encrypt/internal/testusers/users.go @@ -51,6 +51,7 @@ type QueryProfile struct { Email string `stash:"email,encrypt,index=equality"` Age uint32 `stash:"age,encrypt,index=equality;ore"` Name string `stash:"name,encrypt,index=match"` + Score float64 `stash:"score,encrypt,index=ore"` } //go:generate go run github.com/cipherstash/stack/languages/golang/cmd/stashgen -type Document -name Document @@ -83,6 +84,8 @@ type Probe struct { I64 int64 `stash:"i64,encrypt,index=ore;ope"` S string `stash:"s,encrypt,index=ore;ope"` B []byte `stash:"b,encrypt,index=ore;ope"` + F64 float64 `stash:"f64,encrypt,index=ore;ope"` + F32 float32 `stash:"f32,encrypt,index=ore;ope"` } //go:generate go run github.com/cipherstash/stack/languages/golang/cmd/stashgen -type Note -name Note diff --git a/languages/golang/encrypt/order_test.go b/languages/golang/encrypt/order_test.go index 78e439b69..8d1ff5ac2 100644 --- a/languages/golang/encrypt/order_test.go +++ b/languages/golang/encrypt/order_test.go @@ -4,6 +4,7 @@ import ( "bytes" "cmp" "context" + "math" "math/rand" "reflect" "slices" @@ -18,7 +19,7 @@ import ( // stores for them through the generated Probe fields' Encrypt, comparison // in Go. A query method derives the same bytes, but only for the index // that answers its operator (ORE, where a field declares both), and only -// over integers and text, so the stored terms reach every index and kind. ORE is +// over integers, floats and text, so the stored terms reach every index and kind. ORE is // probabilistic — a wrong comparator or a wrong derivation still agrees // with plaintext order on many pairs — so a fixed vector set says little; // hundreds of random pairs per type say more. The index key is the @@ -139,6 +140,32 @@ func foldedString(r *rand.Rand) string { return string(out) } +// floatOrder is ADR-0002's order of floats, which is Postgres's float8 +// order: -0.0 is 0.0, and every NaN is one value, above +Inf. +func floatOrder[F float32 | float64](a, b F) int { + an, bn := a != a, b != b + switch { + case an && bn: + return 0 + case an: + return 1 + case bn: + return -1 + } + return cmp.Compare(a, b) +} + +// floatValues are the edges of a float's order: both infinities, the +// largest and smallest magnitudes, both zeros, and NaNs of several signs +// and payloads, which are one value. +func floatValues() []float64 { + return []float64{ + math.Inf(-1), -math.MaxFloat64, -1.5, -math.SmallestNonzeroFloat64, + math.Copysign(0, -1), 0, math.SmallestNonzeroFloat64, 1.5, math.MaxFloat64, math.Inf(1), + math.NaN(), math.Float64frombits(0xfff8_0000_0000_0000), math.Float64frombits(0x7ff8_0000_0000_beef), + } +} + // inOrder orders strings by their place in values, for a list written in // the order the fold gives it. func inOrder(values []string) func(a, b string) int { @@ -199,6 +226,31 @@ func TestTermOrderIsPlaintextOrder(t *testing.T) { names := []string{"alice", "Bob", "eclair", "\u00c9lan", "emu", "Zoe"} adjacentProperty(t, cipher, ope, d, names, inOrder(names)) }) + t.Run("float64", func(t *testing.T) { + d := storedTerm(f.F64.Encrypt, ope, + func(e testusers.EncryptedProbeF64) []byte { return e.Ore }, + func(e testusers.EncryptedProbeF64) []byte { return e.Ope }) + // Every bit pattern, so every exponent is drawn: testing/quick's + // default scales by MaxFloat64 and draws only huge values. + orderProperty(t, cipher, ope, d, floatOrder[float64], func(r *rand.Rand) float64 { + return math.Float64frombits(r.Uint64()) + }) + adjacentProperty(t, cipher, ope, d, floatValues(), floatOrder[float64]) + }) + t.Run("float32", func(t *testing.T) { + d := storedTerm(f.F32.Encrypt, ope, + func(e testusers.EncryptedProbeF32) []byte { return e.Ore }, + func(e testusers.EncryptedProbeF32) []byte { return e.Ope }) + orderProperty(t, cipher, ope, d, floatOrder[float32], func(r *rand.Rand) float32 { + return math.Float32frombits(r.Uint32()) + }) + var values []float32 + for _, v := range floatValues() { + values = append(values, float32(v)) + } + values = append(values, math.SmallestNonzeroFloat32, -math.SmallestNonzeroFloat32, math.MaxFloat32) + adjacentProperty(t, cipher, ope, d, values, floatOrder[float32]) + }) t.Run("bytes", func(t *testing.T) { d := storedTerm(f.B.Encrypt, ope, func(e testusers.EncryptedProbeB) []byte { return e.Ore }, diff --git a/languages/golang/encrypt/postgres_test.go b/languages/golang/encrypt/postgres_test.go index 5290d624f..de42f913a 100644 --- a/languages/golang/encrypt/postgres_test.go +++ b/languages/golang/encrypt/postgres_test.go @@ -61,7 +61,9 @@ func TestQueriesRunAgainstPostgres(t *testing.T) { age_eq bytea NOT NULL, age_ore bytea NOT NULL, name bytea NOT NULL, - name_match bytea NOT NULL + name_match bytea NOT NULL, + score bytea NOT NULL, + score_ore bytea NOT NULL )`, matchPositions, } { @@ -74,7 +76,7 @@ func TestQueriesRunAgainstPostgres(t *testing.T) { var people []testusers.QueryProfile for i, v := range f.Verify.Values { if v != nil { - people = append(people, testusers.QueryProfile{Email: "p" + string(rune('a'+i)) + "@example.com", Age: uint32(20 + i), Name: *v}) + people = append(people, testusers.QueryProfile{Email: "p" + string(rune('a'+i)) + "@example.com", Age: uint32(20 + i), Name: *v, Score: float64(i) - 1.5}) } } encrypted, err := testusers.EncryptQueryProfile(ctx, cipher, people) @@ -83,8 +85,8 @@ func TestQueriesRunAgainstPostgres(t *testing.T) { } for i, e := range encrypted { if _, err := conn.ExecContext(ctx, - `INSERT INTO profiles VALUES ($1, $2, $3, $4, $5, $6, $7, $8)`, - i, e.Email.Ciphertext, e.Email.Equality, e.Age.Ciphertext, e.Age.Equality, e.Age.Ore, e.Name.Ciphertext, e.Name.Match, + `INSERT INTO profiles VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10)`, + i, e.Email.Ciphertext, e.Email.Equality, e.Age.Ciphertext, e.Age.Equality, e.Age.Ore, e.Name.Ciphertext, e.Name.Match, e.Score.Ciphertext, e.Score.Ore, ); err != nil { t.Fatal(err) } @@ -121,7 +123,7 @@ func TestQueriesRunAgainstPostgres(t *testing.T) { t.Fatal("a field declared with tags has no expected token count, so with no histogram the limit is unbounded") } result, err := conn.QueryContext(ctx, ` - SELECT id, email, email_eq, age, age_eq, age_ore, name, name_match FROM profiles + SELECT id, email, email_eq, age, age_eq, age_ore, name, name_match, score, score_ore FROM profiles WHERE pg_temp.match_positions(name_match) @> pg_temp.match_positions($1) ORDER BY id LIMIT $2`, q, limit.FetchOr(50)) if err != nil { @@ -134,7 +136,7 @@ func TestQueriesRunAgainstPostgres(t *testing.T) { var fetched []testusers.EncryptedQueryProfile for result.Next() { var e testusers.EncryptedQueryProfile - if err := result.Scan(&id, &e.Email.Ciphertext, &e.Email.Equality, &e.Age.Ciphertext, &e.Age.Equality, &e.Age.Ore, &e.Name.Ciphertext, &e.Name.Match); err != nil { + if err := result.Scan(&id, &e.Email.Ciphertext, &e.Email.Equality, &e.Age.Ciphertext, &e.Age.Equality, &e.Age.Ore, &e.Name.Ciphertext, &e.Name.Match, &e.Score.Ciphertext, &e.Score.Ore); err != nil { t.Fatal(err) } ids = append(ids, id) diff --git a/languages/golang/encrypt/query_test.go b/languages/golang/encrypt/query_test.go index a2c55eb26..aa9a92ec1 100644 --- a/languages/golang/encrypt/query_test.go +++ b/languages/golang/encrypt/query_test.go @@ -9,6 +9,7 @@ import ( "errors" "fmt" "log/slog" + "math" "os" "path/filepath" "reflect" @@ -112,11 +113,13 @@ func wireJSON(v any) any { } // queryPlan is the fixture's declaration: an email with equality, an age -// with equality and ORE, a name with match, all under users. +// with equality and ORE, a name with match, a score with ORE, all under +// users. var queryPlan = &record.Plan{Context: []string{"users"}, Fields: []record.Field{ {Name: "email", Kind: record.String, Outputs: []record.Output{record.Ciphertext, record.Equality}}, {Name: "age", Kind: record.Uint32, Outputs: []record.Output{record.Ciphertext, record.Equality, record.Ore}}, {Name: "name", Kind: record.String, Outputs: []record.Output{record.Ciphertext, record.Match}}, + {Name: "score", Kind: record.Float64, Outputs: []record.Output{record.Ciphertext, record.Ore}}, }} func queryFixtureClient(t *testing.T, f queryFixture) *encrypt.Client { @@ -137,9 +140,21 @@ func queryFixtureClient(t *testing.T, f queryFixture) *encrypt.Client { } // goInput is a fixture input as Go code passes it: text as a string, an -// age as the field's uint32, a range as two of them. +// age as the field's uint32, a score as its float64, a range as two of +// them. func goInput(t *testing.T, field string, raw json.RawMessage) any { t.Helper() + if field == "score" { + var f float64 + if err := json.Unmarshal(raw, &f); err == nil { + return f + } + var pair [2]float64 + if err := json.Unmarshal(raw, &pair); err != nil { + t.Fatal(err) + } + return []any{pair[0], pair[1]} + } if field != "age" { var s string if err := json.Unmarshal(raw, &s); err != nil { @@ -492,6 +507,28 @@ func TestGeneratedQueriesAreTheRustFixtures(t *testing.T) { check("Age.Between high", between.High, err, bounds[1]) name, err := fields.Name.Matches(ctx, cipher, "alice") check("Name.Matches", name.Term(), err, want("name", "matches")[0]) + scoreLt, err := fields.Score.Lt(ctx, cipher, 1.5) + check("Score.Lt", scoreLt, err, want("score", "lt")[0]) + scoreBetween, err := fields.Score.Between(ctx, cipher, -0.5, 2.5) + scoreBounds := want("score", "between") + check("Score.Between low", scoreBetween.Low, err, scoreBounds[0]) + check("Score.Between high", scoreBetween.High, err, scoreBounds[1]) + // A float orders as Postgres's float8 does: -0.0 is 0.0, and NaN sorts + // last, so a range from NaN is inverted and, as SQL's BETWEEN, matches + // nothing rather than being refused. + negZero, err := fields.Score.Lt(ctx, cipher, math.Copysign(0, -1)) + if err != nil { + t.Fatalf("Score.Lt(-0.0): %v", err) + } + zero, err := fields.Score.Lt(ctx, cipher, 0) + check("Score.Lt(-0.0)", zero, err, negZero) + fromNaN, err := fields.Score.Between(ctx, cipher, math.NaN(), math.Inf(1)) + if err != nil { + t.Fatalf("a range from NaN: %v", err) + } + if fromNaN.Low.Compare(fromNaN.High) <= 0 { + t.Errorf("a range from NaN: NaN should order above +Inf") + } // Each query binds as a parameter: driver.Valuer gives the term's bytes. for label, v := range map[string]driver.Valuer{"Eq": email, "Lt": lt, "Between.Low": between.Low, "Matches": name} { diff --git a/languages/golang/stashgen/emit.go b/languages/golang/stashgen/emit.go index 642556e4e..1ac6e7256 100644 --- a/languages/golang/stashgen/emit.go +++ b/languages/golang/stashgen/emit.go @@ -636,8 +636,8 @@ func planQueries(f *genFile) error { // // It returns a notice for each index the engine answers no operator // through on the field: the index stores a term no generated method can -// query. That is an ore or ope index on bytes or a float (the engine stores -// it and answers the orderings over integers and text only), or a second +// query. That is an ore or ope index on bytes (the engine stores it and +// answers the orderings over integers, floats and text only), or a second // order index beside the one the engine already answers the orderings // with. Storing it is allowed, since a program may compare the stored terms // itself, but an order term tells anyone who reads the column how its @@ -662,7 +662,7 @@ func indexesAnswer(g *genField) ([]string, error) { } why := "" if ix.Name == IndexOre || ix.Name == IndexOpe { - why = ": the orderings are answered over integers and text only" + why = ": the orderings are answered over integers, floats and text only" for _, other := range []IndexName{IndexOre, IndexOpe} { if op, ok := answers[other]; ok && other != ix.Name { why = fmt.Sprintf(": the %s index already answers %s and the other orderings", other, op) diff --git a/languages/golang/stashgen/enginetest/enginetest.go b/languages/golang/stashgen/enginetest/enginetest.go index fd7d948fa..7aeba26bc 100644 --- a/languages/golang/stashgen/enginetest/enginetest.go +++ b/languages/golang/stashgen/enginetest/enginetest.go @@ -199,7 +199,7 @@ func answeredBy(operator string, t stashgen.GoType, indexes []stashgen.IndexName case "matches": return stashgen.IndexMatch, declared(stashgen.IndexMatch) } - orderable := (integer(t) && !eightBit(t)) || t.Kind == stashgen.KindString + orderable := (integer(t) && !eightBit(t)) || t.Kind == stashgen.KindFloat || t.Kind == stashgen.KindString if !orderable { return "", false } diff --git a/languages/golang/stashgen/refusal_test.go b/languages/golang/stashgen/refusal_test.go index e66142f89..e1e8b77a6 100644 --- a/languages/golang/stashgen/refusal_test.go +++ b/languages/golang/stashgen/refusal_test.go @@ -347,9 +347,9 @@ func TestTheWholeDeclarationIsCheckedAgainstTheEmbeddedEngine(t *testing.T) { } // An index no generated method queries is stored, and the generator says -// so, on stderr and in the file: an order index on bytes or a float, and -// the second of two order indexes on an integer or text. An order index on -// text answers the orderings, so it gets methods and no notice. +// so, on stderr and in the file: an order index on bytes, and the second +// of two order indexes on an integer, a float or text. An order index on +// text or a float answers the orderings, so it gets methods and no notice. func TestAnIndexNoMethodQueriesGetsANotice(t *testing.T) { src := user(ctx + "\tName string `stash:\"name,encrypt,index=match;ope\"`\n\tBlob []byte `stash:\"blob,encrypt,index=ope\"`\n\tScore float64 `stash:\"score,encrypt,index=ore\"`\n\tAge int64 `stash:\"age,encrypt,index=ore;ope\"`\n\tRank int32 `stash:\"rank,encrypt,index=ope\"`") file, err := generate(t, src, stashgen.Request{Type: "User"}) @@ -359,14 +359,13 @@ func TestAnIndexNoMethodQueriesGetsANotice(t *testing.T) { notices := strings.Join(file.Notices, "\n") for _, want := range []string{ "stashgen: User.Blob: stores ope terms that no generated method queries", - "stashgen: User.Score: stores ore terms that no generated method queries", "stashgen: User.Age: stores ope terms that no generated method queries: the engine answers no query through the ope index on this field (int64): the ore index already answers", } { if !strings.Contains(notices, want) { t.Errorf("notices lack %q:\n%s", want, notices) } } - for _, field := range []string{"User.Name", "User.Rank"} { + for _, field := range []string{"User.Name", "User.Rank", "User.Score"} { if strings.Contains(notices, field) { t.Errorf("an order index that answers the orderings got a notice (%s):\n%s", field, notices) } @@ -377,4 +376,7 @@ func TestAnIndexNoMethodQueriesGetsANotice(t *testing.T) { if !strings.Contains(string(file.Content), "func (f NameField) Lt(") { t.Errorf("a text field with ope has no Lt:\n%s", file.Content) } + if !strings.Contains(string(file.Content), "func (f ScoreField) Between(") { + t.Errorf("a float field with ore has no Between:\n%s", file.Content) + } } diff --git a/packages/stack-encrypt/CHANGELOG.md b/packages/stack-encrypt/CHANGELOG.md index a05bf1f4b..9040b6e70 100644 --- a/packages/stack-encrypt/CHANGELOG.md +++ b/packages/stack-encrypt/CHANGELOG.md @@ -104,9 +104,9 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - **The ordering operators, and `Between` as one term.** `op::Lt`, `Lte`, `Gt` and `Gte` take one value and `op::Between` a pair of bounds, `cipher.query(op::Between, &(18, 65)).on(&age_plan)`, inclusive at both - ends. They are answered over the integer types and text (`op::Ordered`) - by the `Ore` index by default and by `Ope` through `.via(Ope)`; an - ordering on bytes or a float does not compile. A comparison's query term is the index's own term + ends. They are answered over the integer types, the floats and text + (`op::Ordered`) by the `Ore` index by default and by `Ope` through + `.via(Ope)`; an ordering on bytes does not compile. A comparison's query term is the index's own term for the value (`OreTerm`, `OpeTerm`); a range's is one `sem::RangeQuery` carrying both bounds' terms, read with `lo()` and `hi()`, so no statement can pair the low bound of one query with the high bound of another. A @@ -307,6 +307,14 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 equality alone, and the frozen `stored_rows_v1.json` loses its four 8-bit order terms under the order-term exception; every other stored byte is as it was. +- **Floats are ordered.** An `f32` or `f64` is encoded at the `F64` + target, whose encoding folds `-0.0` into `0.0` and every NaN into one + value above `+Infinity`: Postgres's `float8` order. So the ordering + operators are answered over `f32` and `f64` (`op::Ordered`) and over a + data plan's `float32` and `float64` fields, which they were not. An `f32` + is ordered as the `f64` it widens to: vitaminc widens it exactly, so an + `f32` and its `f64` are one term. Float order terms change bytes with + every other order term (above). - **A binding's control data is read from its frame with serde, not decoded into an `FfiValue`** (`dynamic` feature). Each message is a typed diff --git a/packages/stack-encrypt/docs/adr/0009-operators-select-the-index-and-a-query-term-is-not-the-stored-term.md b/packages/stack-encrypt/docs/adr/0009-operators-select-the-index-and-a-query-term-is-not-the-stored-term.md index 93771512b..d5df44462 100644 --- a/packages/stack-encrypt/docs/adr/0009-operators-select-the-index-and-a-query-term-is-not-the-stored-term.md +++ b/packages/stack-encrypt/docs/adr/0009-operators-select-the-index-and-a-query-term-is-not-the-stored-term.md @@ -579,9 +579,12 @@ encryption, the order the index compares in (text by its fold, floats by their canonical encoding), which belongs to the ORE crates. One call derives both bounds, under one context, so a caller cannot pair a bound from one column with a bound from -another. The ordering operators are answered over the integer types and -text (`op::Ordered`), by the ORE index by default and by OPE through -`.via(Ope)`. +another. The ordering operators are answered over the integer types, the +floats and text (`op::Ordered`), by the ORE index by default and by OPE +through `.via(Ope)`. Each term is cllw-ore's encryption of the value's +ADR-0002 order key at its type's target, so a float orders as Postgres's +`float8` does (`-0.0` ties `0.0`, NaN sorts last), and an `f32` is ordered +as the `f64` it widens to. In SQL the term stays one operand. EQL v4 gains a function that takes the column and the range, and a range query domain beside each ordered query diff --git a/packages/stack-encrypt/src/dynamic/query.rs b/packages/stack-encrypt/src/dynamic/query.rs index 2b00f2e67..bb835c22e 100644 --- a/packages/stack-encrypt/src/dynamic/query.rs +++ b/packages/stack-encrypt/src/dynamic/query.rs @@ -35,15 +35,16 @@ //! A field with indexes answers what [`OpSpec::answered_by`] says for its //! declared kind: `eq` through an equality index, `matches` through a match //! index over text, and the orderings (`lt`, `lte`, `gt`, `gte`, -//! `between`) through an ORE index over an integer or text, or an OPE index -//! when the field declares no ORE. The typed path's default for an ordering -//! is ORE, and OPE is the typed path's `.via(Ope)`; here a field that -//! declares only OPE is answered through it. Text orders by its folded -//! order key (ADR-0002). An ordering over bytes or a float is not answered, -//! as `op::Ordered` covers the integers of 16 bits and wider (`int16` to -//! `uint128`) and text only. Nor is one over an 8-bit integer, which has -//! no order term, or a decimal, date or timestamp: -//! a plan cannot declare an order index on one yet. +//! `between`) through an ORE index over an integer of 16 bits or wider, a +//! float or text, or an OPE index when the field declares no ORE. The typed +//! path's default for an ordering is ORE, and OPE is the typed path's +//! `.via(Ope)`; here a field that declares only OPE is answered through it. +//! Every order term is derived over the value's ADR-0002 order key: a +//! float's ties `-0.0` with `0.0` and sorts every NaN last, and text's is +//! its fold. An ordering over bytes is not answered, as `op::Ordered` does +//! not cover them. Nor is one over an 8-bit integer, which has no order +//! term, or over a decimal, date or timestamp: a plan cannot declare an +//! order index on one yet. //! //! A field that names an EQL type answers what its //! [`TargetDescriptor::operators`] lists: the operators of a single value @@ -174,6 +175,7 @@ impl OpSpec { /// let both = [IndexSpec::Ope, IndexSpec::Ore]; /// assert_eq!(OpSpec::Lt.answered_by(ValueKind::UInt32, &both), Some(&IndexSpec::Ore)); /// assert_eq!(OpSpec::Lt.answered_by(ValueKind::String, &both), Some(&IndexSpec::Ore)); + /// assert_eq!(OpSpec::Lt.answered_by(ValueKind::Float32, &both), Some(&IndexSpec::Ore)); /// assert_eq!(OpSpec::Lt.answered_by(ValueKind::Bytes, &both), None); /// assert_eq!(OpSpec::Eq.answered_by(ValueKind::UInt32, &both), None); /// ``` @@ -202,10 +204,11 @@ impl fmt::Display for OpSpec { } /// The kinds the ordering operators are answered over: the integers of 16 -/// bits and wider, `int16` to `uint128`, and text, as +/// bits and wider, `int16` to `uint128`, the floats and text, as /// [`op::Ordered`](crate::op::Ordered) covers them in the typed path. Not -/// `int8` or `uint8`, which have no order term. Text orders by its order -/// key (ADR-0002's fold). +/// `int8` or `uint8`, which have no order term. Each orders by its ADR-0002 +/// order key; a `float32` is ordered as the `f64` it widens to, as the +/// typed path asks. fn ordered(kind: ValueKind) -> bool { matches!( kind, @@ -217,6 +220,8 @@ fn ordered(kind: ValueKind) -> bool { | ValueKind::UInt32 | ValueKind::UInt64 | ValueKind::UInt128 + | ValueKind::Float32 + | ValueKind::Float64 | ValueKind::String ) } @@ -1078,6 +1083,8 @@ mod tests { field("score", "int64", vec![text("ope")]), field("nickname", "string", vec![text("c"), text("ore")]), field("handle", "string", vec![text("c"), case_sensitive]), + field("ratio", "float32", vec![text("c"), text("ore")]), + field("rating", "float64", vec![text("c"), text("ope")]), ]))) .expect("the plan parses") } @@ -1182,6 +1189,8 @@ mod tests { ValueKind::UInt32, ValueKind::UInt64, ValueKind::UInt128, + ValueKind::Float32, + ValueKind::Float64, ValueKind::String, ] { assert_eq!( @@ -1202,7 +1211,6 @@ mod tests { } for kind in [ ValueKind::Bytes, - ValueKind::Float64, ValueKind::Bool, ValueKind::Decimal, ValueKind::Date, @@ -1211,7 +1219,7 @@ mod tests { assert_eq!( answered(op, kind, &all), None, - "{op} is answered over integers and text only: {kind}" + "{op} is not answered over {kind}" ); } } @@ -1463,6 +1471,12 @@ mod tests { .await .unwrap(); assert_eq!(folded.operand, cafe.operand); + + // A float32 orders as the f64 it widens to: `-0.0` ties `0.0`, so + // the two are one term. + let zero = query(&keyset, "ratio", OpSpec::Lt, 0.0f32).await.unwrap(); + let negative_zero = query(&keyset, "ratio", OpSpec::Lt, -0.0f32).await.unwrap(); + assert_eq!(negative_zero.operand, zero.operand); } /// A plan whose context is a field of each record has no context a diff --git a/packages/stack-encrypt/src/dynamic/term.rs b/packages/stack-encrypt/src/dynamic/term.rs index 2dc5e5903..1bcb28b7b 100644 --- a/packages/stack-encrypt/src/dynamic/term.rs +++ b/packages/stack-encrypt/src/dynamic/term.rs @@ -1073,6 +1073,16 @@ mod tests { ordered!(2.5f64, Tree::Float64(2.5), "f64"); ordered!("alice".to_string(), s("alice"), "text"); ordered!(b"ab".to_vec(), bytes(b"ab"), "bytes"); + + // A float32 is ordered as the f64 it widens to: `f32`'s target + // is `F64`, so a dynamic `f32` and the typed `f64` it widens to + // are one term. + let ore = term(ks, 1.5f32, &IndexSpec::Ore, ctx.clone()).await; + let typed = keyset.ore_term(1.5f64, nonempty!("users/x")).await; + assert_eq!(ore.expect("ore"), typed.expect("typed").as_ref().to_vec()); + let ope = term(ks, 1.5f32, &IndexSpec::Ope, ctx.clone()).await; + let typed = keyset.ope_term(1.5f64, nonempty!("users/x")).await; + assert_eq!(ope.expect("ope"), typed.expect("typed").as_ref().to_vec()); } /// Equality normalizes: every integer width of one number is one diff --git a/packages/stack-encrypt/src/op/mod.rs b/packages/stack-encrypt/src/op/mod.rs index e7fa34100..d749707cd 100644 --- a/packages/stack-encrypt/src/op/mod.rs +++ b/packages/stack-encrypt/src/op/mod.rs @@ -340,13 +340,15 @@ impl Input for (S, S) { } /// A plaintext the ordering operators ([`Lt`], [`Lte`], [`Gt`], [`Gte`], -/// [`Between`]) are answered over: the integers of 16 bits and wider, and -/// text (`String`). Not `u8` or `i8`, which have no order term -/// ([`Orderable`] says why), as `bool` has none. -/// Each is encoded as its [`Orderable`] target and its terms order as -/// cllw-ore orders that encoding: integers as numbers, text by the system -/// ADR-0002's fold, Unicode 16 NFC with case and accents folded, so `Café`, -/// `cafe` and `CAFÉ` tie and `alice` orders before `Zoe` (ADR-0009). +/// [`Between`]) are answered over: the integers of 16 bits and wider, the +/// floats (`f32` and `f64`) and text (`String`). Not `u8` or `i8`, which +/// have no order term ([`Orderable`] says why), as `bool` has none. Each is +/// encoded as its [`Orderable`] target and its terms order as cllw-ore +/// orders that encoding: integers as numbers; floats as Postgres's `float8` +/// does, `-0.0` tied with `0.0` and every NaN one value above `+Infinity`, +/// an `f32` as the `f64` it widens to; text by the system ADR-0002's fold, +/// Unicode 16 NFC with case and accents folded, so `Café`, `cafe` and +/// `CAFÉ` tie and `alice` orders before `Zoe` (ADR-0009). /// /// Sealed: a type joins only when the order its terms compare in is the /// one its operators promise, so a type whose terms could order otherwise @@ -363,9 +365,9 @@ impl Input for (S, S) { /// /// [`Orderable`]: crate::sem::Orderable #[diagnostic::on_unimplemented( - message = "`{Self}` is not ordered: the ordering operators are answered over the integers of 16 bits and wider, and text", + message = "`{Self}` is not ordered: the ordering operators are answered over the integers of 16 bits and wider, the floats and text", label = "no ordering operator over this type", - note = "`Lt`, `Lte`, `Gt`, `Gte` and `Between` take `u16` to `u128`, `i16` to `i128`, or a `String`; a `u8`, an `i8` and a `bool` have no order term" + note = "`Lt`, `Lte`, `Gt`, `Gte` and `Between` take `u16` to `u128`, `i16` to `i128`, `f32`, `f64` or a `String`; a `u8`, an `i8` and a `bool` have no order term" )] pub trait Ordered: sealed::Sealed + Orderable {} @@ -375,7 +377,7 @@ macro_rules! ordered { impl Ordered for $ty {} )+}; } -ordered!(u16, u32, u64, u128, i16, i32, i64, i128, String); +ordered!(u16, u32, u64, u128, i16, i32, i64, i128, f32, f64, String); /// Text search: rows whose text contains the query's tokens, under the /// match index's tokenizer. Not containment: a Bloom match is order- and diff --git a/packages/stack-encrypt/tests/fixtures/README.md b/packages/stack-encrypt/tests/fixtures/README.md index aec4ab1d3..6fb6ac18c 100644 --- a/packages/stack-encrypt/tests/fixtures/README.md +++ b/packages/stack-encrypt/tests/fixtures/README.md @@ -130,7 +130,9 @@ file changes only when a derivation does: regenerate it with - **`queries`.** Each query as a binding sends it, and its term in the wire form `QueryTerm::to_value` writes, the bytes in hex: one term, or for `between` the low bound's then the high's. An input is JSON, read as the - field's declared type, so `40` queries the `uint32` field `age`. + field's declared type, so `40` queries the `uint32` field `age` and + `1.5` the `float64` field `score`, whose terms are cllw-ore's + encryption of the float's ADR-0002 order key. - **`verify`.** A page of decrypted values, null for an empty field, and the boolean `verify` answers for each, which is what the typed `MatchQuery` answers. diff --git a/packages/stack-encrypt/tests/fixtures/query_operators.json b/packages/stack-encrypt/tests/fixtures/query_operators.json index 3520eee25..d0630eba4 100644 --- a/packages/stack-encrypt/tests/fixtures/query_operators.json +++ b/packages/stack-encrypt/tests/fixtures/query_operators.json @@ -39,6 +39,17 @@ "match" ], "type": "string" + }, + "score": { + "context": [ + "users", + "score" + ], + "outputs": [ + "c", + "ore" + ], + "type": "float64" } }, "queries": [ @@ -155,6 +166,36 @@ "operator": "matches", "term": "30009d00c100" } + }, + { + "field": "score", + "input": 1.5, + "operator": "lt", + "term": { + "domain": null, + "fidelity": "exact", + "index": "ore", + "operator": "lt", + "term": "d86769e4203df9a878dfb084014ea9df2ac9e74b1cef394f5042a6a88e4b81ab7d678eb59f8d27089e5e988fe8130bf9ea2707ab35e341d60c19c9836360eb21" + } + }, + { + "field": "score", + "input": [ + -0.5, + 2.5 + ], + "operator": "between", + "term": { + "domain": null, + "fidelity": "exact", + "index": "ore", + "operator": "between", + "term": [ + "d73ffdebc829eff390b70c5e169f58bf4b2aa664817584cc01f5e114eb7d91d2b6fe729db0f85f0df93d0487a2888f0a1865ca8946912ec33493cb18c5abb183", + "d8688e0201b46fefe8147f16e4011433e428361b12b70e40fed58a526bcdad72208619a7f90e617d22ddda3df8de4ccc224672bc0ea650ad64e30fb253851039" + ] + } } ], "target_limit": [ diff --git a/packages/stack-encrypt/tests/fixtures/record_lowering.json b/packages/stack-encrypt/tests/fixtures/record_lowering.json index 1cfa33472..45ec0cca3 100644 --- a/packages/stack-encrypt/tests/fixtures/record_lowering.json +++ b/packages/stack-encrypt/tests/fixtures/record_lowering.json @@ -47,7 +47,7 @@ "passthrough": "tenants/acme" }, "text": { - "c": "0100000000000000000000000000000000cf1a45d1517a08c45d6ae1c989ae99de2000da620d1dbdbba9372d1439b6d44f7cc4169e9a255387aa180d6a95fb17926d7002feb70f92eddc6cf5ca7057e55c63c0ad86f563b5fc6db163691f66a21e23a7b7e089", + "c": "0100000000000000000000000000000000cf1a45d1517a08c45d6ae1c989ae99de2000da620d1dbdbba9372d1439b6d44f7cc4169e9a255387aa180d6a95fb17926d700294bddaed0bd2c45206be9407edb15f4c046eff417d18a00d6c033139e01770404e72", "eq": "d07ccccb55205dbb5c69c80bd88c907496a18bbed9317f39d96a83f6fd308a85" } }, @@ -59,7 +59,7 @@ "passthrough": "tenants/acme" }, "text": { - "c": "010000000000000000000000000000000048a99626f697c02001f07da2edb19f4220009868b833b6220fa0d13b612af03af1f38fee082c7555b6e7fbf8e13ccb70f80702b4c4d486351fc9e2b013a683cbc7307baba454f2c09d1d30201f2eb501294f2dab73", + "c": "010000000000000000000000000000000048a99626f697c02001f07da2edb19f4220009868b833b6220fa0d13b612af03af1f38fee082c7555b6e7fbf8e13ccb70f80702d9b40099839313e8ad1e943b7a5fc8f1513a45c1e5309cbcdccc05dbd0a991b0f3f4", "eq": "d07ccccb55205dbb5c69c80bd88c907496a18bbed9317f39d96a83f6fd308a85" } } @@ -125,12 +125,12 @@ "records": { "lowering": { "age": { - "c": "01000000000000000000000000000000004d5626148f77c4c6d9da0ddbdf7663a2200021162641133485216f389886cbd7c6e9b25afa0f5f166f985b23384e81ffcfac022c3ca025899006b277f5b901e4ce12fcd2ab92e68ed47a10c23aa44cdfa9f6956b", + "c": "01000000000000000000000000000000004d5626148f77c4c6d9da0ddbdf7663a2200021162641133485216f389886cbd7c6e9b25afa0f5f166f985b23384e81ffcfac02ec48f08e1f2c79b55251885601527a0226a754a797eaa77131f9c7f2d6157f2098", "eq": "cc854fd5c7e3f4bb28cb15cae47d866b65b7012c06df43b4b9a6c51adff4710a", "ore": "758821c792265d942f89fa1dad016fb71b326f756e62950367e5d12f62824807" }, "email": { - "c": "0100000000000000000000000000000000b2afa77357a61488146c0edaa0246f65200043956c4f4bba857028c28165dfce9c63d904b738bbb039b6e11c805112d4a1ee025a27c14c007f12f51176221ffb32f484fb3d257fc1be1e63670d59473975e0d4fc268a016f4857baa18c26f9", + "c": "0100000000000000000000000000000000b2afa77357a61488146c0edaa0246f65200043956c4f4bba857028c28165dfce9c63d904b738bbb039b6e11c805112d4a1ee025de4969eb5509d9cb7d2ef0ea827c2b6bf09d7b73ff165ad68d6fe1136f254f9accc930118490a162a1f829b", "eq": "0cf708c07acaaa169e84e4adf7081f0433d87760bd7e9c63bd7a58c000b8d33b", "match": "030005000900100012001a001c0027002a002e003000340042004b0068006d007100750083009200a800b200b400b700ba00c200c500dc00e500ea00f200f300f900fe00" }, @@ -138,17 +138,17 @@ "passthrough": 42 }, "notes": { - "c": "0100000000000000000000000000000000723f7a7f14b07197168aa13bc52010b3200072a73194b45af604cc6201b5a57554b05cc7ed6ad22b9102cbcb71bd9067849602fe8ea66badb24fd24bbb309e46dd4a291e1abd7f42fd162c71a169b3becfd443ed3d69b4757a97" + "c": "0100000000000000000000000000000000723f7a7f14b07197168aa13bc52010b3200072a73194b45af604cc6201b5a57554b05cc7ed6ad22b9102cbcb71bd90678496021b7a7da71f40bec0f7d93f6bb3a8f32da41d7eac589e1063c90e7d18e397a81b88d966861810b8" } }, "typed_chain": { "age": { - "c": "010000000000000000000000000000000092e427c5e176b60261add6ce80fd02df20008d231453c97fdec97a0b6559ac8f509129d20163f262d40ce5d77cf1ebe0cad502498779411650f97518dc5eeacafb98acf3d9b84a7d2f1c1fdaa21d7782d7b02632", + "c": "010000000000000000000000000000000092e427c5e176b60261add6ce80fd02df20008d231453c97fdec97a0b6559ac8f509129d20163f262d40ce5d77cf1ebe0cad50248c801def074dfd0d34b7f34a04479875a7df403a3b12bd8ca475d4f377a86394f", "eq": "cc854fd5c7e3f4bb28cb15cae47d866b65b7012c06df43b4b9a6c51adff4710a", "ore": "758821c792265d942f89fa1dad016fb71b326f756e62950367e5d12f62824807" }, "email": { - "c": "0100000000000000000000000000000000cf92191950409f9fe82a2b11888ffc1d2000ca4d5bbafbff919af4d3a95c7cc046d632fd4c28785b238f4b6f7ea3aa08482902bfae280fe03edea613bc16e6632ffa9364c5a5fdc25b6015c16700e5c1108c69cbe8116a5367e1751992751b", + "c": "0100000000000000000000000000000000cf92191950409f9fe82a2b11888ffc1d2000ca4d5bbafbff919af4d3a95c7cc046d632fd4c28785b238f4b6f7ea3aa08482902a278c63260694db784fbb3361b95717b08851fa7bf6cce35e45ac115dbaffb9f93dac5cd53939eae0cba0832", "eq": "0cf708c07acaaa169e84e4adf7081f0433d87760bd7e9c63bd7a58c000b8d33b", "match": "030005000900100012001a001c0027002a002e003000340042004b0068006d007100750083009200a800b200b400b700ba00c200c500dc00e500ea00f200f300f900fe00" }, @@ -156,7 +156,7 @@ "passthrough": 42 }, "notes": { - "c": "0100000000000000000000000000000000562a41e032316ff2d1264a9e9f5c3b842000de33c8d1f2caaab8b90f691161a57eeb1328f7f1c9b5be76dec83e8a53765f1102b84037a30ef214de44076878d7c5965eaa2f555e2337bc5a0e1cf1e3e92a4cdafa23089183493c" + "c": "0100000000000000000000000000000000562a41e032316ff2d1264a9e9f5c3b842000de33c8d1f2caaab8b90f691161a57eeb1328f7f1c9b5be76dec83e8a53765f1102bdeeaf5aa37fa829e7519021baaa76584778ece652b6b881054d4f5c9203662309001de93e044a" } } } diff --git a/packages/stack-encrypt/tests/op.rs b/packages/stack-encrypt/tests/op.rs index 569054205..496002968 100644 --- a/packages/stack-encrypt/tests/op.rs +++ b/packages/stack-encrypt/tests/op.rs @@ -1292,3 +1292,47 @@ async fn text_orders_by_its_folded_key() { "a bound with no fold" ); } + +/// Floats order by their ADR-0002 order key, as Postgres's `float8` does: +/// the orderings are answered over `f64`, `-0.0` and `0.0` are one query +/// term, and every NaN is one and sorts above `+Infinity`. +#[tokio::test] +async fn floats_order_by_their_order_key() { + let cipher = stack_cipher().await; + let plan = Plan::context("users/score") + .with::((Ore, Ope)) + .build() + .unwrap(); + let lt = |value: f64| cipher.query(op::Lt, &value).on(&plan); + let ope = |value: f64| cipher.query(op::Lt, &value).via(Ope).on(&plan); + let zero: OreTerm = lt(0.0).await.unwrap(); + assert_eq!(lt(-0.0).await.unwrap(), zero, "-0.0 is 0.0"); + let nan: OreTerm = lt(f64::NAN).await.unwrap(); + assert_eq!(lt(-f64::NAN).await.unwrap(), nan, "one NaN"); + + let order = [ + f64::NEG_INFINITY, + -1.5, + 0.0, + 1e-300, + 1.5, + f64::INFINITY, + f64::NAN, + ]; + let mut terms = Vec::new(); + for value in order { + let term: OpeTerm = ope(value).await.unwrap(); + terms.push(term.as_bytes().to_vec()); + } + assert!(terms.windows(2).all(|pair| pair[0] < pair[1]), "{order:?}"); + let low: OreTerm = lt(-1.5).await.unwrap(); + assert!(low < zero && zero < nan); + + let between = |lo: f64, hi: f64| cipher.query(op::Between, &(lo, hi)).on(&plan); + let from_nan = between(f64::NAN, f64::INFINITY).await.unwrap(); + assert!(from_nan.lo() > from_nan.hi(), "NaN sorts last"); + let one = between(0.0, -0.0).await.unwrap(); + assert_eq!(one.lo(), one.hi(), "one value"); + let range = between(-0.5, 2.5).await.unwrap(); + assert!(range.lo() < range.hi()); +} diff --git a/packages/stack-encrypt/tests/query_operators.rs b/packages/stack-encrypt/tests/query_operators.rs index b2c5d9df6..71c76870c 100644 --- a/packages/stack-encrypt/tests/query_operators.rs +++ b/packages/stack-encrypt/tests/query_operators.rs @@ -33,7 +33,8 @@ fn plan_json() -> Json { json!({ "email": { "context": ["users", "email"], "outputs": ["c", "eq"], "type": "string" }, "age": { "context": ["users", "age"], "outputs": ["c", "eq", "ore"], "type": "uint32" }, - "name": { "context": ["users", "name"], "outputs": ["c", "match"], "type": "string" } + "name": { "context": ["users", "name"], "outputs": ["c", "match"], "type": "string" }, + "score": { "context": ["users", "score"], "outputs": ["c", "ore"], "type": "float64" } }) } @@ -49,6 +50,8 @@ fn queries() -> Vec<(&'static str, OpSpec, Json)> { ("age", OpSpec::Between, json!([18, 65])), ("name", OpSpec::Matches, json!("alice")), ("name", OpSpec::Matches, json!("ali")), + ("score", OpSpec::Lt, json!(1.5)), + ("score", OpSpec::Between, json!([-0.5, 2.5])), ] } @@ -191,6 +194,33 @@ async fn typed_operand( } } ("name", OpSpec::Matches) => one(typed_match(cipher, &text(input)).await.to_bytes()), + ("score", operator) => { + let float = |value: &Json| value.as_f64().expect("a number"); + let score = Plan::context("users/score") + .with::(Ore) + .build() + .expect("plan"); + match operator { + OpSpec::Lt => one(cipher + .query(op::Lt, &float(input)) + .on(&score) + .await + .expect("lt") + .to_bytes()), + OpSpec::Between => { + let range = cipher + .query(op::Between, &(float(&input[0]), float(&input[1]))) + .on(&score) + .await + .expect("between"); + Operand::Range { + lo: range.lo().to_bytes(), + hi: range.hi().to_bytes(), + } + } + other => panic!("no typed score query for {other}"), + } + } other => panic!("no typed query for {other:?}"), } } diff --git a/packages/stack-encrypt/tests/term_bytes.rs b/packages/stack-encrypt/tests/term_bytes.rs index a1fd5b4d1..e4c2c1d05 100644 --- a/packages/stack-encrypt/tests/term_bytes.rs +++ b/packages/stack-encrypt/tests/term_bytes.rs @@ -29,7 +29,9 @@ //! their width, text by the system ADR-0002's fold, behind cllw-ore's //! marker byte) and encrypted under a column salt that binds the scheme //! and the target. Terms written by 0.1.0 and 0.2.0 do not compare with -//! these (the CHANGELOG says so). +//! these (the CHANGELOG says so). Floats are ordered by the same path: +//! `F64`'s encoding folds `-0.0` into `0.0` and every NaN into one, which +//! cllw-ore's own float encoding did not. use stack_encrypt::nonempty; use stack_encrypt::sem::{DefaultMatch, MatchConfig, MatchOptions}; @@ -253,3 +255,140 @@ async fn text_with_no_fold_has_no_order_term() { .await .is_err()); } + +/// A float's order term: cllw-ore's encryption of its ADR-0002 order key, +/// the `f64` with zero signs and NaN payloads folded, under the column salt +/// of the `F64` target. Pins the key, the salt and the encryption. +#[tokio::test] +async fn float_order_term_bytes_are_pinned() { + let cipher = cipher().await; + let keyset = cipher.default_keyset(); + let ore = keyset + .ore_term(1.5f64, nonempty!("users/score")) + .await + .unwrap(); + let ope = keyset + .ope_term(1.5f64, nonempty!("users/score")) + .await + .unwrap(); + assert_eq!(hex(ore.as_ref()), "f591a4bf7663b9ffcaf021d87de0d10dad925b7663e33de47966c05dab4f76ff1e8bc5537162088c01be03b4db4d3fe980b111f46e7d4da445ec19d2f7445ef0"); + assert_eq!(hex(ope.as_ref()), "01501c6dd5f9a769811461eadfd54631ea58f3f051e7603117396add6c79e5fe4fd2f549c92f0afeaf94cd6c49fc33a9a0657c812132bea46779b2f19f10ce36ae"); +} + +/// Floats order as Postgres's `float8` does: `-0.0` ties `0.0`, every NaN is +/// one value, and it sorts above `+Infinity`. The ORE terms compare in that +/// order, and the OPE terms' bytes do. +#[tokio::test] +async fn float_order_terms_follow_the_semantic_baseline() { + let cipher = cipher().await; + let keyset = cipher.default_keyset(); + let ore = |value: f64| keyset.ore_term(value, nonempty!("users/score")); + let ope = |value: f64| keyset.ope_term(value, nonempty!("users/score")); + + assert_eq!(ore(-0.0).await.unwrap(), ore(0.0).await.unwrap()); + assert_eq!(ope(-0.0).await.unwrap(), ope(0.0).await.unwrap()); + let nans = [ + f64::NAN, + -f64::NAN, + f64::from_bits(0x7ff8_0000_0000_beef), + f64::from_bits(0x7ff0_0000_0000_0001), + ]; + for nan in nans { + assert_eq!( + ore(nan).await.unwrap(), + ore(f64::NAN).await.unwrap(), + "{nan:?}" + ); + assert_eq!( + ope(nan).await.unwrap(), + ope(f64::NAN).await.unwrap(), + "{nan:?}" + ); + } + + let ascending = [ + f64::NEG_INFINITY, + f64::MIN, + -1.5, + -f64::MIN_POSITIVE, + 0.0, + f64::MIN_POSITIVE, + 1.5, + f64::MAX, + f64::INFINITY, + f64::NAN, + ]; + for pair in ascending.windows(2) { + let (lo, hi) = (pair[0], pair[1]); + assert!( + ore(lo).await.unwrap() < ore(hi).await.unwrap(), + "ore: {lo:?} < {hi:?}" + ); + assert!( + ope(lo).await.unwrap().as_ref() < ope(hi).await.unwrap().as_ref(), + "ope: {lo:?} < {hi:?}" + ); + } +} + +/// An `f32` is ordered as the `f64` it widens to (ADR-0002): its target +/// is `F64`, and vitaminc widens it exactly, so its terms are the `f64`'s. +#[tokio::test] +async fn an_f32_is_ordered_as_the_f64_it_widens_to() { + let cipher = cipher().await; + let keyset = cipher.default_keyset(); + for value in [1.5f32, -0.0, f32::NAN, f32::MIN_POSITIVE, f32::INFINITY] { + let wide = f64::from(value); + assert_eq!( + keyset + .ore_term(value, nonempty!("users/score")) + .await + .unwrap(), + keyset + .ore_term(wide, nonempty!("users/score")) + .await + .unwrap(), + "ore {value:?}" + ); + assert_eq!( + keyset + .ope_term(value, nonempty!("users/score")) + .await + .unwrap(), + keyset + .ope_term(wide, nonempty!("users/score")) + .await + .unwrap(), + "ope {value:?}" + ); + } +} + +/// Every integer width of 16 bits or more is encoded at its own target: an +/// ORE term of one byte per bit of the width and an OPE term of one more, +/// each ordering as the values do, from the width's minimum through its +/// maximum. (A `u8` and an `i8` have no order term.) +#[tokio::test] +async fn every_integer_width_orders_by_its_own_key() { + let cipher = cipher().await; + let keyset = cipher.default_keyset(); + macro_rules! width { + ($($ty:ty),+) => {$({ + let values: [$ty; 3] = [<$ty>::MIN, <$ty>::MIN / 2 + <$ty>::MAX / 2 + 1, <$ty>::MAX]; + let bits = <$ty>::BITS as usize; + let mut ores = Vec::new(); + let mut opes = Vec::new(); + for value in values { + let ore = keyset.ore_term(value, nonempty!("users/n")).await.unwrap(); + let ope = keyset.ope_term(value, nonempty!("users/n")).await.unwrap(); + assert_eq!(ore.as_ref().len(), bits, "{} ore", stringify!($ty)); + assert_eq!(ope.as_ref().len(), bits + 1, "{} ope", stringify!($ty)); + ores.push(ore); + opes.push(ope.as_ref().to_vec()); + } + assert!(ores[0] < ores[1] && ores[1] < ores[2], "{} ore order", stringify!($ty)); + assert!(opes[0] < opes[1] && opes[1] < opes[2], "{} ope order", stringify!($ty)); + })+}; + } + width!(u16, u32, u64, u128, i16, i32, i64, i128); +} diff --git a/packages/stack-encrypt/tests/ui/op_between_takes_a_pair.stderr b/packages/stack-encrypt/tests/ui/op_between_takes_a_pair.stderr index b842b9929..a6c0d011e 100644 --- a/packages/stack-encrypt/tests/ui/op_between_takes_a_pair.stderr +++ b/packages/stack-encrypt/tests/ui/op_between_takes_a_pair.stderr @@ -41,23 +41,23 @@ error[E0271]: type mismatch resolving `<(u32, {integer}) as Input>::Plainte found tuple `(u32, {integer})` = note: required for `ValuePlan>` to implement `Chooses` -error[E0277]: `(u32, {integer})` is not ordered: the ordering operators are answered over the integers of 16 bits and wider, and text +error[E0277]: `(u32, {integer})` is not ordered: the ordering operators are answered over the integers of 16 bits and wider, the floats and text --> tests/ui/op_between_takes_a_pair.rs:13:48 | 13 | let _ = cipher.query(op::Lt, &(18u32, 65)).on(&age_plan); | ^^ no ordering operator over this type | = help: the trait `Ordered` is not implemented for `(u32, {integer})` - = note: `Lt`, `Lte`, `Gt`, `Gte` and `Between` take `u16` to `u128`, `i16` to `i128`, or a `String`; a `u8`, an `i8` and a `bool` have no order term + = note: `Lt`, `Lte`, `Gt`, `Gte` and `Between` take `u16` to `u128`, `i16` to `i128`, `f32`, `f64` or a `String`; a `u8`, an `i8` and a `bool` have no order term = help: the following other types implement trait `Ordered`: + f32 + f64 i128 i16 i32 i64 std::string::String u128 - u16 - u32 - u64 + and $N others = note: required for `stack_encrypt::op::Lt` to implement `DefaultIndex<(u32, {integer})>` = note: required for `ValuePlan>` to implement `Chooses` diff --git a/packages/stack-encrypt/tests/ui/op_order_on_bytes.rs b/packages/stack-encrypt/tests/ui/op_order_on_bytes.rs index b72629e1f..a2cecbbf9 100644 --- a/packages/stack-encrypt/tests/ui/op_order_on_bytes.rs +++ b/packages/stack-encrypt/tests/ui/op_order_on_bytes.rs @@ -1,4 +1,4 @@ -//! The ordering operators are answered over the integer types and text. +//! The ordering operators are answered over the integer types, the floats and text. //! Bytes have no order key the ordering operators compare, so an ordering on //! a bytes plan does not compile, even where the plan declares an ORE index. use stack_encrypt::kms::FakeDataKeySource; diff --git a/packages/stack-encrypt/tests/ui/op_order_on_bytes.stderr b/packages/stack-encrypt/tests/ui/op_order_on_bytes.stderr index 307d0df29..870e876b1 100644 --- a/packages/stack-encrypt/tests/ui/op_order_on_bytes.stderr +++ b/packages/stack-encrypt/tests/ui/op_order_on_bytes.stderr @@ -1,20 +1,20 @@ -error[E0277]: `Vec` is not ordered: the ordering operators are answered over the integers of 16 bits and wider, and text +error[E0277]: `Vec` is not ordered: the ordering operators are answered over the integers of 16 bits and wider, the floats and text --> tests/ui/op_order_on_bytes.rs:13:49 | 13 | let _ = cipher.query(op::Lt, &vec![1u8, 2]).on(&blob_plan); | ^^ no ordering operator over this type | = help: the trait `Ordered` is not implemented for `Vec` - = note: `Lt`, `Lte`, `Gt`, `Gte` and `Between` take `u16` to `u128`, `i16` to `i128`, or a `String`; a `u8`, an `i8` and a `bool` have no order term + = note: `Lt`, `Lte`, `Gt`, `Gte` and `Between` take `u16` to `u128`, `i16` to `i128`, `f32`, `f64` or a `String`; a `u8`, an `i8` and a `bool` have no order term = help: the following other types implement trait `Ordered`: + f32 + f64 i128 i16 i32 i64 std::string::String u128 - u16 - u32 - u64 + and $N others = note: required for `stack_encrypt::op::Lt` to implement `DefaultIndex>` = note: required for `ValuePlan, Indexed>` to implement `Chooses, Whole>`