diff --git a/.release-notes/next-release.md b/.release-notes/next-release.md index 7983c96..23d2676 100644 --- a/.release-notes/next-release.md +++ b/.release-notes/next-release.md @@ -203,3 +203,40 @@ actor MyReceiver is ResultReceiver session.close() ``` +## Add equality comparison for Field + +`Field` now implements `Equatable`, enabling `==` and `!=` comparisons. A `Field` holds a column name and a typed value. Two fields are equal when they have the same name and the same value — the values must be the same type and compare equal using that type's own equality. + +```pony +Field("id", I32(42)) == Field("id", I32(42)) // true +Field("id", I32(42)) == Field("id", I64(42)) // false — different types +Field("id", I32(42)) == Field("name", I32(42)) // false — different names +``` + +## Add equality comparison for Row + +`Row` now implements `Equatable`, enabling `==` and `!=` comparisons. A `Row` holds an ordered sequence of `Field` values representing a single result row. Two rows are equal when they have the same number of fields and each corresponding pair of fields is equal. Field order matters — the same fields in a different order are not equal. + +```pony +let r1 = Row(recover val [Field("id", I32(1)); Field("name", "Alice")] end) +let r2 = Row(recover val [Field("id", I32(1)); Field("name", "Alice")] end) +r1 == r2 // true + +let r3 = Row(recover val [Field("name", "Alice"); Field("id", I32(1))] end) +r1 == r3 // false — same fields, different order +``` + +## Add equality comparison for Rows + +`Rows` now implements `Equatable`, enabling `==` and `!=` comparisons. A `Rows` holds an ordered collection of `Row` values representing a query result set. Two `Rows` are equal when they have the same number of rows and each corresponding pair of rows is equal. Row order matters — the same rows in a different order are not equal. + +```pony +let rs1 = Rows(recover val + [Row(recover val [Field("id", I32(1))] end)] +end) +let rs2 = Rows(recover val + [Row(recover val [Field("id", I32(1))] end)] +end) +rs1 == rs2 // true +``` + diff --git a/CHANGELOG.md b/CHANGELOG.md index e9abebb..70c0d21 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -17,6 +17,9 @@ All notable changes to this project will be documented in this file. This projec - Add named prepared statement support ([PR #78](https://github.com/ponylang/postgres/pull/78)) - Add SSL/TLS negotiation support ([PR #79](https://github.com/ponylang/postgres/pull/79)) - Enable follow-up queries from ResultReceiver and PrepareReceiver callbacks ([PR #84](https://github.com/ponylang/postgres/pull/84)) +- Add equality comparison for Field ([PR #85](https://github.com/ponylang/postgres/pull/85)) +- Add equality comparison for Row ([PR #85](https://github.com/ponylang/postgres/pull/85)) +- Add equality comparison for Rows ([PR #85](https://github.com/ponylang/postgres/pull/85)) ### Changed diff --git a/CLAUDE.md b/CLAUDE.md index 542602d..ff1ca23 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -128,6 +128,11 @@ Tests live in the main `postgres/` package (private test classes). - `_TestSSLNegotiationRefused` — mock server responds 'N' to SSLRequest; verifies `pg_session_connection_failed` fires - `_TestSSLNegotiationJunkResponse` — mock server responds with junk byte to SSLRequest; verifies session shuts down - `_TestSSLNegotiationSuccess` — mock server responds 'S', both sides upgrade to TLS, sends AuthOk+ReadyForQuery; verifies full SSL→auth flow +- `_TestField*Equality*` / `_TestFieldInequality` — example-based reflexive, structural, symmetric equality and inequality tests for Field +- `_TestRowEquality` / `_TestRowInequality` — example-based equality and inequality tests for Row +- `_TestRowsEquality` / `_TestRowsInequality` — example-based equality and inequality tests for Rows +- `_TestField*Property` — PonyCheck property tests for Field reflexive, structural, and symmetric equality +- `_TestRowReflexiveProperty` / `_TestRowsReflexiveProperty` — PonyCheck property tests for Row/Rows reflexive equality **Integration tests** (require PostgreSQL, names prefixed `integration/`): - Connect, ConnectFailure, Authenticate, AuthenticateFailure @@ -145,7 +150,6 @@ Test helpers: `_ConnectionTestConfiguration` reads env vars with defaults. Sever ## Known Issues and TODOs in Code -- `rows.pony:43` — TODO: need tests for Rows/Row/Field (requires implementing `eq`) - `_test_response_parser.pony:6` — TODO: chain-of-messages tests to verify correct buffer advancement across message sequences ## Roadmap @@ -293,7 +297,7 @@ Can arrive between any other messages (must always handle): ## File Layout ``` -postgres/ # Main package (29 files) +postgres/ # Main package (30 files) session.pony # Session actor + state machine traits + query sub-state machine ssl_mode.pony # SSLDisabled, SSLRequired, SSLMode types simple_query.pony # SimpleQuery class @@ -323,6 +327,7 @@ postgres/ # Main package (29 files) _test_query.pony # Query integration tests _test_response_parser.pony # Parser unit tests + test message builders _test_frontend_message.pony # Frontend message unit tests + _test_equality.pony # Equality tests for Field/Row/Rows (example + PonyCheck property) assets/test-cert.pem # Self-signed test certificate for SSL unit tests assets/test-key.pem # Private key for SSL unit tests examples/README.md # Examples overview diff --git a/postgres/_test.pony b/postgres/_test.pony index ad0a4e5..589101f 100644 --- a/postgres/_test.pony +++ b/postgres/_test.pony @@ -2,6 +2,7 @@ use "cli" use "collections" use "files" use lori = "lori" +use "pony_check" use "pony_test" use "ssl/net" @@ -87,6 +88,20 @@ actor \nodoc\ Main is TestList test(_TestSSLAuthenticate) test(_TestSSLQueryResults) test(_TestSSLRefused) + test(_TestFieldEqualityReflexive) + test(_TestFieldEqualityStructural) + test(_TestFieldEqualitySymmetric) + test(_TestFieldInequality) + test(_TestRowEquality) + test(_TestRowInequality) + test(_TestRowsEquality) + test(_TestRowsInequality) + test(Property1UnitTest[Field](_TestFieldReflexiveProperty)) + test(Property1UnitTest[FieldDataTypes](_TestFieldStructuralProperty)) + test(Property1UnitTest[(FieldDataTypes, FieldDataTypes)]( + _TestFieldSymmetricProperty)) + test(Property1UnitTest[Row](_TestRowReflexiveProperty)) + test(Property1UnitTest[Rows](_TestRowsReflexiveProperty)) class \nodoc\ iso _TestAuthenticate is UnitTest """ diff --git a/postgres/_test_equality.pony b/postgres/_test_equality.pony new file mode 100644 index 0000000..8118f5c --- /dev/null +++ b/postgres/_test_equality.pony @@ -0,0 +1,313 @@ +use "collections" +use "pony_check" +use "pony_test" + +class \nodoc\ iso _TestFieldEqualityReflexive is UnitTest + """ + Every FieldDataTypes variant produces a Field that is equal to itself. + Covers all 8 variants of the FieldDataTypes union to verify each match + branch in Field.eq. + """ + fun name(): String => "Field/Equality/Reflexive" + + fun apply(h: TestHelper) => + let fields: Array[Field] val = [ + Field("b", true) + Field("f32", F32(1.5)) + Field("f64", F64(2.5)) + Field("i16", I16(16)) + Field("i32", I32(32)) + Field("i64", I64(64)) + Field("none", None) + Field("str", "hello") + ] + for f in fields.values() do + h.assert_true(f == f) + end + +class \nodoc\ iso _TestFieldEqualityStructural is UnitTest + """ + Two independently constructed Fields with the same name and value are equal. + Covers all 8 variants of the FieldDataTypes union. + """ + fun name(): String => "Field/Equality/Structural" + + fun apply(h: TestHelper) => + h.assert_true(Field("a", true) == Field("a", true)) + h.assert_true(Field("a", F32(1.5)) == Field("a", F32(1.5))) + h.assert_true(Field("a", F64(2.5)) == Field("a", F64(2.5))) + h.assert_true(Field("a", I16(16)) == Field("a", I16(16))) + h.assert_true(Field("a", I32(32)) == Field("a", I32(32))) + h.assert_true(Field("a", I64(64)) == Field("a", I64(64))) + h.assert_true(Field("a", None) == Field("a", None)) + h.assert_true(Field("a", "hello") == Field("a", "hello")) + +class \nodoc\ iso _TestFieldEqualitySymmetric is UnitTest + """ + Field equality is symmetric: if a == b then b == a, and if a != b then + b != a. Tests across different value types. + """ + fun name(): String => "Field/Equality/Symmetric" + + fun apply(h: TestHelper) => + // Equal pairs: a == b and b == a + let f1 = Field("x", I32(42)) + let f2 = Field("x", I32(42)) + h.assert_true(f1.eq(f2) == f2.eq(f1)) + + // Unequal pairs: different types + let f3 = Field("x", I32(42)) + let f4 = Field("x", "42") + h.assert_true(f3.eq(f4) == f4.eq(f3)) + + // Unequal pairs: different names + let f5 = Field("x", I32(42)) + let f6 = Field("y", I32(42)) + h.assert_true(f5.eq(f6) == f6.eq(f5)) + + // None vs non-None + let f7 = Field("x", None) + let f8 = Field("x", I32(0)) + h.assert_true(f7.eq(f8) == f8.eq(f7)) + +class \nodoc\ iso _TestFieldInequality is UnitTest + fun name(): String => "Field/Inequality" + + fun apply(h: TestHelper) => + // Different names, same value + h.assert_false(Field("a", I32(42)) == Field("b", I32(42))) + + // Same name, different values of same type + h.assert_false(Field("a", I32(42)) == Field("a", I32(43))) + h.assert_false(Field("a", "hello") == Field("a", "world")) + h.assert_false(Field("a", true) == Field("a", false)) + + // Same name, different value types + h.assert_false(Field("a", I32(42)) == Field("a", "42")) + h.assert_false(Field("a", I32(42)) == Field("a", I64(42))) + h.assert_false(Field("a", F32(1.0)) == Field("a", F64(1.0))) + h.assert_false(Field("a", I16(1)) == Field("a", I32(1))) + + // None vs non-None + h.assert_false(Field("a", None) == Field("a", I32(0))) + h.assert_false(Field("a", I32(0)) == Field("a", None)) + + +class \nodoc\ iso _TestRowEquality is UnitTest + fun name(): String => "Row/Equality" + + fun apply(h: TestHelper) => + // Empty rows are equal + let empty1 = Row(recover val Array[Field] end) + let empty2 = Row(recover val Array[Field] end) + h.assert_true(empty1 == empty2) + + // Reflexive + let r1 = Row(recover val + [Field("a", I32(1)); Field("b", "hello")] + end) + h.assert_true(r1 == r1) + + // Structural equality: same content, independent construction + let r2 = Row(recover val + [Field("a", I32(1)); Field("b", "hello")] + end) + let r3 = Row(recover val + [Field("a", I32(1)); Field("b", "hello")] + end) + h.assert_true(r2 == r3) + +class \nodoc\ iso _TestRowInequality is UnitTest + fun name(): String => "Row/Inequality" + + fun apply(h: TestHelper) => + // Different sizes + let r1 = Row(recover val [Field("a", I32(1))] end) + let r2 = Row(recover val + [Field("a", I32(1)); Field("b", I32(2))] + end) + h.assert_false(r1 == r2) + + // Same size, different content + let r3 = Row(recover val [Field("a", I32(1))] end) + let r4 = Row(recover val [Field("a", I32(2))] end) + h.assert_false(r3 == r4) + +class \nodoc\ iso _TestRowsEquality is UnitTest + fun name(): String => "Rows/Equality" + + fun apply(h: TestHelper) => + // Empty Rows are equal + let empty1 = Rows(recover val Array[Row] end) + let empty2 = Rows(recover val Array[Row] end) + h.assert_true(empty1 == empty2) + + // Reflexive + let rs1 = Rows(recover val + [Row(recover val [Field("a", I32(1))] end)] + end) + h.assert_true(rs1 == rs1) + + // Structural equality + let rs2 = Rows(recover val + [Row(recover val [Field("a", I32(1))] end)] + end) + let rs3 = Rows(recover val + [Row(recover val [Field("a", I32(1))] end)] + end) + h.assert_true(rs2 == rs3) + +class \nodoc\ iso _TestRowsInequality is UnitTest + fun name(): String => "Rows/Inequality" + + fun apply(h: TestHelper) => + // Different sizes + let rs1 = Rows(recover val + [Row(recover val [Field("a", I32(1))] end)] + end) + let rs2 = Rows(recover val Array[Row] end) + h.assert_false(rs1 == rs2) + + // Different content + let rs3 = Rows(recover val + [Row(recover val [Field("a", I32(1))] end)] + end) + let rs4 = Rows(recover val + [Row(recover val [Field("a", I32(2))] end)] + end) + h.assert_false(rs3 == rs4) + +// -- Generators -- + +primitive \nodoc\ _FieldDataTypesGen + fun apply(): Generator[FieldDataTypes] => + Generators.frequency[FieldDataTypes]([ + (1, Generators.bool().map[FieldDataTypes]({(v) => v })) + (1, Generators.i32().map[FieldDataTypes]({(v) => F32.from[I32](v) })) + (1, Generators.i64().map[FieldDataTypes]({(v) => F64.from[I64](v) })) + (1, Generators.i16().map[FieldDataTypes]({(v) => v })) + (1, Generators.i32().map[FieldDataTypes]({(v) => v })) + (1, Generators.i64().map[FieldDataTypes]({(v) => v })) + (1, Generators.unit[None](None).map[FieldDataTypes]({(v) => v })) + (1, Generators.ascii_printable(0, 20) + .map[FieldDataTypes]({(v) => v })) + ]) + +primitive \nodoc\ _FieldGen + fun apply(): Generator[Field] => + Generators.map2[String, FieldDataTypes, Field]( + Generators.ascii_printable(1, 10), + _FieldDataTypesGen(), + {(name, value) => Field(name, value) }) + +primitive \nodoc\ _RowGen + fun apply(): Generator[Row] => + Generator[Row](object is GenObj[Row] + fun generate(rnd: Randomness): Row => + let size = rnd.usize(0, 5) + let fields = recover iso Array[Field](size) end + for i in Range(0, size) do + fields.push(Field("f" + i.string(), + _random_field_value(rnd))) + end + Row(consume fields) + + fun _random_field_value(rnd: Randomness): FieldDataTypes => + match rnd.usize(0, 7) + | 0 => rnd.bool() + | 1 => F32.from[I32](rnd.i32()) + | 2 => F64.from[I64](rnd.i64()) + | 3 => rnd.i16() + | 4 => rnd.i32() + | 5 => rnd.i64() + | 6 => None + else + "str" + rnd.u32().string() + end + end) + +primitive \nodoc\ _RowsGen + fun apply(): Generator[Rows] => + Generator[Rows](object is GenObj[Rows] + fun generate(rnd: Randomness): Rows => + let size = rnd.usize(0, 3) + let rows = recover iso Array[Row](size) end + for i in Range(0, size) do + let field_count = rnd.usize(0, 5) + let fields = recover iso Array[Field](field_count) end + for j in Range(0, field_count) do + fields.push(Field("f" + j.string(), + _random_field_value(rnd))) + end + rows.push(Row(consume fields)) + end + Rows(consume rows) + + fun _random_field_value(rnd: Randomness): FieldDataTypes => + match rnd.usize(0, 7) + | 0 => rnd.bool() + | 1 => F32.from[I32](rnd.i32()) + | 2 => F64.from[I64](rnd.i64()) + | 3 => rnd.i16() + | 4 => rnd.i32() + | 5 => rnd.i64() + | 6 => None + else + "str" + rnd.u32().string() + end + end) + +// -- Property Tests -- + +class \nodoc\ iso _TestFieldReflexiveProperty is Property1[Field] + fun name(): String => "Field/Equality/Reflexive/Property" + + fun gen(): Generator[Field] => + _FieldGen() + + fun ref property(arg1: Field, h: PropertyHelper) => + h.assert_true(arg1 == arg1) + +class \nodoc\ iso _TestFieldStructuralProperty is Property1[FieldDataTypes] + fun name(): String => "Field/Equality/Structural/Property" + + fun gen(): Generator[FieldDataTypes] => + _FieldDataTypesGen() + + fun ref property(arg1: FieldDataTypes, h: PropertyHelper) => + h.assert_true(Field("x", arg1) == Field("x", arg1)) + +class \nodoc\ iso _TestFieldSymmetricProperty + is Property2[FieldDataTypes, FieldDataTypes] + fun name(): String => "Field/Equality/Symmetric/Property" + + fun gen1(): Generator[FieldDataTypes] => + _FieldDataTypesGen() + + fun gen2(): Generator[FieldDataTypes] => + _FieldDataTypesGen() + + fun ref property2(arg1: FieldDataTypes, arg2: FieldDataTypes, + h: PropertyHelper) + => + let f1 = Field("x", arg1) + let f2 = Field("x", arg2) + h.assert_true(f1.eq(f2) == f2.eq(f1)) + +class \nodoc\ iso _TestRowReflexiveProperty is Property1[Row] + fun name(): String => "Row/Equality/Reflexive/Property" + + fun gen(): Generator[Row] => + _RowGen() + + fun ref property(arg1: Row, h: PropertyHelper) => + h.assert_true(arg1 == arg1) + +class \nodoc\ iso _TestRowsReflexiveProperty is Property1[Rows] + fun name(): String => "Rows/Equality/Reflexive/Property" + + fun gen(): Generator[Rows] => + _RowsGen() + + fun ref property(arg1: Rows, h: PropertyHelper) => + h.assert_true(arg1 == arg1) diff --git a/postgres/field.pony b/postgres/field.pony index 74e407b..12d756b 100644 --- a/postgres/field.pony +++ b/postgres/field.pony @@ -1,7 +1,27 @@ -class val Field +class val Field is Equatable[Field] let name: String let value: FieldDataTypes new val create(name': String, value': FieldDataTypes) => name = name' value = value' + + fun eq(that: box->Field): Bool => + """ + Two fields are equal when they have the same name and the same value. + Values must be the same type and compare equal using the type's own + equality. + """ + if name != that.name then return false end + match (value, that.value) + | (let a: Bool, let b: Bool) => a == b + | (let a: F32, let b: F32) => a == b + | (let a: F64, let b: F64) => a == b + | (let a: I16, let b: I16) => a == b + | (let a: I32, let b: I32) => a == b + | (let a: I64, let b: I64) => a == b + | (None, None) => true + | (let a: String, let b: String) => a == b + else + false + end diff --git a/postgres/row.pony b/postgres/row.pony index feba0d9..dab8e70 100644 --- a/postgres/row.pony +++ b/postgres/row.pony @@ -1,5 +1,23 @@ -class val Row +class val Row is Equatable[Row] let fields: Array[Field] val new val create(fields': Array[Field] val) => fields = fields' + + fun eq(that: box->Row): Bool => + """ + Two rows are equal when they have the same number of fields and each + corresponding pair of fields is equal. Field order matters: the same + fields in a different order are not equal. + """ + if fields.size() != that.fields.size() then return false end + try + var i: USize = 0 + while i < fields.size() do + if fields(i)? != that.fields(i)? then return false end + i = i + 1 + end + true + else + false + end diff --git a/postgres/rows.pony b/postgres/rows.pony index 4485c11..dc9f4aa 100644 --- a/postgres/rows.pony +++ b/postgres/rows.pony @@ -1,4 +1,4 @@ -class val Rows +class val Rows is Equatable[Rows] let _rows: Array[Row] val new val create(rows': Array[Row] val) => @@ -16,6 +16,24 @@ class val Rows """ _rows(i)? + fun eq(that: box->Rows): Bool => + """ + Two Rows are equal when they have the same number of rows and each + corresponding pair of rows is equal. Row order matters: the same rows + in a different order are not equal. + """ + if size() != that.size() then return false end + try + var i: USize = 0 + while i < size() do + if apply(i)? != that(i)? then return false end + i = i + 1 + end + true + else + false + end + fun values(): RowIterator => """ Returns an iterator over the rows. @@ -40,10 +58,6 @@ class RowIterator is Iterator[Row] _i = 0 this -// TODO need tests for all this -// In order to easily test it though, we need to add a decent chunk of operators -// to be able to compare the structure of the objects. Really, we need 'eq' on -// 'Rows', 'Row' and 'Field'. primitive _RowsBuilder fun apply(rows': Array[Array[(String|None)] val] val, row_descriptions': Array[(String, U32)] val): Rows ?