diff --git a/src/lib/validator.ts b/src/lib/validator.ts index ce3dd3f..4b7ac2a 100644 Binary files a/src/lib/validator.ts and b/src/lib/validator.ts differ diff --git a/tests/levels.test.ts b/tests/levels.test.ts index ae6ab9a..72ed5b4 100644 --- a/tests/levels.test.ts +++ b/tests/levels.test.ts @@ -3,7 +3,7 @@ import initSqlJs, { type Database } from 'sql.js'; import { levels } from '@/data/levels'; import { seedDatabase } from '@/lib/seed'; import { runReadOnlyQuery, assertReadOnlyQuery } from '@/lib/sqlRunner'; -import { compareResults, solutionRequiresOrder } from '@/lib/validator'; +import { compareResults, extractOrderKeyIndices, solutionRequiresOrder } from '@/lib/validator'; import type { Epoch, QueryResult } from '@/types'; let db: Database; @@ -52,6 +52,30 @@ describe.each(levels)('level $id — $title', (level) => { return; // seed scaffold doesn't even parse — that's fine } const orderMatters = level.orderMatters ?? solutionRequiresOrder(level.solutionQuery); - expect(compareResults(seedResult, expected, orderMatters)).not.toBeNull(); + const orderKeys = orderMatters + ? extractOrderKeyIndices(level.solutionQuery, expected.columns) + : null; + expect(compareResults(seedResult, expected, orderMatters, orderKeys)).not.toBeNull(); + }); +}); + +describe('order-only ties accept any tie order (level 20 regression)', () => { + it('accepts a correct query whose tied rows come out in a different order', () => { + const level = levels.find((l) => l.id === 20)!; + const userQuery = `SELECT + customer_name, + count(distinct account_id) as account_count + FROM customers c + JOIN accounts a ON c.customer_id = a.customer_id + GROUP BY customer_name +HAVING account_count > 1 + ORDER BY account_count desc`; + const expected = runReadOnlyQuery(db, level.solutionQuery); + const user = runReadOnlyQuery(db, userQuery); + const orderMatters = level.orderMatters ?? solutionRequiresOrder(level.solutionQuery); + const orderKeys = extractOrderKeyIndices(level.solutionQuery, expected.columns); + expect(orderMatters).toBe(true); + expect(orderKeys).toEqual([1]); + expect(compareResults(user, expected, orderMatters, orderKeys)).toBeNull(); }); }); diff --git a/tests/validator.test.ts b/tests/validator.test.ts index b5ebeca..ffc536a 100644 --- a/tests/validator.test.ts +++ b/tests/validator.test.ts @@ -1,5 +1,10 @@ import { describe, expect, it } from 'vitest'; -import { compareResults, solutionRequiresOrder, valuesEqual } from '@/lib/validator'; +import { + compareResults, + extractOrderKeyIndices, + solutionRequiresOrder, + valuesEqual, +} from '@/lib/validator'; import type { QueryResult } from '@/types'; const result = (columns: string[], values: unknown[][]): QueryResult => ({ columns, values }); @@ -75,6 +80,59 @@ describe('compareResults', () => { }); }); +describe('compareResults with ORDER BY tie groups', () => { + // ORDER BY cnt DESC: 'Ann'/'Bob' tie on cnt, 'Cy' does not. + const expected = result(['name', 'cnt'], [['Bob', 2], ['Ann', 2], ['Cy', 1]]); + + it('accepts any order among rows tied on the ORDER BY keys', () => { + const user = result(['name', 'cnt'], [['Ann', 2], ['Bob', 2], ['Cy', 1]]); + expect(compareResults(user, expected, true)).not.toBeNull(); + expect(compareResults(user, expected, true, [1])).toBeNull(); + }); + + it('rejects rows placed outside their tie group (wrong ORDER BY)', () => { + const user = result(['name', 'cnt'], [['Cy', 1], ['Ann', 2], ['Bob', 2]]); + expect(compareResults(user, expected, true, [1])).toContain('ORDER BY'); + }); + + it('rejects wrong values within a tie group', () => { + const user = result(['name', 'cnt'], [['Bob', 2], ['Zed', 2], ['Cy', 1]]); + expect(compareResults(user, expected, true, [1])).not.toBeNull(); + }); +}); + +describe('extractOrderKeyIndices', () => { + const columns = ['customer_name', 'account_count']; + + it('maps plain and qualified terms (with ASC/DESC) to column indices', () => { + expect( + extractOrderKeyIndices('SELECT ... ORDER BY account_count DESC', columns) + ).toEqual([1]); + expect( + extractOrderKeyIndices('SELECT ... ORDER BY c.customer_name, account_count ASC', columns) + ).toEqual([0, 1]); + }); + + it('supports positional terms and ignores a trailing LIMIT', () => { + expect(extractOrderKeyIndices('SELECT ... ORDER BY 2 DESC LIMIT 5', columns)).toEqual([1]); + }); + + it('ignores ORDER BY inside parentheses', () => { + expect( + extractOrderKeyIndices( + 'SELECT RANK() OVER (ORDER BY balance) AS r FROM t ORDER BY account_count', + columns + ) + ).toEqual([1]); + }); + + it('returns null when a term cannot be resolved to a column', () => { + expect(extractOrderKeyIndices('SELECT ... ORDER BY COUNT(*) DESC', columns)).toBeNull(); + expect(extractOrderKeyIndices('SELECT ... ORDER BY 3', columns)).toBeNull(); + expect(extractOrderKeyIndices('SELECT a FROM t', columns)).toBeNull(); + }); +}); + describe('solutionRequiresOrder', () => { it('detects a top-level ORDER BY', () => { expect(solutionRequiresOrder('SELECT a FROM t ORDER BY a')).toBe(true);