Skip to content

Commit

Permalink
use mathjs to convert matomo formulas to looker studio formulas
Browse files Browse the repository at this point in the history
  • Loading branch information
diosmosis committed Jun 28, 2024
1 parent 7eb853b commit 2153b35
Show file tree
Hide file tree
Showing 3 changed files with 133 additions and 6 deletions.
3 changes: 0 additions & 3 deletions src/data.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,6 @@

import dayjs from 'dayjs/esm';
import weekOfYear from 'dayjs/esm/plugin/weekOfYear';
import { create, all } from 'mathjs/lib/esm/number';
import cc, { ConnectorParams } from './connector';
import * as Api from './api';
import env from './env';
Expand All @@ -31,8 +30,6 @@ import { detectMatomoPeriodFromRange } from './matomo/period';

dayjs.extend(weekOfYear);

const math = create(all);

const pastScriptRuntimeLimitErrorMessage = 'It\'s taking too long to get the requested data. This may be a momentary issue with '
+ 'your Matomo, but if it continues to occur for this report, then you may be requesting too much data. In this '
+ 'case, limit the data you are requesting to see it in Looker Studio.';
Expand Down
104 changes: 104 additions & 0 deletions src/schema/formula.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,104 @@
/**
* Matomo - free/libre analytics platform
*
* @link https://matomo.org
* @license http://www.gnu.org/licenses/gpl-3.0.html GPL v3 or later
*/

import {
create,
all,
MathNode,
AccessorNode,
ConditionalNode,
OperatorNode,
ParenthesisNode,
RelationalNode,
SymbolNode,
ConstantNode,
OperatorNodeMap,
} from 'mathjs/lib/esm/number';

const math = create(all);

function toLookerString(ast: MathNode): string {
if (!ast) {
return '';
}

switch (ast.type) {
case 'AccessorNode':
const accessor = ast as AccessorNode;
if (accessor.object.type !== 'SymbolNode'
|| (accessor.object.type as SymbolNode).name !== '$goals'
|| accessor.index.dimensions.length > 1
) {
throw new Error(`Invalid accessor '${accessor}' found in formula. Currently only the $goals column can`
+ ' be used this way, and with only one dimension.');
}

const idgoalIndex = accessor.index.dimensions[0];
if (idgoalIndex.type !== 'ConstantNode') {
throw new Error(`Invalid goal index in '${accessor}', expected something like '$goals["idgoal=1"]'.`);
}

const idgoal = ((idgoalIndex as ConstantNode).value as string).match(/^idgoal=(\d+)$/)[1];
if (typeof idgoal !== 'string') {
throw new Error(`Could not extract goal ID from '${accessor}'.`);
}

return `$goals_${idgoal}_${accessor.index.dimensions[1]}`;
case 'ConditionalNode':
const conditional = ast as ConditionalNode;
return `IF(${toLookerString(conditional.condition)}, ${toLookerString(conditional.trueExpr)}, ${toLookerString(conditional.falseExpr)})`;
case 'OperatorNode':
const operator = ast as OperatorNode;
return operator.args.map(toLookerString).join(` ${operator.op} `);
case 'ParenthesisNode':
const parenthesis = ast as ParenthesisNode;
return `(${toLookerString(parenthesis.content)})`;
case 'RelationalNode':
const relational = ast as RelationalNode;
let relationalStr = toLookerString(ast.params[0]);
relational.conditionals.forEach((op, i) => {
relationalStr += ` ${OperatorNodeMap[op]} ${toLookerString(ast.params[i + 1])}`;
});
return relationalStr;
case 'ConstantNode':
case 'SymbolNode':
return ast.toString();
case 'ArrayNode':
case 'AssignmentNode':
case 'FunctionNode':
case 'BlockNode':
case 'RangeNode':
case 'ObjectNode':
case 'FunctionAssignmentNode':
throw new Error(`unsupported formula: found disallowed node ${ast.type} in '${ast}'`); // TODO: handle this
case 'IndexNode':
throw new Error(`Unexpected node found in formula: ${ast}`); // TODO: handle this
default:
throw new Error(`unknown node ${ast.type} in formula: ${ast}`); // TODO: handle this
}
}

export function mapMatomoFormulaToLooker(formula: string): {
lookerFormula: string,
temporaryMetrics: string[],
} {
const ast = math.parse(formula);

const temporaryMetrics = [];
ast.traverse((node) => {
if (node.type === 'SymbolNode' && (node as SymbolNode).name.startsWith('$')) {
temporaryMetrics.push((node as SymbolNode).name);
}
});

const lookerFormula = toLookerString(ast);

return {
lookerFormula,
temporaryMetrics,
};
}
32 changes: 29 additions & 3 deletions src/schema/report-metadata.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,9 +9,11 @@ import cc, { ConnectorParams } from '../connector';
import * as Api from '../api';
import {
DATE_DIMENSIONS,
mapMatomoSemanticTypeToLooker,
mapMatomoAggregationTypeToLooker,
} from "./data-types";
mapMatomoSemanticTypeToLooker,
} from './data-types';
import AggregationType = GoogleAppsScript.Data_Studio.AggregationType;
import { mapMatomoFormulaToLooker } from './formula';

export function getReportMetadataAndGoalsAndCurrency(request: GoogleAppsScript.Data_Studio.Request<ConnectorParams>) {
const idSite = request.configParams.idsite;
Expand Down Expand Up @@ -95,6 +97,7 @@ function addMetric(
matomoType: string,
siteCurrency: string,
reaggregation?: string,
formula?: string,
) {
let type = mapMatomoSemanticTypeToLooker(matomoType, siteCurrency);
let aggregationType = mapMatomoAggregationTypeToLooker(reaggregation);
Expand All @@ -105,7 +108,13 @@ function addMetric(
.setName(name)
.setType(type);

if (aggregationType) {
if (formula) {
let { lookerFormula } = mapMatomoFormulaToLooker(formula);

field
.setFormula(lookerFormula)
.setAggregation(AggregationType.AUTO);
} else if (aggregationType) {
field.setAggregation(aggregationType);
} else {
field.setIsReaggregatable(false);
Expand Down Expand Up @@ -145,6 +154,23 @@ export function getFieldsFromReportMetadata(
) {
const fields = cc.getFields();

// TODO: add temporary metrics first? how do we handle metadata that is only present in getData()?
/*
for (const tempMetric of temporaryMetrics) {
const tempField = fields
.newMetric()
.setId(tempMetric.id)
.setName(tempMetric.id)
.setIsHidden(true)
.setType(mapMatomoSemanticTypeToLooker(tempMetric.type, siteCurrency));
const tempFieldAggregation = mapMatomoAggregationTypeToLooker(tempMetric.aggregationType);
if (tempFieldAggregation) {
tempField.setAggregation(tempFieldAggregation);
}
}
*/

let allMetrics = {
...reportMetadata.metrics,
...reportMetadata.processedMetrics,
Expand Down

0 comments on commit 2153b35

Please sign in to comment.