Skip to content
Original file line number Diff line number Diff line change
@@ -1,8 +1,10 @@
import { TelemetryModelJson } from '../telemetryUtils';
import { ToolingModelJson } from '../toolingModelService';

export enum MessageType {
UI_ACTIVATED = 'ui_activated',
UI_SOQL_CHANGED = 'ui_soql_changed',
UI_TELEMETRY = 'ui_telemetry',
SOBJECT_METADATA_REQUEST = 'sobject_metadata_request',
SOBJECT_METADATA_RESPONSE = 'sobject_metadata_response',
SOBJECTS_REQUEST = 'sobjects_request',
Expand All @@ -14,5 +16,5 @@ export enum MessageType {

export interface SoqlEditorEvent {
type: MessageType;
payload?: string | string[] | ToolingModelJson;
payload?: string | string[] | ToolingModelJson | TelemetryModelJson;
}
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,10 @@ export class VscodeMessageService implements IMessageService {
this.vscode.postMessage(event);
}

public sendTelemetry(telemetry: JSON) {
this.vscode.postMessage({ type: MessageType.UI_TELEMETRY, payload: telemetry})
}

public getState() {
let state = this.vscode.getState();
return state;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -16,8 +16,26 @@ describe('SoqlUtils', () => {
errors: [],
unsupported: []
};
const uiModelErrors: ToolingModelJson = {
sObject: 'Account',
fields: ['Name'],
orderBy: [],
limit: '',
errors: [
{
type: 'UNKNOWN'
}
],
unsupported: [
{
unmodeledSyntax: 'GROUP BY',
reason: 'unmodeled:group-by'
}
]
};
const soqlOne =
'Select Name, Id from Account ORDER BY Name ASC NULLS FIRST LIMIT 11';
const soqlError = 'Select Name from Account GROUP BY';
it('transform UI Model to Soql', () => {
const transformedSoql = convertUiModelToSoql(uiModelOne);
expect(transformedSoql).toContain(uiModelOne.fields[0]);
Expand All @@ -28,8 +46,24 @@ describe('SoqlUtils', () => {
expect(transformedSoql).toContain(uiModelOne.orderBy[0].nulls);
expect(transformedSoql).toContain('11');
});
it('transform UI Model to Soql but leaves out errors/unsupported', () => {
const transformedSoql = convertUiModelToSoql(uiModelErrors);
expect(transformedSoql).not.toContain(
uiModelErrors.unsupported[0].unmodeledSyntax
);
expect(transformedSoql).not.toContain(uiModelErrors.errors[0].type);
});
it('transforms Soql to UI Model', () => {
const transformedUiModel = convertSoqlToUiModel(soqlOne);
expect(transformedUiModel).toEqual(uiModelOne);
});
it('transforms Soql to UI Model with errors in soql syntax', () => {
const transformedUiModel = convertSoqlToUiModel(soqlError);
expect(transformedUiModel.errors[0].type).toEqual(
uiModelErrors.errors[0].type
);
expect(transformedUiModel.unsupported[0].reason).toEqual(
uiModelErrors.unsupported[0].reason
);
});
});
Original file line number Diff line number Diff line change
Expand Up @@ -62,7 +62,7 @@ export function convertSoqlModelToUiModel(
const prop = queryModel[key];
if (typeof prop === 'object') {
if (SoqlModelUtils.containsUnmodeledSyntax(prop)) {
unsupported.push(prop.unmodeledSyntax);
SoqlModelUtils.getUnmodeledSyntax(prop, unsupported);
}
}
}
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,49 @@
import { createQueryTelemetry } from './telemetryUtils';
import { ToolingModelJson } from './toolingModelService';

describe('Telemetry Utils', () => {
const error1 = {
type: 'UNKNOWN',
message:
"mismatched input 'BY' expecting {<EOF>, ',', 'offset', 'for', 'limit', 'having', 'order', 'update', 'bind'}",
lineNumber: 5,
charInLine: 8,
grammarRule: 'SoqlQueryContext'
};
const error2 = {
type: 'NOSELECT',
message:
'Incomplete SELECT clause. The SELECT clause must contain at least one SELECT expression.',
lineNumber: 1,
charInLine: 0,
grammarRule: 'SoqlSelectClauseContext'
};
const unsupported1 = {
unmodeledSyntax: 'GROUP BY\n ORDER',
reason: 'unmodeled:group-by'
};
const unsupported2 = {
unmodeledSyntax: 'COUNT(Id) recordCount',
reason: 'unmodeled:function-reference'
};

const query = ({
sObject: 'account',
fields: ['Id', 'Name'],
orderBy: [{ field: 'Name', nulls: 'first', order: 'desc' }],
limit: '1234',
errors: [error1, error2],
unsupported: [unsupported1, unsupported2]
} as unknown) as ToolingModelJson;
it('should create telemetry model from soql model', () => {
const telemetry = createQueryTelemetry(query);
expect(telemetry.sObject).toEqual('standard');
expect(telemetry.fields).toEqual(query.fields.length);
expect(telemetry.orderBy).toEqual(query.orderBy.length);
expect(telemetry.errors.length).toEqual(query.errors.length);
expect(telemetry.errors[0]).toContain(error1.grammarRule);
expect(telemetry.unsupported.length).toEqual(query.unsupported.length);
expect(telemetry.unsupported[0]).toEqual(unsupported1.reason);
expect(JSON.stringify(telemetry)).not.toContain(query.fields[0]);
});
});
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
import { ToolingModelJson, ToolingModelService } from './toolingModelService';
import { JsonMap } from '@salesforce/ts-types';

export interface TelemetryModelJson extends JsonMap {
sObject: string;
fields: number;
orderBy: number;
limit: string;
errors: string[];
unsupported: string[];
}

export function createQueryTelemetry(
query: ToolingModelJson
): TelemetryModelJson {
const telemetry = {} as TelemetryModelJson;
telemetry.sObject = query.sObject.indexOf('__c') > -1 ? 'custom' : 'standard';
telemetry.fields = query.fields.length;
telemetry.orderBy = query.orderBy.length;
telemetry.limit = query.limit;
telemetry.errors = query.errors.map(
(err) => `${err.type}:${err.grammarRule}`
);
telemetry.unsupported = query.unsupported.map((unsup) => unsup.reason);
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

this uses jon's new reason attribute of unsupported array.

return telemetry;
}
Original file line number Diff line number Diff line change
Expand Up @@ -144,6 +144,25 @@ describe('Tooling Model Service', () => {
expect(query.unsupported.length).toEqual(0);
});

it('Emit telemetry when error/unsupported in soql statement', () => {
(messageService.sendMessage as jest.Mock).mockClear();
const soqlText =
"Select Name1, Id1 from Account1 WHERE Name1 = 'Pickles' GROUP BY";
const soqlEvent = { ...soqlEditorEvent };
soqlEvent.payload = soqlText;
(messageService.messagesToUI as BehaviorSubject<SoqlEditorEvent>).next(
soqlEvent
);
expect(messageService.sendMessage).toHaveBeenCalled();
expect(
(messageService.sendMessage as jest.Mock).mock.calls[0][0].type
).toEqual(MessageType.UI_TELEMETRY);
// does not expose any user data
expect(
JSON.stringify((messageService.sendMessage as jest.Mock).mock.calls[0][0])
).not.toContain('Pickles');
});

it('should add, update, remove order by fields in model', () => {
(messageService.setState as jest.Mock).mockClear();
expect(messageService.setState).toHaveBeenCalledTimes(0);
Expand All @@ -164,7 +183,11 @@ describe('Tooling Model Service', () => {

// Yet Update Field IF Direction and/or Nulls Change
expect(query!.orderBy[0].order).toBeDefined();
const updatedOrderBy = { field: 'orderBy1', order: undefined, nulls: 'NULLS LAST' };
const updatedOrderBy = {
field: 'orderBy1',
order: undefined,
nulls: 'NULLS LAST'
};
modelService.addUpdateOrderByField(updatedOrderBy);
expect(query!.orderBy[0].order).not.toBeDefined();

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ import {
convertUiModelToSoql,
convertSoqlToUiModel
} from '../services/soqlUtils';
import { createQueryTelemetry } from './telemetryUtils';

// This is to satisfy TS and stay dry
type IMap = Map<string, string | List<string>>;
Expand All @@ -26,7 +27,7 @@ export interface ToolingModel extends IMap {
orderBy: List<Map>;
limit: string;
errors: List<Map>;
unsupported: string[];
unsupported: List<Map>;
}
// Public inteface for accessing modelService.query
export interface ToolingModelJson extends JsonMap {
Expand All @@ -35,7 +36,7 @@ export interface ToolingModelJson extends JsonMap {
orderBy: JsonMap[];
limit: string;
errors: JsonMap[];
unsupported: string[];
unsupported: JsonMap[];
originalSoqlStatement: string;
}

Expand Down Expand Up @@ -115,18 +116,19 @@ export class ToolingModelService {
}

private hasOrderByField(field: string) {
return this.getOrderBy().findIndex( (item) => item.get('field') === field );
return this.getOrderBy().findIndex((item) => item.get('field') === field);
}

public addUpdateOrderByField(orderByObj: JsonMap) {
const currentModel = this.getModel();
let updatedOrderBy;
const existingIndex = this.hasOrderByField(orderByObj.field);
if (existingIndex > -1) {
updatedOrderBy = this.getOrderBy().update(existingIndex, () => { return fromJS(orderByObj)});
}
else {
updatedOrderBy = this.getOrderBy().push(fromJS(orderByObj))
updatedOrderBy = this.getOrderBy().update(existingIndex, () => {
return fromJS(orderByObj);
});
} else {
updatedOrderBy = this.getOrderBy().push(fromJS(orderByObj));
}
const newModel = currentModel.set(
'orderBy',
Expand Down Expand Up @@ -161,9 +163,14 @@ export class ToolingModelService {
const originalSoqlStatement = event.payload as string;
const soqlJSModel = convertSoqlToUiModel(originalSoqlStatement);
soqlJSModel.originalSoqlStatement = originalSoqlStatement;

const updatedModel = fromJS(soqlJSModel);
if (!updatedModel.equals(this.model.getValue())) {
if (
originalSoqlStatement.length &&
(soqlJSModel.errors.length || soqlJSModel.unsupported.length)
) {
this.sendTelemetryToBackend(soqlJSModel);
}
this.model.next(updatedModel);
}
break;
Expand Down Expand Up @@ -199,6 +206,18 @@ export class ToolingModelService {
}
}

public sendTelemetryToBackend(query: ToolingModelJson) {
try {
const telemetryMetrics = createQueryTelemetry(query);
this.messageService.sendMessage({
type: MessageType.UI_TELEMETRY,
payload: telemetryMetrics
});
} catch (e) {
console.error(e);
}
}

public restoreViewState() {
this.model.next(this.getSavedState());
}
Expand Down
6 changes: 3 additions & 3 deletions packages/soql-model/src/model/impl/fieldSelectionImpl.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,18 +14,18 @@ describe('FieldSelectionImpl should', () => {
expect(actual).toEqual(expected);
});
it('store an unmodeled syntax object as the alias', () => {
const expected = { field: { fieldName: 'brian' }, alias: { unmodeledSyntax: 'bill' } };
const expected = { field: { fieldName: 'brian' }, alias: { unmodeledSyntax: 'bill', reason: 'unmodeled:alias' } };
const actual = new Impl.FieldSelectionImpl(
new Impl.FieldRefImpl(expected.field.fieldName),
new Impl.UnmodeledSyntaxImpl(expected.alias.unmodeledSyntax)
new Impl.UnmodeledSyntaxImpl(expected.alias.unmodeledSyntax, 'unmodeled:alias')
);
expect(actual).toEqual(expected);
});
it('return field name followed by alias for toSoqlSyntax()', () => {
const expected = 'rolling stones';
const actual = new Impl.FieldSelectionImpl(
new Impl.FieldRefImpl('rolling'),
new Impl.UnmodeledSyntaxImpl('stones')
new Impl.UnmodeledSyntaxImpl('stones', 'unmodeled:alias')
).toSoqlSyntax();
expect(actual).toEqual(expected);
});
Expand Down
12 changes: 6 additions & 6 deletions packages/soql-model/src/model/impl/fromImpl.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,22 +16,22 @@ describe('FromImpl should', () => {
it('store as and using clauses as unmodeled syntax', () => {
const expected = {
sobjectName: 'black',
as: { unmodeledSyntax: 'and' },
using: { unmodeledSyntax: 'blue' },
as: { unmodeledSyntax: 'and', reason: 'unmodeled:as' },
using: { unmodeledSyntax: 'blue', reason: 'unmodeled:using' },
};
const actual = new Impl.FromImpl(
expected.sobjectName,
new Impl.UnmodeledSyntaxImpl(expected.as.unmodeledSyntax),
new Impl.UnmodeledSyntaxImpl(expected.using.unmodeledSyntax)
new Impl.UnmodeledSyntaxImpl(expected.as.unmodeledSyntax, 'unmodeled:as'),
new Impl.UnmodeledSyntaxImpl(expected.using.unmodeledSyntax, 'unmodeled:using')
);
expect(actual).toEqual(expected);
});
it('return FROM sobject name followed by as and using clauses for toSoqlSyntax()', () => {
const expected = 'FROM exile on main';
const actual = new Impl.FromImpl(
'exile',
new Impl.UnmodeledSyntaxImpl('on'),
new Impl.UnmodeledSyntaxImpl('main')
new Impl.UnmodeledSyntaxImpl('on', 'unmodeled:as'),
new Impl.UnmodeledSyntaxImpl('main', 'unmodeled:using')
).toSoqlSyntax();
expect(actual).toEqual(expected);
});
Expand Down
24 changes: 12 additions & 12 deletions packages/soql-model/src/model/impl/queryImpl.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,14 +14,14 @@ describe('QueryImpl should', () => {
select: { selectExpressions: [] },
from: { sobjectName: 'songs' },
where: { condition: { field: { fieldName: 'paint_it' }, operator: '=', compareValue: { type: 'STRING', value: "'black'" } } },
with: { unmodeledSyntax: 'gimme shelter' },
groupBy: { unmodeledSyntax: 'start me up' },
with: { unmodeledSyntax: 'gimme shelter', reason: 'unmodeled:with' },
groupBy: { unmodeledSyntax: 'start me up', reason: 'unmodeled:group-by' },
orderBy: { orderByExpressions: [{ field: { fieldName: 'angie' } }] },
limit: { limit: 5 },
offset: { unmodeledSyntax: 'wild horses' },
bind: { unmodeledSyntax: 'miss you' },
recordTrackingType: { unmodeledSyntax: 'satisfaction' },
update: { unmodeledSyntax: 'under my thumb' },
offset: { unmodeledSyntax: 'wild horses', reason: 'unmodeled:offset' },
bind: { unmodeledSyntax: 'miss you', reason: 'unmodeled:bind' },
recordTrackingType: { unmodeledSyntax: 'satisfaction', reason: 'unmodeled:record-tracking' },
update: { unmodeledSyntax: 'under my thumb', reason: 'unmodeled:update' },
};
const actual = new Impl.QueryImpl(
new Impl.SelectExprsImpl([]),
Expand All @@ -31,14 +31,14 @@ describe('QueryImpl should', () => {
CompareOperator.EQ,
new Impl.LiteralImpl(LiteralType.String, expected.where.condition.compareValue.value)
)),
new Impl.UnmodeledSyntaxImpl(expected.with.unmodeledSyntax),
new Impl.UnmodeledSyntaxImpl(expected.groupBy.unmodeledSyntax),
new Impl.UnmodeledSyntaxImpl(expected.with.unmodeledSyntax, 'unmodeled:with'),
new Impl.UnmodeledSyntaxImpl(expected.groupBy.unmodeledSyntax, 'unmodeled:group-by'),
new Impl.OrderByImpl([new Impl.OrderByExpressionImpl(new Impl.FieldRefImpl(expected.orderBy.orderByExpressions[0].field.fieldName))]),
new Impl.LimitImpl(expected.limit.limit),
new Impl.UnmodeledSyntaxImpl(expected.offset.unmodeledSyntax),
new Impl.UnmodeledSyntaxImpl(expected.bind.unmodeledSyntax),
new Impl.UnmodeledSyntaxImpl(expected.recordTrackingType.unmodeledSyntax),
new Impl.UnmodeledSyntaxImpl(expected.update.unmodeledSyntax)
new Impl.UnmodeledSyntaxImpl(expected.offset.unmodeledSyntax, 'unmodeled:offset'),
new Impl.UnmodeledSyntaxImpl(expected.bind.unmodeledSyntax, 'unmodeled:bind'),
new Impl.UnmodeledSyntaxImpl(expected.recordTrackingType.unmodeledSyntax, 'unmodeled:record-tracking'),
new Impl.UnmodeledSyntaxImpl(expected.update.unmodeledSyntax, 'unmodeled:update')
);
expect(actual).toEqual(expected);
});
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@ import * as Impl from '.';
import { SyntaxOptions } from '../model';

describe('SoqlModelObjectImpl should', () => {
const testModelObject = new Impl.UnmodeledSyntaxImpl('mick');
const testModelObject = new Impl.UnmodeledSyntaxImpl('mick', 'fake SOQL');
it('use SyntaxOptions that are passed in', () => {
const expectedSyntaxOptions = new SyntaxOptions();
expectedSyntaxOptions.indent = 50;
Expand Down
Loading