mirror of
https://github.com/Kaelio/ktx.git
synced 2026-07-07 11:02:11 +02:00
109 lines
5.1 KiB
TypeScript
109 lines
5.1 KiB
TypeScript
|
|
import { describe, expect, it } from 'vitest';
|
||
|
|
import { KtxDuckDbDialect } from '../../../src/connectors/duckdb/dialect.js';
|
||
|
|
|
||
|
|
describe('KtxDuckDbDialect', () => {
|
||
|
|
const dialect = new KtxDuckDbDialect();
|
||
|
|
|
||
|
|
it('quotes identifiers with double quotes and escapes embedded quotes', () => {
|
||
|
|
expect(dialect.quoteIdentifier('order"s')).toBe('"order""s"');
|
||
|
|
});
|
||
|
|
|
||
|
|
it('maps integer types to number dimension', () => {
|
||
|
|
expect(dialect.mapToDimensionType('BIGINT')).toBe('number');
|
||
|
|
expect(dialect.mapToDimensionType('DOUBLE')).toBe('number');
|
||
|
|
});
|
||
|
|
|
||
|
|
it('maps timestamp types to time dimension', () => {
|
||
|
|
expect(dialect.mapToDimensionType('TIMESTAMP')).toBe('time');
|
||
|
|
expect(dialect.mapToDimensionType('DATE')).toBe('time');
|
||
|
|
});
|
||
|
|
|
||
|
|
it('maps text types to string dimension', () => {
|
||
|
|
expect(dialect.mapToDimensionType('VARCHAR')).toBe('string');
|
||
|
|
});
|
||
|
|
|
||
|
|
it('maps boolean types to boolean dimension', () => {
|
||
|
|
expect(dialect.mapToDimensionType('BOOLEAN')).toBe('boolean');
|
||
|
|
expect(dialect.mapToDimensionType('BOOL')).toBe('boolean');
|
||
|
|
});
|
||
|
|
|
||
|
|
it('falls back to string for an empty or unknown native type', () => {
|
||
|
|
expect(dialect.mapToDimensionType('')).toBe('string');
|
||
|
|
expect(dialect.mapToDimensionType('JSON')).toBe('string');
|
||
|
|
});
|
||
|
|
|
||
|
|
// The precedence ladder strips parameters before substring rules fire, so a
|
||
|
|
// parameterized DECIMAL still resolves through the numeric branch rather than
|
||
|
|
// the string fallback.
|
||
|
|
it('strips type parameters before resolving the dimension', () => {
|
||
|
|
expect(dialect.mapToDimensionType('DECIMAL(10,2)')).toBe('number');
|
||
|
|
expect(dialect.mapToDimensionType('VARCHAR(255)')).toBe('string');
|
||
|
|
});
|
||
|
|
|
||
|
|
// Types absent from the exact-match table still resolve via substring rules:
|
||
|
|
// TIMESTAMP_NS (time), UINT128/HUGEINT-like (number), and lowercase input.
|
||
|
|
it('resolves unlisted types through substring matching, case-insensitively', () => {
|
||
|
|
expect(dialect.mapToDimensionType('timestamp_ns')).toBe('time');
|
||
|
|
expect(dialect.mapToDimensionType('INT128')).toBe('number');
|
||
|
|
expect(dialect.mapToDimensionType(' double ')).toBe('number');
|
||
|
|
});
|
||
|
|
|
||
|
|
it('generates a limited sample query', () => {
|
||
|
|
expect(dialect.generateSampleQuery('"t"', 5)).toBe('SELECT * FROM "t" LIMIT 5');
|
||
|
|
});
|
||
|
|
|
||
|
|
it('quotes selected columns in a sample query', () => {
|
||
|
|
expect(dialect.generateSampleQuery('"t"', 5, ['a', 'b'])).toBe('SELECT "a", "b" FROM "t" LIMIT 5');
|
||
|
|
});
|
||
|
|
|
||
|
|
it('builds a non-null, non-blank column sample query', () => {
|
||
|
|
expect(dialect.generateColumnSampleQuery('"t"', 'email', 3)).toBe(
|
||
|
|
`SELECT "email" FROM "t" WHERE "email" IS NOT NULL AND TRIM(CAST("email" AS VARCHAR)) != '' LIMIT 3`,
|
||
|
|
);
|
||
|
|
});
|
||
|
|
|
||
|
|
// A degenerate sample percentage (<=0 or >=1) means "no sampling", so both the
|
||
|
|
// random filter and the TABLESAMPLE clause must collapse to an empty string.
|
||
|
|
it('returns empty sample clauses outside the (0,1) range and real clauses inside it', () => {
|
||
|
|
expect(dialect.getRandomSampleFilter(0)).toBe('');
|
||
|
|
expect(dialect.getRandomSampleFilter(1)).toBe('');
|
||
|
|
expect(dialect.getRandomSampleFilter(0.25)).toBe('RANDOM() < 0.25');
|
||
|
|
expect(dialect.getTableSampleClause(0)).toBe('');
|
||
|
|
expect(dialect.getTableSampleClause(0.1)).toBe('USING SAMPLE 10 PERCENT (bernoulli)');
|
||
|
|
});
|
||
|
|
|
||
|
|
// A type missing from the exact-match table but containing BOOL still resolves
|
||
|
|
// through the substring branch rather than the string fallback.
|
||
|
|
it('resolves a BOOL-substring type to boolean', () => {
|
||
|
|
expect(dialect.mapToDimensionType('MYBOOL')).toBe('boolean');
|
||
|
|
});
|
||
|
|
|
||
|
|
it('builds limit/offset, sample-value aggregation, and randomized cardinality clauses', () => {
|
||
|
|
expect(dialect.getLimitOffsetClause(10, 5)).toContain('LIMIT 10');
|
||
|
|
expect(dialect.getSampleValueAggregation('SELECT 1')).toContain('STRING_AGG');
|
||
|
|
expect(dialect.generateRandomizedCardinalitySampleQuery('"t"', 'c', 100)).toContain('USING SAMPLE 100 ROWS');
|
||
|
|
});
|
||
|
|
|
||
|
|
it('exposes profiling expressions and a null column-statistics query', () => {
|
||
|
|
expect(dialect.getNullCountExpression('c')).toBe('SUM(CASE WHEN c IS NULL THEN 1 ELSE 0 END)');
|
||
|
|
expect(dialect.getDistinctCountExpression('c')).toBe('COUNT(DISTINCT c)');
|
||
|
|
expect(dialect.textLengthExpression('c')).toBe('LENGTH(CAST(c AS VARCHAR))');
|
||
|
|
expect(dialect.castToText('c')).toBe('CAST(c AS VARCHAR)');
|
||
|
|
expect(dialect.mapDataType('BIGINT')).toBe('BIGINT');
|
||
|
|
expect(dialect.getTopClause(5)).toBe('');
|
||
|
|
expect(dialect.generateColumnStatisticsQuery('main', 't')).toBeNull();
|
||
|
|
});
|
||
|
|
|
||
|
|
// Guards the single-namespace (db=null) display shape: v1 introspects only
|
||
|
|
// `main`, so a display ref must round-trip as a bare table name. An ANSI shape
|
||
|
|
// would emit a 1-part name it then refuses to parse, breaking column lookups.
|
||
|
|
it('round-trips a single-namespace display ref and reports a 1-part column shape', () => {
|
||
|
|
const table = { catalog: null, db: null, name: 'orders' };
|
||
|
|
const display = dialect.formatDisplayRef(table);
|
||
|
|
expect(display).toBe('orders');
|
||
|
|
expect(dialect.parseDisplayRef(display)).toMatchObject({ name: 'orders' });
|
||
|
|
expect(dialect.columnDisplayTablePartCount()).toBe(1);
|
||
|
|
expect(dialect.formatTableName(table)).toBe('"orders"');
|
||
|
|
});
|
||
|
|
});
|