From 4cf302457e9c5987ef9509193df03d8ef8fd8dea Mon Sep 17 00:00:00 2001 From: Alec Garcia Date: Sat, 20 Jun 2026 12:15:55 -0400 Subject: [PATCH] fix(core): support PostgreSQL with deterministic ordering MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Several relationships had no explicit ORDER BY, so row order depended on the database engine. MySQL/InnoDB returns rows in clustered primary-key order by coincidence, but PostgreSQL — which is officially supported — returns heap order, which changes after an UPDATE. This surfaced as user-visible instability once running on PostgreSQL. - Cart::lines() and Order::lines() now order by id. Lunar relies on a stable line sequence: GenerateFingerprint reduces $cart->lines in iteration order, so an unstable order can change a cart's fingerprint after an unrelated update, not just shift the displayed order. - ProductVariant::values() now orders by position, with the pivot id as a deterministic tie-break across options. getOption() joins these values into the variant option label, which is snapshotted onto order lines, so the persisted value must be deterministic across engines. This follows the relationship-level ordering convention already used by Order::transactions(), ProductOption::values(), and AttributeGroup::attributes(). --- packages/core/src/Models/Cart.php | 2 +- packages/core/src/Models/Order.php | 2 +- packages/core/src/Models/ProductVariant.php | 4 ++- tests/core/Unit/Models/CartTest.php | 30 +++++++++++++++++++ tests/core/Unit/Models/OrderTest.php | 23 ++++++++++++++ tests/core/Unit/Models/ProductVariantTest.php | 21 +++++++++++++ 6 files changed, 79 insertions(+), 3 deletions(-) diff --git a/packages/core/src/Models/Cart.php b/packages/core/src/Models/Cart.php index f5197d1200..40f65a7e62 100644 --- a/packages/core/src/Models/Cart.php +++ b/packages/core/src/Models/Cart.php @@ -214,7 +214,7 @@ protected static function newFactory() public function lines(): HasMany { - return $this->hasMany(CartLine::modelClass(), 'cart_id', 'id'); + return $this->hasMany(CartLine::modelClass(), 'cart_id', 'id')->orderBy('id'); } public function currency(): BelongsTo diff --git a/packages/core/src/Models/Order.php b/packages/core/src/Models/Order.php index 62a73bf532..ebaac2fd61 100644 --- a/packages/core/src/Models/Order.php +++ b/packages/core/src/Models/Order.php @@ -98,7 +98,7 @@ public function cart(): BelongsTo public function lines(): HasMany { - return $this->hasMany(OrderLine::modelClass()); + return $this->hasMany(OrderLine::modelClass())->orderBy('id'); } public function physicalLines(): HasMany diff --git a/packages/core/src/Models/ProductVariant.php b/packages/core/src/Models/ProductVariant.php index 41adceb682..5e4ef7d43a 100644 --- a/packages/core/src/Models/ProductVariant.php +++ b/packages/core/src/Models/ProductVariant.php @@ -106,7 +106,9 @@ public function values(): BelongsToMany "{$prefix}product_option_value_product_variant", 'variant_id', 'value_id' - )->withTimestamps(); + )->withTimestamps() + ->orderBy('position') + ->orderByPivot('id'); } public function getPrices(): Collection diff --git a/tests/core/Unit/Models/CartTest.php b/tests/core/Unit/Models/CartTest.php index 18bab4d905..3c79ecd073 100644 --- a/tests/core/Unit/Models/CartTest.php +++ b/tests/core/Unit/Models/CartTest.php @@ -1330,3 +1330,33 @@ ->and($foundCart->id)->not->toBe($mergedCart->id) ->and($foundCart->merged_id)->toBeNull(); }); + +test('orders cart lines by id', function () { + $cart = Cart::factory()->create(); + + // Cart::lines() must carry an explicit ordering contract. Without an + // ORDER BY, row order is undefined by the SQL standard: MySQL/InnoDB + // returns clustered primary-key order by coincidence, but PostgreSQL + // returns heap order, which changes after an UPDATE. Lunar relies on a + // stable line sequence (e.g. GenerateFingerprint reduces $cart->lines in + // iteration order), so it must be deterministic across engines. + expect($cart->lines()->toBase()->orders) + ->toBe([['column' => 'id', 'direction' => 'asc']]); +}); + +test('can retrieve cart lines in ascending id order', function () { + $currency = Currency::factory()->create(); + + $cart = Cart::factory()->create([ + 'currency_id' => $currency->id, + ]); + + $lines = CartLine::factory() + ->count(5) + ->create(['cart_id' => $cart->id]); + + $expectedOrder = $lines->pluck('id')->sort()->values()->all(); + + expect($cart->load('lines')->lines->pluck('id')->all()) + ->toBe($expectedOrder); +}); diff --git a/tests/core/Unit/Models/OrderTest.php b/tests/core/Unit/Models/OrderTest.php index ead351e031..2196347d79 100644 --- a/tests/core/Unit/Models/OrderTest.php +++ b/tests/core/Unit/Models/OrderTest.php @@ -300,3 +300,26 @@ 'id' => $order->id, ]); }); + +test('orders order lines by id', function () { + $order = Order::factory()->create(); + + // Order::lines() must carry an explicit ordering contract so line order is + // deterministic across database engines. PostgreSQL does not return rows in + // insertion order without an ORDER BY, which shifts invoice/history display. + expect($order->lines()->toBase()->orders) + ->toBe([['column' => 'id', 'direction' => 'asc']]); +}); + +test('can retrieve order lines in ascending id order', function () { + $order = Order::factory()->create(); + + $lines = OrderLine::factory() + ->count(5) + ->create(['order_id' => $order->id]); + + $expectedOrder = $lines->pluck('id')->sort()->values()->all(); + + expect($order->load('lines')->lines->pluck('id')->all()) + ->toBe($expectedOrder); +}); diff --git a/tests/core/Unit/Models/ProductVariantTest.php b/tests/core/Unit/Models/ProductVariantTest.php index 373a68c14a..4e227c9203 100644 --- a/tests/core/Unit/Models/ProductVariantTest.php +++ b/tests/core/Unit/Models/ProductVariantTest.php @@ -8,6 +8,8 @@ use Lunar\Models\CustomerGroup; use Lunar\Models\Price; use Lunar\Models\Product; +use Lunar\Models\ProductOption; +use Lunar\Models\ProductOptionValue; use Lunar\Models\ProductVariant; use Lunar\Models\TaxClass; use Lunar\Models\TaxRate; @@ -277,3 +279,22 @@ expect($variant->fresh()->isPurchasable())->toBeFalse(); }); + +test('returns variant option values ordered by position', function () { + $variant = ProductVariant::factory()->create(); + $option = ProductOption::factory()->create(); + + // Attached deliberately out of position order. Without an explicit ORDER BY + // the pivot rows would come back in attachment order ([3, 1, 2]); the + // relationship must order by the option value's position so getOption() + // (snapshotted onto order lines) is deterministic across database engines. + $values = collect([3, 1, 2])->map(fn ($position) => ProductOptionValue::factory()->create([ + 'product_option_id' => $option->id, + 'position' => $position, + ])); + + $variant->values()->attach($values->pluck('id')->all()); + + expect($variant->load('values')->values->pluck('position')->all()) + ->toBe([1, 2, 3]); +});