Home Reference Source Test

src/modules/relexp.test.js

import reducer from './relexp';
import {
  exprFromSql,
  enableOptimization,
  disableOptimization,
  SUPPORTED_AGGREGATE_FUNCTIONS,
} from './relexp';

const parser = require('@michaelmior/js-sql-parser');

/** @test {relexp} */
it('converts a simple SELECT *', () => {
  const sql = parser.parse('SELECT * FROM foo');
  const action = exprFromSql(sql.value, {});
  expect(reducer({}, action)).toMatchObject({
    expr: {relation: 'foo'},
  });
});

/** @test {relexp} */
it('converts a simple projection', () => {
  const sql = parser.parse('SELECT bar FROM foo');
  const action = exprFromSql(sql.value, {foo: ['bar']});
  expect(reducer({}, action)).toMatchObject({
    expr: {
      projection: {
        arguments: {project: ['bar']},
        children: [{relation: 'foo'}],
      },
    },
  });
});

/** @test {relexp} */
it('converts a rename', () => {
  const sql = parser.parse('SELECT bar as baz FROM foo');
  const action = exprFromSql(sql.value, {foo: ['bar']});
  expect(reducer({}, action)).toMatchObject({
    expr: {
      rename: {
        arguments: {rename: {columns: {bar: 'baz'}}},
        children: [
          {
            projection: {
              arguments: {project: ['bar']},
              children: [{relation: 'foo'}],
            },
          },
        ],
      },
    },
  });
});

/** @test {relexp} */
it('converts a difference', () => {
  const sql = parser.parse('SELECT * FROM foo EXCEPT SELECT * FROM bar');
  const action = exprFromSql(sql.value, {});
  expect(reducer({}, action)).toMatchObject({
    expr: {
      except: {
        left: {relation: 'foo'},
        right: {relation: 'bar'},
        distinct: true,
      },
    },
  });
});

/** @test {relexp} */
it('converts an intersection', () => {
  const sql = parser.parse('SELECT * FROM foo INTERSECT SELECT * FROM bar');
  const action = exprFromSql(sql.value, {});
  expect(reducer({}, action)).toMatchObject({
    expr: {
      intersect: {
        left: {relation: 'foo'},
        right: {relation: 'bar'},
        distinct: true,
      },
    },
  });
});

/** @test {relexp} */
it('converts a distinct union', () => {
  const sql = parser.parse('SELECT * FROM foo UNION SELECT * FROM bar');
  const action = exprFromSql(sql.value, {});
  expect(reducer({}, action)).toMatchObject({
    expr: {
      union: {
        left: {relation: 'foo'},
        right: {relation: 'bar'},
        distinct: true,
      },
    },
  });
});

/** @test {relexp} */
it('converts a union', () => {
  const sql = parser.parse('SELECT * FROM foo UNION ALL SELECT * FROM bar');
  const action = exprFromSql(sql.value, {});
  expect(reducer({}, action)).toMatchObject({
    expr: {
      union: {
        left: {relation: 'foo'},
        right: {relation: 'bar'},
        distinct: false,
      },
    },
  });
});

/** @test {relexp} */
it('converts a union on two tables with the same column', () => {
  const sql = parser.parse('SELECT bar FROM foo UNION SELECT bar FROM baz');
  const action = exprFromSql(sql.value, {foo: ['bar'], baz: ['bar']});
  expect(reducer({}, action)).toMatchObject({
    expr: {
      union: {
        left: {
          projection: {
            arguments: {project: ['bar']},
            children: [{relation: 'foo'}],
          },
        },
        right: {
          projection: {
            arguments: {project: ['bar']},
            children: [{relation: 'baz'}],
          },
        },
        distinct: true,
      },
    },
  });
});

/** @test {relexp} */
it('converts a simple cross join', () => {
  const sql = parser.parse('SELECT * FROM foo JOIN bar');
  const action = exprFromSql(sql.value, {});
  expect(reducer({}, action)).toMatchObject({
    expr: {
      product: {
        left: {relation: 'foo'},
        right: {relation: 'bar'},
      },
    },
  });
});

/** @test {relexp} */
it('converts a join with a condition', () => {
  const sql = parser.parse('SELECT * FROM foo JOIN bar ON foo.baz = bar.corge');
  const action = exprFromSql(sql.value, {foo: ['baz'], bar: ['corge']});
  expect(reducer({}, action)).toMatchObject({
    expr: {
      join: {
        left: {relation: 'foo'},
        right: {relation: 'bar'},
        type: 'inner',
        condition: {cmp: {lhs: 'foo.baz', op: '$eq', rhs: 'bar.corge'}},
      },
    },
  });
});

/** @test {relexp} */
it('converts a left outer join with a condition', () => {
  const sql = parser.parse(
    'SELECT * FROM foo LEFT JOIN bar ON foo.baz = bar.corge'
  );
  const action = exprFromSql(sql.value, {foo: ['baz'], bar: ['corge']});
  expect(reducer({}, action)).toMatchObject({
    expr: {
      join: {
        left: {relation: 'foo'},
        right: {relation: 'bar'},
        type: 'left',
        condition: {cmp: {lhs: 'foo.baz', op: '$eq', rhs: 'bar.corge'}},
      },
    },
  });
});

/** @test {relexp} */
it('converts a right outer join with a condition', () => {
  const sql = parser.parse(
    'SELECT * FROM foo RIGHT JOIN bar ON foo.baz = bar.corge'
  );
  const action = exprFromSql(sql.value, {foo: ['baz'], bar: ['corge']});
  expect(reducer({}, action)).toMatchObject({
    expr: {
      join: {
        left: {relation: 'foo'},
        right: {relation: 'bar'},
        type: 'right',
        condition: {cmp: {lhs: 'foo.baz', op: '$eq', rhs: 'bar.corge'}},
      },
    },
  });
});

/** @test {relexp} */
it('converts a pre-optimized select-join statement with a condition', () => {
  const sql = parser.parse(
    'SELECT * FROM foo JOIN bar ON foo.baz = bar.corge WHERE foo.baz = 1'
  );
  const action = exprFromSql(sql.value, {foo: ['baz'], bar: ['corge']});
  expect(reducer({}, action)).toMatchObject({
    expr: {
      selection: {
        arguments: {
          select: {cmp: {lhs: 'foo.baz', op: '$eq', rhs: '1'}},
        },
        children: [
          {
            join: {
              condition: {cmp: {lhs: 'foo.baz', op: '$eq', rhs: 'bar.corge'}},
              left: {relation: 'foo'},
              right: {relation: 'bar'},
              type: 'inner',
            },
          },
        ],
      },
    },
  });
});

/** @test {relexp} */
it('converts a post-optimized select-join statement with a condition', () => {
  const sql = parser.parse(
    'SELECT * FROM Doctor JOIN Patient ON Doctor.id = Patient.primaryDoctor WHERE Patient.id = 1'
  );
  const action = exprFromSql(sql.value, {
    Doctor: ['id'],
    Patient: ['primaryDoctor', 'id'],
  });
  const draft = reducer({}, action);
  const optimizeAction = enableOptimization('join');
  expect(reducer(draft, optimizeAction)).toMatchObject({
    expr: {
      join: {
        condition: {
          cmp: {lhs: 'Doctor.id', op: '$eq', rhs: 'Patient.primaryDoctor'},
        },
        left: {
          selection: {
            arguments: {
              select: {cmp: {lhs: 'Patient.id', op: '$eq', rhs: '1'}},
            },
            children: [{relation: 'Patient'}],
          },
        },
        right: {relation: 'Doctor'},
        type: 'inner',
      },
    },
    unoptimizedExpr: {
      selection: {
        arguments: {
          select: {cmp: {lhs: 'Patient.id', op: '$eq', rhs: '1'}},
        },
        children: [
          {
            join: {
              condition: {
                cmp: {
                  lhs: 'Doctor.id',
                  op: '$eq',
                  rhs: 'Patient.primaryDoctor',
                },
              },
              left: {relation: 'Doctor'},
              right: {relation: 'Patient'},
              type: 'inner',
            },
          },
        ],
      },
    },
  });
});

/** @test {relexp} */
it('checks if a select-join statement with a condition converts back correctly after optimization', () => {
  const sql = parser.parse(
    'SELECT * FROM Patient JOIN Doctor ON Patient.primaryDoctor = Doctor.id WHERE Patient.id = 1'
  );
  const action = exprFromSql(sql.value, {
    Patient: ['primaryDoctor', 'id'],
    Doctor: ['id'],
  });
  const preOptDraft = reducer({}, action);
  const optimizeAction = enableOptimization('join');
  const draft = reducer(preOptDraft, optimizeAction);
  const disableOptimizeAction = disableOptimization();
  expect(reducer(draft, disableOptimizeAction)).toMatchObject(preOptDraft);
});

/** @test {relexp} */
it('converts a pre-optimized select-join statement with multiple conditions', () => {
  const sql = parser.parse(
    'SELECT * FROM foo JOIN bar ON foo.baz = bar.corge WHERE foo.baz > 4 AND foo.baz < 9 AND bar.corge > 1 AND bar.corge < 6'
  );
  const action = exprFromSql(sql.value, {foo: ['baz'], bar: ['corge']});
  expect(reducer({}, action)).toMatchObject({
    expr: {
      selection: {
        arguments: {
          select: {
            and: {
              clauses: [
                {cmp: {lhs: 'foo.baz', op: '$gt', rhs: '4'}},
                {cmp: {lhs: 'foo.baz', op: '$lt', rhs: '9'}},
                {cmp: {lhs: 'bar.corge', op: '$gt', rhs: '1'}},
                {cmp: {lhs: 'bar.corge', op: '$lt', rhs: '6'}},
              ],
            },
          },
        },
        children: [
          {
            join: {
              condition: {cmp: {lhs: 'foo.baz', op: '$eq', rhs: 'bar.corge'}},
              left: {relation: 'foo'},
              right: {relation: 'bar'},
              type: 'inner',
            },
          },
        ],
      },
    },
  });
});

/** @test {relexp} */
it('converts a post-optimized select-join statement with multiple conditions', () => {
  const sql = parser.parse(
    'SELECT * FROM Doctor JOIN Patient ON Doctor.id = Patient.primaryDoctor WHERE Doctor.id > 4 AND Doctor.id < 9 AND Patient.id > 1 AND Patient.id < 6'
  );
  const action = exprFromSql(sql.value, {
    Doctor: ['id'],
    Patient: ['id', 'primaryDoctor'],
  });
  const draft = reducer({}, action);
  const optimizeAction = enableOptimization('join');
  expect(reducer(draft, optimizeAction)).toMatchObject({
    expr: {
      join: {
        condition: {
          cmp: {lhs: 'Doctor.id', op: '$eq', rhs: 'Patient.primaryDoctor'},
        },
        left: {
          selection: {
            arguments: {
              select: {
                and: {
                  clauses: [
                    {cmp: {lhs: 'Doctor.id', op: '$gt', rhs: '4'}},
                    {cmp: {lhs: 'Doctor.id', op: '$lt', rhs: '9'}},
                  ],
                },
              },
            },
            children: [{relation: 'Doctor'}],
          },
        },
        right: {
          selection: {
            arguments: {
              select: {
                and: {
                  clauses: [
                    {cmp: {lhs: 'Patient.id', op: '$gt', rhs: '1'}},
                    {cmp: {lhs: 'Patient.id', op: '$lt', rhs: '6'}},
                  ],
                },
              },
            },
            children: [{relation: 'Patient'}],
          },
        },
        type: 'inner',
      },
    },
    unoptimizedExpr: {
      selection: {
        arguments: {
          select: {
            and: {
              clauses: [
                {cmp: {lhs: 'Doctor.id', op: '$gt', rhs: '4'}},
                {cmp: {lhs: 'Doctor.id', op: '$lt', rhs: '9'}},
                {cmp: {lhs: 'Patient.id', op: '$gt', rhs: '1'}},
                {cmp: {lhs: 'Patient.id', op: '$lt', rhs: '6'}},
              ],
            },
          },
        },
        children: [
          {
            join: {
              condition: {
                cmp: {
                  lhs: 'Doctor.id',
                  op: '$eq',
                  rhs: 'Patient.primaryDoctor',
                },
              },
              left: {relation: 'Doctor'},
              right: {relation: 'Patient'},
              type: 'inner',
            },
          },
        ],
      },
    },
    optimized: true,
  });
});

/** @test {relexp} */
it('checks if a select-join statement with mutiple condition converts back correctly after optimization', () => {
  const sql = parser.parse(
    'SELECT * FROM Doctor JOIN Patient ON Doctor.id = Patient.primaryDoctor WHERE Patient.id > 4 AND Doctor.id < 9 AND Patient.primaryDoctor > 1 AND Doctor.id < 6'
  );
  const action = exprFromSql(sql.value, {
    Doctor: ['id'],
    Patient: ['id', 'primaryDoctor'],
  });
  const preOptDraft = reducer({}, action);
  const optimizeAction = enableOptimization('join');
  const draft = reducer(preOptDraft, optimizeAction);
  const disableOptimizeAction = disableOptimization();
  expect(reducer(draft, disableOptimizeAction)).toMatchObject(preOptDraft);
});

/** @test {relexp} */
it('converts a default (ascending) sorting', () => {
  const sql = parser.parse('SELECT * FROM foo ORDER BY bar');
  const action = exprFromSql(sql.value, {foo: ['bar']});
  expect(reducer({}, action)).toStrictEqual({
    expr: {
      order_by: {
        arguments: {
          order_by: [
            {
              ascending: true,
              column_name: 'bar',
            },
          ],
        },
        children: [{relation: 'foo'}],
      },
    },
  });
});

/** @test {relexp} */
it('converts a descending sorting', () => {
  const sql = parser.parse('SELECT * FROM foo ORDER BY bar DESC');
  const action = exprFromSql(sql.value, {foo: ['bar']});
  expect(reducer({}, action)).toStrictEqual({
    expr: {
      order_by: {
        arguments: {
          order_by: [
            {
              ascending: false,
              column_name: 'bar',
            },
          ],
        },
        children: [{relation: 'foo'}],
      },
    },
  });
});

/** @test {relexp} */
it('converts a sorting on multiple column conditions', () => {
  const sql = parser.parse('SELECT * FROM foo ORDER BY bar DESC, baz ASC');
  const action = exprFromSql(sql.value, {foo: ['bar', 'baz']});
  expect(reducer({}, action)).toStrictEqual({
    expr: {
      order_by: {
        arguments: {
          order_by: [
            {
              ascending: false,
              column_name: 'bar',
            },
            {
              ascending: true,
              column_name: 'baz',
            },
          ],
        },
        children: [{relation: 'foo'}],
      },
    },
  });
});

/** @test {relexp} */
it('converts a selection', () => {
  const sql = parser.parse('SELECT * FROM foo WHERE bar > 1');
  const action = exprFromSql(sql.value, {foo: ['bar']});
  expect(reducer({}, action)).toMatchObject({
    expr: {
      selection: {
        arguments: {select: {cmp: {lhs: 'bar', op: '$gt', rhs: '1'}}},
        children: [{relation: 'foo'}],
      },
    },
  });
});

/** @test {relexp} */
it('converts a selection with a literal on the left', () => {
  const sql = parser.parse('SELECT * FROM foo WHERE 1 > bar');
  const action = exprFromSql(sql.value, {foo: ['bar']});
  expect(reducer({}, action)).toMatchObject({
    expr: {
      selection: {
        arguments: {select: {cmp: {lhs: '1', op: '$gt', rhs: 'bar'}}},
        children: [{relation: 'foo'}],
      },
    },
  });
});

/** @test {relexp} */
it.each(['and', 'or'])('converts a selection with %s', (op) => {
  const sql = parser.parse(
    'SELECT * FROM foo WHERE bar > 1 ' + op + ' baz < 3'
  );
  const action = exprFromSql(sql.value, {foo: ['bar', 'baz']});
  expect(reducer({}, action)).toMatchObject({
    expr: {
      selection: {
        arguments: {
          select: {
            [op]: {
              clauses: [
                {cmp: {lhs: 'bar', op: '$gt', rhs: '1'}},
                {cmp: {lhs: 'baz', op: '$lt', rhs: '3'}},
              ],
            },
          },
        },
        children: [{relation: 'foo'}],
      },
    },
  });
});

/** @test {relexp} */
it('converts a selection with BETWEEN', () => {
  const sql = parser.parse('SELECT * FROM foo WHERE bar BETWEEN 1 AND 3');
  const action = exprFromSql(sql.value, {foo: ['bar']});
  expect(reducer({}, action)).toStrictEqual({
    expr: {
      selection: {
        arguments: {
          select: {
            and: {
              clauses: [
                {cmp: {lhs: 'bar', op: '$gte', rhs: '1'}},
                {cmp: {lhs: 'bar', op: '$lte', rhs: '3'}},
              ],
            },
          },
        },
        children: [{relation: 'foo'}],
      },
    },
  });
});

/** @test {relexp} */
it("converts a basic selection with multiple 'AND's", () => {
  const sql = parser.parse(
    'SELECT * FROM foo WHERE bar > 1 and bar < 3 and baz > 1 and baz < 3'
  );
  const action = exprFromSql(sql.value, {foo: ['bar', 'baz']});
  expect(reducer({}, action)).toMatchObject({
    expr: {
      selection: {
        arguments: {
          select: {
            and: {
              clauses: [
                {cmp: {lhs: 'bar', op: '$gt', rhs: '1'}},
                {cmp: {lhs: 'bar', op: '$lt', rhs: '3'}},
                {cmp: {lhs: 'baz', op: '$gt', rhs: '1'}},
                {cmp: {lhs: 'baz', op: '$lt', rhs: '3'}},
              ],
            },
          },
        },
        children: [{relation: 'foo'}],
      },
    },
  });
});

/** @test {relexp} */
it("converts a basic selection with both 'AND's in 'OR'", () => {
  const sql = parser.parse(
    'SELECT * FROM foo WHERE bar > 1 and baz < 3 or baz > 1 and bar < 3'
  );
  const action = exprFromSql(sql.value, {foo: ['bar', 'baz']});
  expect(reducer({}, action)).toMatchObject({
    expr: {
      selection: {
        arguments: {
          select: {
            or: {
              clauses: [
                {
                  and: {
                    clauses: [
                      {cmp: {lhs: 'bar', op: '$gt', rhs: '1'}},
                      {cmp: {lhs: 'baz', op: '$lt', rhs: '3'}},
                    ],
                  },
                },
                {
                  and: {
                    clauses: [
                      {cmp: {lhs: 'baz', op: '$gt', rhs: '1'}},
                      {cmp: {lhs: 'bar', op: '$lt', rhs: '3'}},
                    ],
                  },
                },
              ],
            },
          },
        },
        children: [{relation: 'foo'}],
      },
    },
  });
});

/** @test {relexp} */
it("converts a basic selection with both 'OR's in 'AND'", () => {
  const sql = parser.parse(
    'SELECT * FROM foo WHERE bar > 1 or baz < 3 and baz > 1 or bar < 3'
  );
  const action = exprFromSql(sql.value, {foo: ['bar', 'baz']});
  expect(reducer({}, action)).toMatchObject({
    expr: {
      selection: {
        arguments: {
          select: {
            or: {
              clauses: [
                {
                  or: {
                    clauses: [
                      {cmp: {lhs: 'bar', op: '$gt', rhs: '1'}},
                      {
                        and: {
                          clauses: [
                            {cmp: {lhs: 'baz', op: '$lt', rhs: '3'}},
                            {cmp: {lhs: 'baz', op: '$gt', rhs: '1'}},
                          ],
                        },
                      },
                    ],
                  },
                },
                {cmp: {lhs: 'bar', op: '$lt', rhs: '3'}},
              ],
            },
          },
        },
        children: [{relation: 'foo'}],
      },
    },
  });
});

/** @test {relexp} */
it("converts a basic selection with both 'OR's in 'AND'", () => {
  const sql = parser.parse(
    'SELECT * FROM foo WHERE (bar > 1 or baz < 3) and (baz > 1 or bar < 3)'
  );
  const action = exprFromSql(sql.value, {foo: ['bar', 'baz']});
  expect(reducer({}, action)).toMatchObject({
    expr: {
      selection: {
        arguments: {
          select: {
            and: {
              clauses: [
                {
                  or: {
                    clauses: [
                      {cmp: {lhs: 'bar', op: '$gt', rhs: '1'}},
                      {cmp: {lhs: 'baz', op: '$lt', rhs: '3'}},
                    ],
                  },
                },
                {
                  or: {
                    clauses: [
                      {cmp: {lhs: 'baz', op: '$gt', rhs: '1'}},
                      {cmp: {lhs: 'bar', op: '$lt', rhs: '3'}},
                    ],
                  },
                },
              ],
            },
          },
        },
        children: [{relation: 'foo'}],
      },
    },
  });
});

/** @test {relexp} */
it("converts a selection with more than two 'AND' clauses", () => {
  const sql = parser.parse(
    'SELECT * FROM foo WHERE bar > 1 and baz < 3 and baz > 1'
  );
  const action = exprFromSql(sql.value, {foo: ['bar', 'baz']});
  expect(reducer({}, action)).toStrictEqual({
    expr: {
      selection: {
        arguments: {
          select: {
            and: {
              clauses: [
                {cmp: {lhs: 'bar', op: '$gt', rhs: '1'}},
                {cmp: {lhs: 'baz', op: '$lt', rhs: '3'}},
                {cmp: {lhs: 'baz', op: '$gt', rhs: '1'}},
              ],
            },
          },
        },
        children: [{relation: 'foo'}],
      },
    },
  });
});

/** @test {relexp} */
it('converts a selection with NOT', () => {
  const sql = parser.parse('SELECT * FROM foo WHERE NOT bar > 1');
  const action = exprFromSql(sql.value, {foo: ['bar', 'baz']});
  expect(reducer({}, action)).toMatchObject({
    expr: {
      selection: {
        arguments: {
          select: {
            not: {clause: {cmp: {lhs: 'bar', op: '$gt', rhs: '1'}}},
          },
        },
        children: [{relation: 'foo'}],
      },
    },
  });
});

/** @test {relexp} */
it('throws an error if a column is referenced in a table not joined', () => {
  const sql = parser.parse('SELECT baz.quux FROM foo');
  const action = exprFromSql(sql.value, {foo: ['bar'], baz: ['quux']});
  expect(() => reducer({}, action)).toThrow(
    'Table baz is not referenced in query'
  );
});

/** @test {relexp} */
it('throws an error if no FROM clause is given', () => {
  const sql = parser.parse('SELECT 0');
  const action = exprFromSql(sql.value, {});
  expect(() => reducer({}, action)).toThrow('A FROM clause must be specified.');
});

/** @test {relexp} */
it('should remove quotes from string literals', () => {
  const sql = parser.parse('SELECT * FROM foo WHERE bar = "baz"');
  const action = exprFromSql(sql.value, {foo: ['bar']});
  expect(reducer({}, action)).toMatchObject({
    expr: {
      selection: {
        arguments: {select: {cmp: {lhs: 'bar', op: '$eq', rhs: 'baz'}}},
        children: [{relation: 'foo'}],
      },
    },
  });
});

/** @test {relexp} */
it('should convert conditions with IN', () => {
  const sql = parser.parse('SELECT * FROM foo WHERE id IN (1, 2)');
  const action = exprFromSql(sql.value, {foo: ['id']});
  expect(reducer({}, action)).toMatchObject({
    expr: {
      selection: {
        arguments: {
          select: {
            or: {
              clauses: [
                {cmp: {lhs: 'id', op: '$eq', rhs: '1'}},
                {cmp: {lhs: 'id', op: '$eq', rhs: '2'}},
              ],
            },
          },
        },
        children: [{relation: 'foo'}],
      },
    },
  });
});

/** @test {relexp} */
it('should convert conditions with IN', () => {
  const sql = parser.parse('SELECT * FROM foo WHERE id NOT IN (1, 2)');
  const action = exprFromSql(sql.value, {foo: ['id']});
  expect(reducer({}, action)).toMatchObject({
    expr: {
      selection: {
        arguments: {
          select: {
            not: {
              clause: {
                or: {
                  clauses: [
                    {cmp: {lhs: 'id', op: '$eq', rhs: '1'}},
                    {cmp: {lhs: 'id', op: '$eq', rhs: '2'}},
                  ],
                },
              },
            },
          },
        },
        children: [{relation: 'foo'}],
      },
    },
  });
});

/** @test {relexp} */
it('converts a GROUP BY with aggregate only', () => {
  const sql = parser.parse(
    'SELECT MIN(salary) FROM Doctor GROUP BY departmentId'
  );
  const action = exprFromSql(sql.value, {Doctor: ['salary', 'departmentId']});
  expect(reducer({}, action)).toMatchObject({
    expr: {
      group_by: {
        arguments: {
          groupBy: ['departmentId'],
          aggregates: [
            {
              aggregate: {
                function: 'MIN',
                column: 'salary',
              },
            },
          ],
          selectColumns: [],
        },
        children: [{relation: 'Doctor'}],
      },
    },
  });
});

/** @test {relexp} */
it('converts a GROUP BY with mixed columns and aggregates', () => {
  const sql = parser.parse(
    'SELECT departmentId, MAX(salary) FROM Doctor GROUP BY departmentId'
  );
  const action = exprFromSql(sql.value, {Doctor: ['salary', 'departmentId']});
  expect(reducer({}, action)).toMatchObject({
    expr: {
      group_by: {
        arguments: {
          groupBy: ['departmentId'],
          aggregates: [
            {
              aggregate: {
                function: 'MAX',
                column: 'salary',
              },
            },
          ],
          selectColumns: ['departmentId'],
        },
        children: [{relation: 'Doctor'}],
      },
    },
  });
});

/** @test {relexp} */
it('converts a GROUP BY with multiple aggregates', () => {
  const sql = parser.parse(
    'SELECT MIN(salary), MAX(salary), AVG(salary) FROM Doctor GROUP BY departmentId'
  );
  const action = exprFromSql(sql.value, {Doctor: ['salary', 'departmentId']});
  expect(reducer({}, action)).toMatchObject({
    expr: {
      group_by: {
        arguments: {
          groupBy: ['departmentId'],
          aggregates: [
            {
              aggregate: {
                function: 'MIN',
                column: 'salary',
              },
            },
            {
              aggregate: {
                function: 'MAX',
                column: 'salary',
              },
            },
            {
              aggregate: {
                function: 'AVG',
                column: 'salary',
              },
            },
          ],
          selectColumns: [],
        },
        children: [{relation: 'Doctor'}],
      },
    },
  });
});

/** @test {relexp} */
it('converts aggregate without GROUP BY (implicit grouping)', () => {
  const sql = parser.parse('SELECT MIN(salary) FROM Doctor');
  const action = exprFromSql(sql.value, {Doctor: ['salary']});
  expect(reducer({}, action)).toMatchObject({
    expr: {
      group_by: {
        arguments: {
          groupBy: [],
          aggregates: [
            {
              aggregate: {
                function: 'MIN',
                column: 'salary',
              },
            },
          ],
          selectColumns: [],
        },
        children: [{relation: 'Doctor'}],
      },
    },
  });
});

/** @test {relexp} */
it('converts GROUP BY with qualified column names', () => {
  const sql = parser.parse(
    'SELECT departmentId, MIN(Doctor.salary) FROM Doctor GROUP BY Doctor.departmentId'
  );
  const action = exprFromSql(sql.value, {Doctor: ['salary', 'departmentId']});
  expect(reducer({}, action)).toMatchObject({
    expr: {
      group_by: {
        arguments: {
          groupBy: ['Doctor.departmentId'],
          aggregates: [
            {
              aggregate: {
                function: 'MIN',
                column: 'Doctor.salary',
              },
            },
          ],
          selectColumns: ['departmentId'],
        },
        children: [{relation: 'Doctor'}],
      },
    },
  });
});

/** @test {relexp} */
it('supports all aggregate functions', () => {
  // Test each supported aggregate function
  SUPPORTED_AGGREGATE_FUNCTIONS.forEach((functionName) => {
    const column = functionName === 'COUNT' ? '*' : 'salary';
    const sql = parser.parse(
      `SELECT ${functionName}(${column}) FROM Doctor GROUP BY departmentId`
    );
    const action = exprFromSql(sql.value, {Doctor: ['salary', 'departmentId']});

    expect(reducer({}, action)).toMatchObject({
      expr: {
        group_by: {
          arguments: {
            groupBy: ['departmentId'],
            aggregates: [
              {
                aggregate: {
                  function: functionName,
                  column: column,
                },
              },
            ],
            selectColumns: [],
          },
          children: [{relation: 'Doctor'}],
        },
      },
    });
  });
});

// GROUP BY Validation Tests
/** @test {relexp} */
it('throws error when non-aggregate column not in GROUP BY', () => {
  const sql = parser.parse(
    'SELECT id, MIN(salary) FROM Doctor GROUP BY departmentId'
  );
  const action = exprFromSql(sql.value, {
    Doctor: ['id', 'salary', 'departmentId'],
  });
  expect(() => reducer({}, action)).toThrow(
    "Column 'id' must appear in the GROUP BY clause or be used in an aggregate function"
  );
});

/** @test {relexp} */
it('throws error when ORDER BY column not in GROUP BY', () => {
  const sql = parser.parse(
    'SELECT departmentId, MIN(salary) FROM Doctor GROUP BY departmentId ORDER BY id'
  );
  const action = exprFromSql(sql.value, {
    Doctor: ['id', 'salary', 'departmentId'],
  });
  expect(() => reducer({}, action)).toThrow(
    "Column 'id' in ORDER BY clause must appear in the GROUP BY clause or be used in an aggregate function"
  );
});

/** @test {relexp} */
it('allows ORDER BY with GROUP BY column', () => {
  const sql = parser.parse(
    'SELECT departmentId, MIN(salary) FROM Doctor GROUP BY departmentId ORDER BY departmentId'
  );
  const action = exprFromSql(sql.value, {Doctor: ['salary', 'departmentId']});
  expect(reducer({}, action)).toMatchObject({
    expr: {
      order_by: {
        arguments: {
          order_by: [
            {
              ascending: true,
              column_name: 'departmentId',
            },
          ],
        },
        children: [
          {
            group_by: {
              arguments: {
                groupBy: ['departmentId'],
                aggregates: [
                  {
                    aggregate: {
                      function: 'MIN',
                      column: 'salary',
                    },
                  },
                ],
                selectColumns: ['departmentId'],
              },
              children: [{relation: 'Doctor'}],
            },
          },
        ],
      },
    },
  });
});

/** @test {relexp} */
it('handles qualified vs unqualified column name matching', () => {
  const sql = parser.parse(
    'SELECT departmentId, MIN(salary) FROM Doctor GROUP BY Doctor.departmentId'
  );
  const action = exprFromSql(sql.value, {Doctor: ['salary', 'departmentId']});
  expect(() => reducer({}, action)).not.toThrow();
});

/** @test {relexp} */
it('converts a GROUP BY with COUNT aggregate', () => {
  const sql = parser.parse(
    'SELECT COUNT(salary) FROM Doctor GROUP BY departmentId'
  );
  const action = exprFromSql(sql.value, {Doctor: ['salary', 'departmentId']});
  expect(reducer({}, action)).toMatchObject({
    expr: {
      group_by: {
        arguments: {
          groupBy: ['departmentId'],
          aggregates: [
            {
              aggregate: {
                function: 'COUNT',
                column: 'salary',
              },
            },
          ],
          selectColumns: [],
        },
      },
    },
  });
});

/** @test {relexp} */
it('converts a GROUP BY with STDEV aggregate', () => {
  const sql = parser.parse(
    'SELECT STDEV(salary) FROM Doctor GROUP BY departmentId'
  );
  const action = exprFromSql(sql.value, {Doctor: ['salary', 'departmentId']});
  expect(reducer({}, action)).toMatchObject({
    expr: {
      group_by: {
        arguments: {
          groupBy: ['departmentId'],
          aggregates: [
            {
              aggregate: {
                function: 'STDEV',
                column: 'salary',
              },
            },
          ],
          selectColumns: [],
        },
      },
    },
  });
});

/** @test {relexp} */
it('converts STDEV aggregate without GROUP BY (implicit grouping)', () => {
  const sql = parser.parse('SELECT STDEV(salary) FROM Doctor');
  const action = exprFromSql(sql.value, {Doctor: ['salary']});
  expect(reducer({}, action)).toMatchObject({
    expr: {
      group_by: {
        arguments: {
          groupBy: [],
          aggregates: [
            {
              aggregate: {
                function: 'STDEV',
                column: 'salary',
              },
            },
          ],
          selectColumns: [],
        },
        children: [{relation: 'Doctor'}],
      },
    },
  });
});

// HAVING Clause Tests

/** @test {relexp} */
it('converts a GROUP BY with HAVING clause using aggregate function', () => {
  const sql = parser.parse(
    'SELECT departmentId, COUNT(*) FROM Doctor GROUP BY departmentId HAVING COUNT(*) > 5'
  );
  const action = exprFromSql(sql.value, {
    Doctor: ['id', 'salary', 'departmentId'],
  });
  expect(reducer({}, action)).toMatchObject({
    expr: {
      selection: {
        arguments: {
          select: {
            cmp: {
              lhs: 'COUNT(*)',
              op: '$gt',
              rhs: '5',
            },
          },
        },
        children: [
          {
            group_by: {
              arguments: {
                groupBy: ['departmentId'],
                aggregates: [
                  {
                    aggregate: {
                      function: 'COUNT',
                      column: '*',
                    },
                  },
                ],
                selectColumns: ['departmentId'],
              },
              children: [{relation: 'Doctor'}],
            },
          },
        ],
      },
    },
  });
});

/** @test {relexp} */
it('converts a GROUP BY with HAVING clause using multiple aggregate functions', () => {
  const sql = parser.parse(
    'SELECT departmentId, COUNT(*), AVG(salary) FROM Doctor GROUP BY departmentId HAVING COUNT(*) > 3 AND AVG(salary) > 50000'
  );
  const action = exprFromSql(sql.value, {
    Doctor: ['id', 'salary', 'departmentId'],
  });
  expect(reducer({}, action)).toMatchObject({
    expr: {
      selection: {
        arguments: {
          select: {
            and: {
              clauses: [
                {
                  cmp: {
                    lhs: 'COUNT(*)',
                    op: '$gt',
                    rhs: '3',
                  },
                },
                {
                  cmp: {
                    lhs: 'AVG(salary)',
                    op: '$gt',
                    rhs: '50000',
                  },
                },
              ],
            },
          },
        },
        children: [
          {
            group_by: {
              arguments: {
                groupBy: ['departmentId'],
                aggregates: [
                  {
                    aggregate: {
                      function: 'COUNT',
                      column: '*',
                    },
                  },
                  {
                    aggregate: {
                      function: 'AVG',
                      column: 'salary',
                    },
                  },
                ],
                selectColumns: ['departmentId'],
              },
              children: [{relation: 'Doctor'}],
            },
          },
        ],
      },
    },
  });
});

/** @test {relexp} */
it('converts a GROUP BY with HAVING clause using GROUP BY column', () => {
  const sql = parser.parse(
    'SELECT departmentId, COUNT(*) FROM Doctor GROUP BY departmentId HAVING departmentId = 1'
  );
  const action = exprFromSql(sql.value, {
    Doctor: ['id', 'salary', 'departmentId'],
  });
  expect(reducer({}, action)).toMatchObject({
    expr: {
      selection: {
        arguments: {
          select: {
            cmp: {
              lhs: 'departmentId',
              op: '$eq',
              rhs: '1',
            },
          },
        },
        children: [
          {
            group_by: {
              arguments: {
                groupBy: ['departmentId'],
                aggregates: [
                  {
                    aggregate: {
                      function: 'COUNT',
                      column: '*',
                    },
                  },
                ],
                selectColumns: ['departmentId'],
              },
              children: [{relation: 'Doctor'}],
            },
          },
        ],
      },
    },
  });
});

/** @test {relexp} */
it('throws error when HAVING clause references column not in GROUP BY', () => {
  const sql = parser.parse(
    'SELECT departmentId, COUNT(*) FROM Doctor GROUP BY departmentId HAVING salary > 50000'
  );
  const action = exprFromSql(sql.value, {
    Doctor: ['id', 'salary', 'departmentId'],
  });
  expect(() => reducer({}, action)).toThrow(
    "Column 'salary' in HAVING clause must appear in the GROUP BY clause or be used in an aggregate function"
  );
});

/** @test {relexp} */
it('throws error when HAVING clause uses non-aggregate function', () => {
  const sql = parser.parse(
    'SELECT departmentId, COUNT(*) FROM Doctor GROUP BY departmentId HAVING UPPER(departmentId) = "CARDIOLOGY"'
  );
  const action = exprFromSql(sql.value, {
    Doctor: ['id', 'salary', 'departmentId'],
  });
  expect(() => reducer({}, action)).toThrow(
    "Function 'UPPER' is not allowed in HAVING clause"
  );
});

/** @test {relexp} */
it('converts HAVING with BETWEEN predicate using aggregate', () => {
  const sql = parser.parse(
    'SELECT departmentId, AVG(salary) FROM Doctor GROUP BY departmentId HAVING AVG(salary) BETWEEN 40000 AND 80000'
  );
  const action = exprFromSql(sql.value, {
    Doctor: ['id', 'salary', 'departmentId'],
  });
  expect(reducer({}, action)).toMatchObject({
    expr: {
      selection: {
        arguments: {
          select: {
            and: {
              clauses: [
                {
                  cmp: {
                    lhs: 'AVG(salary)',
                    op: '$gte',
                    rhs: '40000',
                  },
                },
                {
                  cmp: {
                    lhs: 'AVG(salary)',
                    op: '$lte',
                    rhs: '80000',
                  },
                },
              ],
            },
          },
        },
      },
    },
  });
});

/** @test {relexp} */
it('converts HAVING with OR condition using aggregates', () => {
  const sql = parser.parse(
    'SELECT departmentId, COUNT(*), MAX(salary) FROM Doctor GROUP BY departmentId HAVING COUNT(*) < 2 OR MAX(salary) > 100000'
  );
  const action = exprFromSql(sql.value, {
    Doctor: ['id', 'salary', 'departmentId'],
  });
  expect(reducer({}, action)).toMatchObject({
    expr: {
      selection: {
        arguments: {
          select: {
            or: {
              clauses: [
                {
                  cmp: {
                    lhs: 'COUNT(*)',
                    op: '$lt',
                    rhs: '2',
                  },
                },
                {
                  cmp: {
                    lhs: 'MAX(salary)',
                    op: '$gt',
                    rhs: '100000',
                  },
                },
              ],
            },
          },
        },
      },
    },
  });
});

/** @test {relexp} */
it('adds projection when HAVING uses aggregates not in SELECT', () => {
  const sql = parser.parse(
    'SELECT departmentId, MIN(salary) FROM Doctor GROUP BY departmentId HAVING COUNT(*) > 3'
  );
  const action = exprFromSql(sql.value, {
    Doctor: ['id', 'salary', 'departmentId'],
  });
  expect(reducer({}, action)).toMatchObject({
    expr: {
      projection: {
        arguments: {
          project: ['departmentId', 'MIN(salary)'],
        },
        children: [
          {
            selection: {
              arguments: {
                select: {
                  cmp: {
                    lhs: 'COUNT(*)',
                    op: '$gt',
                    rhs: '3',
                  },
                },
              },
              children: [
                {
                  group_by: {
                    arguments: {
                      groupBy: ['departmentId'],
                      aggregates: [
                        {
                          aggregate: {
                            function: 'MIN',
                            column: 'salary',
                          },
                        },
                        {
                          aggregate: {
                            function: 'COUNT',
                            column: '*',
                          },
                        },
                      ],
                      selectColumns: ['departmentId'],
                    },
                    children: [{relation: 'Doctor'}],
                  },
                },
              ],
            },
          },
        ],
      },
    },
  });
});

/** @test {relexp} */
it('converts a GROUP BY with HAVING clause using STDEV aggregate', () => {
  const sql = parser.parse(
    'SELECT departmentId, STDEV(salary) FROM Doctor GROUP BY departmentId HAVING STDEV(salary) > 5000'
  );
  const action = exprFromSql(sql.value, {Doctor: ['salary', 'departmentId']});
  expect(reducer({}, action)).toMatchObject({
    expr: {
      selection: {
        arguments: {
          select: {
            cmp: {
              lhs: 'STDEV(salary)',
              op: '$gt',
              rhs: '5000',
            },
          },
        },
        children: [
          {
            group_by: {
              arguments: {
                groupBy: ['departmentId'],
                aggregates: [
                  {
                    aggregate: {
                      function: 'STDEV',
                      column: 'salary',
                    },
                  },
                ],
                selectColumns: ['departmentId'],
              },
              children: [{relation: 'Doctor'}],
            },
          },
        ],
      },
    },
  });
});