Skip to content

Commit

Permalink
add experimental sql_functions to Malloy
Browse files Browse the repository at this point in the history
* add sql_* functions that allow for general sql text to be used
  as expressions which return a specific type.
* these sql_* functions will also interpolate special ${...} lookml
  syntax such as ${TABLE}, ${dimension}, etc
* add experimental Malloy flag 'sql_functions' that must be enabled
  for your Malloy source to use this feature.
  • Loading branch information
Christopher Swenson authored and Adam Markowitz committed Jan 18, 2024
1 parent 449c6c0 commit 04fb520
Show file tree
Hide file tree
Showing 11 changed files with 458 additions and 6 deletions.
2 changes: 1 addition & 1 deletion packages/malloy-render/src/component/table-layout.ts
Original file line number Diff line number Diff line change
Expand Up @@ -77,7 +77,7 @@ function getColumnWidth(f: Field, metadata: RenderResultMetadata) {
let width = 0;
if (f.isAtomicField()) {
// TODO: get font styles from theme
const font = `12px Inter, sans-serif`;
const font = '12px Inter, sans-serif';
const titleWidth = getTextWidth(f.name, font);
if (f.isAtomicField() && f.isString()) {
width =
Expand Down
2 changes: 1 addition & 1 deletion packages/malloy-render/src/stories/tables.stories.ts
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,7 @@ export default meta;
export const ProductsTable = {
args: {
source: 'products',
view: `records`,
view: 'records',
},
};

Expand Down
6 changes: 3 additions & 3 deletions packages/malloy-render/src/stories/themes.stories.ts
Original file line number Diff line number Diff line change
Expand Up @@ -25,21 +25,21 @@ export default meta;
export const ModelThemeOverride = {
args: {
source: 'products',
view: `records`,
view: 'records',
},
};

export const ViewThemeOverride = {
args: {
source: 'products',
view: `records_override`,
view: 'records_override',
},
};

export const ViewThemeOverrideCSS = {
args: {
source: 'products',
view: `records_override`,
view: 'records_override',
classes: 'night',
},
};
13 changes: 13 additions & 0 deletions packages/malloy/src/dialect/functions/all_functions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -79,6 +79,13 @@ import {
import {fnAvgRolling} from './avg_moving';
import {FunctionMap} from './function_map';
import {fnCoalesce} from './coalesce';
import {
fnSqlBoolean,
fnSqlDate,
fnSqlNumber,
fnSqlString,
fnSqlTimestamp,
} from './sql';

/**
* This is a function map containing default implementations of all Malloy
Expand Down Expand Up @@ -155,4 +162,10 @@ FUNCTIONS.add('max_window', fnMaxWindow);
FUNCTIONS.add('sum_window', fnSumWindow);
FUNCTIONS.add('avg_moving', fnAvgRolling);

FUNCTIONS.add('sql_number', fnSqlNumber);
FUNCTIONS.add('sql_string', fnSqlString);
FUNCTIONS.add('sql_date', fnSqlDate);
FUNCTIONS.add('sql_timestamp', fnSqlTimestamp);
FUNCTIONS.add('sql_boolean', fnSqlBoolean);

FUNCTIONS.seal();
86 changes: 86 additions & 0 deletions packages/malloy/src/dialect/functions/sql.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,86 @@
/*
* Copyright 2023 Google LLC
*
* Permission is hereby granted, free of charge, to any person obtaining
* a copy of this software and associated documentation files
* (the "Software"), to deal in the Software without restriction,
* including without limitation the rights to use, copy, modify, merge,
* publish, distribute, sublicense, and/or sell copies of the Software,
* and to permit persons to whom the Software is furnished to do so,
* subject to the following conditions:
*
* The above copyright notice and this permission notice shall be
* included in all copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
* EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF
* MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT.
* IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY
* CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT,
* TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE
* SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/

import {
overload,
minScalar,
DialectFunctionOverloadDef,
makeParam,
maxScalar,
literal,
} from './util';

export function fnSqlNumber(): DialectFunctionOverloadDef[] {
const value = makeParam('value', literal(maxScalar('string')));
return [
overload(
minScalar('number'),
[value.param],
[{type: 'sql-string', e: [value.arg]}]
),
];
}

export function fnSqlString(): DialectFunctionOverloadDef[] {
const value = makeParam('value', literal(maxScalar('string')));
return [
overload(
minScalar('string'),
[value.param],
[{type: 'sql-string', e: [value.arg]}]
),
];
}

export function fnSqlDate(): DialectFunctionOverloadDef[] {
const value = makeParam('value', literal(maxScalar('string')));
return [
overload(
minScalar('date'),
[value.param],
[{type: 'sql-string', e: [value.arg]}]
),
];
}

export function fnSqlTimestamp(): DialectFunctionOverloadDef[] {
const value = makeParam('value', literal(maxScalar('string')));
return [
overload(
minScalar('timestamp'),
[value.param],
[{type: 'sql-string', e: [value.arg]}]
),
];
}

export function fnSqlBoolean(): DialectFunctionOverloadDef[] {
const value = makeParam('value', literal(maxScalar('string')));
return [
overload(
minScalar('boolean'),
[value.param],
[{type: 'sql-string', e: [value.arg]}]
),
];
}
Original file line number Diff line number Diff line change
Expand Up @@ -261,6 +261,7 @@ function getJoinUsage(
};
exprWalk(expr, frag => {
if (typeof frag !== 'string') {
// TODO make this work for field references inside sql_* functions
if (frag.type === 'field') {
const def = lookup(fs, frag.path);
if (def.def.type !== 'struct' && def.def.type !== 'turtle') {
Expand Down
99 changes: 98 additions & 1 deletion packages/malloy/src/lang/ast/expressions/expr-func.ts
Original file line number Diff line number Diff line change
Expand Up @@ -207,7 +207,7 @@ export class ExprFunc extends ExpressionDef {
);
return errorFor('cannot call with source');
}
const funcCall: Expr = [
let funcCall: Expr = [
{
type: 'function_call',
overload,
Expand All @@ -216,6 +216,72 @@ export class ExprFunc extends ExpressionDef {
structPath,
},
];
if (
[
'sql_number',
'sql_string',
'sql_date',
'sql_timestamp',
'sql_boolean',
].includes(func.name)
) {
if (!this.inExperiment('sql_functions', true)) {
return errorFor(
`Cannot use sql_function \`${func.name}\`; use \`sql_functions\` experiment to enable this behavior`
);
}

const str = argExprs[0].value;
if (
str.length !== 1 ||
typeof str[0] === 'string' ||
str[0].type !== 'dialect' ||
str[0].function !== 'stringLiteral'
) {
this.log(`Invalid string literal for \`${func.name}\``);
} else {
const literal = str[0].literal;
const parts = parseSQLInterpolation(literal);
const unsupportedInterpolations = parts
.filter(
part => part.type === 'interpolation' && part.name.includes('.')
)
.map(unsupportedPart =>
unsupportedPart.type === 'interpolation'
? `\${${unsupportedPart.name}}`
: `\${${unsupportedPart.value}}`
);

if (unsupportedInterpolations.length > 0) {
const unsupportedInterpolationMsg =
unsupportedInterpolations.length === 1
? `'.' paths are not yet supported in sql interpolations, found ${unsupportedInterpolations.at(
0
)}`
: `'.' paths are not yet supported in sql interpolations, found [${unsupportedInterpolations.join(
', '
)}]`;
this.log(unsupportedInterpolationMsg);

return errorFor(
`${unsupportedInterpolationMsg}. See LookML \${...} documentation at https://cloud.google.com/looker/docs/reference/param-field-sql#sql_for_dimensions`
);
}

funcCall = [
{
type: 'sql-string',
e: parts.map(part =>
part.type === 'string'
? part.value
: part.name === 'TABLE'
? {type: 'source-reference'}
: {type: 'field-reference', path: part.name}
),
},
];
}
}
if (type.dataType === 'any') {
this.log(
`Invalid return type ${type.dataType} for function '${this.name}'`
Expand Down Expand Up @@ -369,3 +435,34 @@ function findOverload(
}
}
}

type InterpolationPart =
| {type: 'string'; value: string}
| {type: 'interpolation'; name: string};

function parseSQLInterpolation(template: string): InterpolationPart[] {
const parts: InterpolationPart[] = [];
let remaining = template;
while (remaining.length) {
const nextInterp = remaining.indexOf('${');
if (nextInterp === -1) {
parts.push({type: 'string', value: remaining});
break;
} else {
const interpEnd = remaining.slice(nextInterp).indexOf('}');
if (interpEnd === -1) {
parts.push({type: 'string', value: remaining});
break;
}
if (nextInterp > 0) {
parts.push({type: 'string', value: remaining.slice(0, nextInterp)});
}
parts.push({
type: 'interpolation',
name: remaining.slice(nextInterp + 2, interpEnd + nextInterp),
});
remaining = remaining.slice(interpEnd + nextInterp + 1);
}
}
return parts;
}
50 changes: 50 additions & 0 deletions packages/malloy/src/model/malloy_query.ts
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,7 @@ import {
FieldDef,
FieldFragment,
FieldRef,
FieldReferenceFragment,
FieldTimestampDef,
Filtered,
FilterExpression,
Expand Down Expand Up @@ -81,8 +82,10 @@ import {
ResultMetadataDef,
ResultStructMetadataDef,
SearchIndexResult,
SourceReferenceFragment,
SpreadFragment,
SQLExpressionFragment,
SqlStringFragment,
StructDef,
StructRef,
TurtleDef,
Expand Down Expand Up @@ -782,6 +785,47 @@ class QueryField extends QueryNode {
);
}

generateFieldReference(
resultSet: FieldInstanceResult,
context: QueryStruct,
expr: FieldReferenceFragment,
state: GenerateState
): string {
return this.generateFieldFragment(
resultSet,
context,
{type: 'field', path: expr.path},
state
);
}

generateSqlString(
resultSet: FieldInstanceResult,
context: QueryStruct,
expr: SqlStringFragment,
state: GenerateState
): string {
return expr.e
.map(part =>
typeof part === 'string'
? part
: this.generateExpressionFromExpr(resultSet, context, [part], state)
)
.join('');
}

generateSourceReference(
resultSet: FieldInstanceResult,
context: QueryStruct,
expr: SourceReferenceFragment
): string {
if (expr.path === undefined) {
return context.getSQLIdentifier();
} else {
return context.getFieldByName(expr.path).getIdentifier();
}
}

getAnalyticPartitions(resultStruct: FieldInstanceResult) {
const ret: string[] = [];
let p = resultStruct.parent;
Expand Down Expand Up @@ -980,6 +1024,12 @@ class QueryField extends QueryNode {
s += this.generateSpread(resultSet, context, expr, state);
} else if (expr.type === 'dialect') {
s += this.generateDialect(resultSet, context, expr, state);
} else if (expr.type === 'sql-string') {
s += this.generateSqlString(resultSet, context, expr, state);
} else if (expr.type === 'source-reference') {
s += this.generateSourceReference(resultSet, context, expr);
} else if (expr.type === 'field-reference') {
s += this.generateFieldReference(resultSet, context, expr, state);
} else {
throw new Error(
`Internal Error: Unknown expression fragment ${JSON.stringify(
Expand Down
Loading

0 comments on commit 04fb520

Please sign in to comment.