Skip to content

Commit

Permalink
Add types for query, remove type assertions and switch to inference w…
Browse files Browse the repository at this point in the history
…here possible (#26)

Co-authored-by: Cal Irvine <[email protected]>
  • Loading branch information
calirvine and Cal Irvine authored Feb 3, 2024
1 parent aaca36b commit 104f9db
Show file tree
Hide file tree
Showing 5 changed files with 199 additions and 63 deletions.
184 changes: 148 additions & 36 deletions app/analytics/query.ts
Original file line number Diff line number Diff line change
@@ -1,18 +1,19 @@
import { ColumnMappings } from './schema';
import { ColumnMappingToType, ColumnMappings } from './schema';

import dayjs from 'dayjs';
import utc from 'dayjs/plugin/utc';
import timezone from 'dayjs/plugin/timezone';
import invariant from '~/lib/utils';

dayjs.extend(utc)
dayjs.extend(timezone)

export interface AnalyticsQueryResultRow {
[key: string]: any
}
interface AnalyticsQueryResult {
interface AnalyticsQueryResult<SelectionSet extends AnalyticsQueryResultRow> {
meta: string,
data: AnalyticsQueryResultRow[],
data: SelectionSet[],
rows: number,
rows_before_limit_at_least: number
}
Expand All @@ -23,6 +24,24 @@ interface AnalyticsCountResult {
visitors: number
}

function testAnalyticsQueryResult<T extends Record<string, any>>(
result: any,
testRow: (row: any) => row is T
): result is AnalyticsQueryResult<T> {
return (
result.meta &&
typeof result.meta === 'string' &&
result.data &&
Array.isArray(result.data) &&
// Empty result set always passes, otherwise test the first row and be optimistic that the rest is fine
(result.data.length === 0 || testRow(result.data[0])) &&
result.rows &&
typeof result.rows === 'number' &&
result.rows_before_limit_at_least &&
typeof result.rows_before_limit_at_least === 'number'
);
}

/**
* Convert a Date object to YY-MM-DD HH:MM:SS
*/
Expand All @@ -47,10 +66,9 @@ function formatDateString(d: Date) {
* "2021-01-01 04:00:00": 0,
* ...
* }
*
*
* */
function generateEmptyRowsOverInterval(intervalType: string, daysAgo: number, tz?: string): [Date, any] {

function generateEmptyRowsOverInterval(intervalType: string, daysAgo: number, tz?: string): [Date, { [key: string]: number }] {
if (!tz) {
tz = 'Etc/UTC';
}
Expand Down Expand Up @@ -82,7 +100,7 @@ function generateEmptyRowsOverInterval(intervalType: string, daysAgo: number, tz

const startDateTime = localDateTime.toDate();

const initialRows: any = {};
const initialRows: { [key: string]: number } = {};

for (let i = startDateTime.getTime(); i < Date.now(); i += intervalMs) {
// get date as utc
Expand Down Expand Up @@ -130,15 +148,15 @@ export class AnalyticsEngineAPI {
}
}

async query(query: string): Promise<Response> {
async query(query: string) {
return fetch(this.defaultUrl, {
method: 'POST',
body: query,
headers: this.defaultHeaders,
});
}

async getViewsGroupedByInterval(siteId: string, intervalType: string, sinceDays: number, tz: string): Promise<any> {
async getViewsGroupedByInterval(siteId: string, intervalType: string, sinceDays: number, tz?: string) {
let intervalCount = 1;

// keeping this code here once we start allowing bigger intervals (e.g. intervals of 2 hours)
Expand Down Expand Up @@ -171,14 +189,38 @@ export class AnalyticsEngineAPI {
GROUP BY _bucket
ORDER BY _bucket ASC`;

const returnPromise = new Promise<any>((resolve, reject) => (async () => {
type SelectionSet = {
count: number;
_bucket: string;
bucket: string;
};

const returnPromise = new Promise<[string, number][]>((resolve, reject) => (async () => {
const response = await this.query(query);

if (!response.ok) {
reject(response.statusText);
}

const responseData = await response.json() as AnalyticsQueryResult;
const responseData = await response.json();

invariant(
testAnalyticsQueryResult(
responseData,
(
row
): row is AnalyticsQueryResult<SelectionSet>['data'][number] => {
return (
row &&
typeof row === 'object' &&
typeof row.count === 'number' &&
typeof row._bucket === 'string' &&
typeof row.bucket === 'string'
);
}
),
'getViewsGroupedByInterval response did not match expected result'
);

// note this query will return sparse data (i.e. only rows where count > 0)
// merge returnedRows with initial rows to fill in any gaps
Expand All @@ -191,7 +233,7 @@ export class AnalyticsEngineAPI {
}, initialRows);

// return as sorted array of tuples (i.e. [datetime, count])
const sortedRows = Object.entries(rowsByDateTime).sort((a: any, b: any) => {
const sortedRows = Object.entries(rowsByDateTime).sort((a, b) => {
if (a[0] < b[0]) return -1;
else if (a[0] > b[0]) return 1;
else return 0;
Expand All @@ -217,14 +259,39 @@ export class AnalyticsEngineAPI {
AND ${siteIdColumn} = '${siteId}'
GROUP BY isVisitor, isVisit
ORDER BY isVisitor, isVisit ASC`;

type SelectionSet = {
count: number;
isVisitor: ColumnMappingToType<typeof ColumnMappings.newVisitor>;
isVisit: ColumnMappingToType<typeof ColumnMappings.newSession>;
};

const returnPromise = new Promise<AnalyticsCountResult>((resolve, reject) => (async () => {
const response = await this.query(query);

if (!response.ok) {
reject(response.statusText);
}

const responseData = await response.json() as AnalyticsQueryResult;
const responseData = await response.json();

invariant(
testAnalyticsQueryResult(
responseData,
(
row
): row is AnalyticsQueryResult<SelectionSet>['data'][number] => {
return (
row &&
typeof row === 'object' &&
typeof row.count === 'number' &&
typeof row.isVisitor === 'number' &&
typeof row.isVisit === 'number'
);
}
),
'getCounts response did not match expected result'
);

const counts: AnalyticsCountResult = {
views: 0,
Expand All @@ -235,10 +302,10 @@ export class AnalyticsEngineAPI {
// NOTE: note it's possible to get no results, or half results (i.e. a row where isVisit=1 but
// no row where isVisit=0), so this code makes no assumption on number of results
responseData.data.forEach((row) => {
if (row.isVisit == 1) {
if (row.isVisit === 1) {
counts.visits += Number(row.count);
}
if (row.isVisitor == 1) {
if (row.isVisitor === 1) {
counts.visitors += Number(row.count);
}
counts.views += Number(row.count);
Expand All @@ -249,12 +316,12 @@ export class AnalyticsEngineAPI {
return returnPromise;
}

async getVisitorCountByColumn(siteId: string, column: string, sinceDays: number, limit?: number): Promise<any> {
async getVisitorCountByColumn(siteId: string, column: keyof typeof ColumnMappings, sinceDays: number, limit?: number) {
// defaults to 1 day if not specified
const interval = sinceDays || 1;
limit = limit || 10;

const _column: string = ColumnMappings[column];
const _column = ColumnMappings[column];
const query = `
SELECT ${_column}, SUM(_sample_interval) as count
FROM metricsDataset
Expand All @@ -265,47 +332,70 @@ export class AnalyticsEngineAPI {
ORDER BY count DESC
LIMIT ${limit}`;

const returnPromise = new Promise<any>((resolve, reject) => (async () => {
type SelectionSet = {
count: number;
[key: string]: ColumnMappingToType<typeof _column>;
};

return new Promise<[string | number, number][]>((resolve, reject) => (async () => {
const response = await this.query(query);

if (!response.ok) {
reject(response.statusText);
}

const responseData = await response.json() as AnalyticsQueryResult;
resolve(responseData.data.map((row) => {
const key = row[_column] === '' ? '(none)' : row[_column];
return [key, row['count']];
}));
const responseData = await response.json();
invariant(
testAnalyticsQueryResult(
responseData,
(
row
): row is AnalyticsQueryResult<SelectionSet>['data'][number] => {
return (
row &&
typeof row === 'object' &&
typeof row.count === 'number'
);
}
),
'getVisitorCountByColumn response did not match expected result'
);

resolve(
responseData.data.map((row) => {
const key =
row[_column] === '' ? '(none)' : row[_column];
return [key, row['count'] as number];
})
);
})());
return returnPromise;
}

async getCountByUserAgent(siteId: string, sinceDays: number): Promise<any> {
async getCountByUserAgent(siteId: string, sinceDays: number) {
return this.getVisitorCountByColumn(siteId, 'userAgent', sinceDays);
}

async getCountByCountry(siteId: string, sinceDays: number): Promise<any> {
async getCountByCountry(siteId: string, sinceDays: number) {
return this.getVisitorCountByColumn(siteId, 'country', sinceDays);
}

async getCountByReferrer(siteId: string, sinceDays: number): Promise<any> {
async getCountByReferrer(siteId: string, sinceDays: number) {
return this.getVisitorCountByColumn(siteId, 'referrer', sinceDays);
}

async getCountByPath(siteId: string, sinceDays: number): Promise<any> {
async getCountByPath(siteId: string, sinceDays: number) {
return this.getVisitorCountByColumn(siteId, 'path', sinceDays);
}

async getCountByBrowser(siteId: string, sinceDays: number): Promise<any> {
async getCountByBrowser(siteId: string, sinceDays: number) {
return this.getVisitorCountByColumn(siteId, 'browserName', sinceDays);
}

async getCountByDevice(siteId: string, sinceDays: number): Promise<any> {
async getCountByDevice(siteId: string, sinceDays: number) {
return this.getVisitorCountByColumn(siteId, 'deviceModel', sinceDays);
}

async getSitesOrderedByHits(sinceDays: number, limit?: number): Promise<any> {
async getSitesOrderedByHits(sinceDays: number, limit?: number) {
// defaults to 1 day if not specified
const interval = sinceDays || 1;
limit = limit || 10;
Expand All @@ -319,22 +409,44 @@ export class AnalyticsEngineAPI {
ORDER BY count DESC
LIMIT ${limit}
`;
const returnPromise = new Promise<any>((resolve, reject) => (async () => {

type SelectionSet = {
count: number;
siteId: ColumnMappingToType<typeof ColumnMappings.siteId>;
};
const returnPromise = new Promise<[string, number][]>((resolve, reject) => (async () => {
const response = await this.query(query);

if (!response.ok) {
reject(response.statusText);
return;
}

const responseData = await response.json() as AnalyticsQueryResult;
const responseData = await response.json();

invariant(
testAnalyticsQueryResult(
responseData,
(
row
): row is AnalyticsQueryResult<SelectionSet>['data'][number] => {
return (
row &&
typeof row === 'object' &&
typeof row.count === 'number' &&
typeof row.siteId === 'string'
);
}
),
'getSitesOrderedByHits response did not match expected result'
);

const result = responseData.data.reduce((acc, cur) => {
acc.push([cur['siteId'], cur['count']]);
return acc;
}, []);
}, [] as [string, number][]);

resolve(result);
})());
return returnPromise;
}
}
}
Loading

0 comments on commit 104f9db

Please sign in to comment.