ClickHouse-Encoder

 view release on metacpan or  search on metacpan

t/url-helpers.t  view on Meta::CPAN

is($hdr->{'X-ClickHouse-Key'},  'secret', 'password header');

# settings (hashref) -> ordered url params.
($url) = ClickHouse::Encoder::_http_url_headers(
    'select 1',
    settings => {
        max_execution_time => 30,
        max_memory_usage   => '10000000000',
    },
);
like($url, qr/max_execution_time=30/,                'settings: int passthrough');
like($url, qr/max_memory_usage=10000000000/,         'settings: large int as string');

# dedup_token -> insert_deduplication_token.
($url) = ClickHouse::Encoder::_http_url_headers(
    'insert into t format native',
    dedup_token => 'batch-2026-05-19-001',
);
like($url, qr/insert_deduplication_token=batch-2026-05-19-001/,
     'dedup_token: token under the documented param name');

# Empty-SQL form: select_blocks POSTs the SQL as body, so the URL must
# not contain '&query=' when sql is empty.
($url) = ClickHouse::Encoder::_http_url_headers('');
unlike($url, qr/&?query=/, 'empty SQL omits query param entirely');

# Special chars: settings keys/values are URI-encoded so injection of
# raw '&' or '=' can't smuggle additional params.
($url) = ClickHouse::Encoder::_http_url_headers(
    'select 1',
    settings => { 'foo&bar' => 'a=b&c=d' },
);
like($url, qr/foo%26bar=a%3Db%26c%3Dd/,
     'settings: key+value URI-escaped (no smuggling)');

# _decorate_response: surfaces query-id and parses the summary header.
{
    my $resp = {
        success => 1,
        status  => 200,
        headers => {
            'x-clickhouse-query-id' => 'qid-123',
            'x-clickhouse-server'   => '54429',
            'x-clickhouse-summary'  => '{"read_rows":"42","written_rows":"0","elapsed_ns":"1500"}',
        },
    };
    ClickHouse::Encoder::_decorate_response($resp);
    is($resp->{ch}{'query-id'},          'qid-123', 'query-id surfaced');
    is($resp->{ch}{server},              '54429',   'server revision surfaced');
    is($resp->{ch}{summary}{read_rows},  42,        'summary: read_rows parsed');
    is($resp->{ch}{summary}{elapsed_ns}, 1500,      'summary: elapsed_ns parsed');
}

# A response without any X-ClickHouse-* headers must not add a ch slot.
{
    my $resp = { success => 1, status => 200, headers => {} };
    ClickHouse::Encoder::_decorate_response($resp);
    ok(!exists $resp->{ch}, 'no ch slot when no CH headers present');
}

# X-ClickHouse-Progress repeats during a streaming query;
# HTTP::Tiny collapses repeated headers into an arrayref. The last
# snapshot is the final one - the most complete - so _decorate_response
# parses that one as $resp->{ch}{progress}.
{
    my $resp = {
        success => 1, status => 200,
        headers => {
            'x-clickhouse-progress' => [
                '{"read_rows":"100","total_rows_to_read":"1000"}',
                '{"read_rows":"500","total_rows_to_read":"1000"}',
                '{"read_rows":"1000","total_rows_to_read":"1000"}',
            ],
        },
    };
    ClickHouse::Encoder::_decorate_response($resp);
    is($resp->{ch}{progress}{read_rows}, 1000,
       'progress: arrayref of repeated headers -> last snapshot parsed');
    is($resp->{ch}{progress}{total_rows_to_read}, 1000,
       'progress: total_rows_to_read from the final snapshot');
}

# Single-string progress header also works (server sent only one).
{
    my $resp = {
        success => 1, status => 200,
        headers => {
            'x-clickhouse-progress' => '{"read_rows":"7"}',
        },
    };
    ClickHouse::Encoder::_decorate_response($resp);
    is($resp->{ch}{progress}{read_rows}, 7,
       'progress: single-string header is parsed verbatim');
}

# Endpoint guard: scheme/host/port are interpolated into URLs, so they
# must be validated before any HTTP request is built. Reject anything
# that could smuggle path or query material into the URL.
for my $bad (
    [ { scheme => 'file' },           qr/scheme.+http/,   'scheme: file rejected' ],
    [ { scheme => 'gopher' },         qr/scheme.+http/,   'scheme: gopher rejected' ],
    [ { host => 'a/b' },              qr/host/,           'host: slash rejected' ],
    [ { host => 'a:b' },              qr/host/,           'host: colon rejected' ],
    [ { host => 'a?b' },              qr/host/,           'host: query rejected' ],
    [ { host => '' },                 qr/host/,           'host: empty rejected' ],
    [ { port => 'abc' },              qr/port/,           'port: non-numeric rejected' ],
    [ { port => 0 },                  qr/port/,           'port: zero rejected' ],
    [ { port => -1 },                 qr/port/,           'port: negative rejected' ],
    [ { port => 70000 },              qr/port/,           'port: > 65535 rejected' ],
) {
    my ($opts, $re, $name) = @$bad;
    local $@;
    eval { ClickHouse::Encoder::_http_url_headers('select 1', %$opts) };
    like($@, $re, $name);
}

# Valid cases: http + https, default localhost:8123, integer string port.
for my $good (
    [ {},                            'default localhost:8123 accepted' ],
    [ { scheme => 'https' },         'https accepted' ],
    [ { port => '8443' },            'port as string-of-digits accepted' ],



( run in 0.775 second using v1.01-cache-2.11-cpan-140bd7fdf52 )