Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,8 @@ import { type ObjectRecord } from '@/object-record/types/ObjectRecord';
import { useEffect } from 'react';
import { useRecoilCallback } from 'recoil';
import { isDefined } from 'twenty-shared/utils';
import { useEventTracker } from '@/analytics/hooks/useEventTracker';
import { AnalyticsType } from '~/generated-metadata/graphql';

type RecordShowEffectProps = {
objectNameSingular: string;
Expand All @@ -17,6 +19,8 @@ export const RecordShowEffect = ({
objectNameSingular,
recordId,
}: RecordShowEffectProps) => {
const eventTracker = useEventTracker();

const { objectMetadataItem } = useObjectMetadataItem({ objectNameSingular });
const { objectMetadataItems } = useObjectMetadataItems();

Expand Down Expand Up @@ -53,5 +57,15 @@ export const RecordShowEffect = ({
}
}, [record, setRecordStore, loading]);

useEffect(() => {
eventTracker(AnalyticsType.TRACK, {
Copy link
Member

Choose a reason for hiding this comment

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

We already track pageviews, can't we infer it from that or do something smarter rather than send 2 extremely similar track events?
We could either stop tracking unstructured pageviews and only store events like that ; or we could eventually let the endpoint support an array and send 2 events at once...? I think I prefer solution 1

event: 'Object Record Viewed',
properties: {
recordId: recordId,
objectMetadataId: objectMetadataItem.id,
},
});
}, [objectMetadataItem, recordId, eventTracker]);

return <></>;
};
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
import { type MigrationInterface, type QueryRunner } from 'typeorm';

export class AddFieldMetadataStorageColumn1760458320000
implements MigrationInterface
{
name = 'AddFieldMetadataStorageColumn1760458320000';

public async up(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(
`ALTER TABLE "core"."fieldMetadata" ADD COLUMN "storage" character varying`,
);
}

public async down(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(
`ALTER TABLE "core"."fieldMetadata" DROP COLUMN "storage"`,
);
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,255 @@
import { faker } from '@faker-js/faker';

import { GraphqlQueryParser } from 'src/engine/api/graphql/graphql-query-runner/graphql-query-parsers/graphql-query.parser';
import { GraphqlQueryFilterConditionParser } from 'src/engine/api/graphql/graphql-query-runner/graphql-query-parsers/graphql-query-filter/graphql-query-filter-condition.parser';
import { GraphqlQueryOrderFieldParser } from 'src/engine/api/graphql/graphql-query-runner/graphql-query-parsers/graphql-query-order/graphql-query-order.parser';
import { GraphqlQuerySelectedFieldsParser } from 'src/engine/api/graphql/graphql-query-runner/graphql-query-parsers/graphql-query-selected-fields/graphql-selected-fields.parser';
import {
GraphqlQueryRunnerException,
GraphqlQueryRunnerExceptionCode,
} from 'src/engine/api/graphql/graphql-query-runner/errors/graphql-query-runner.exception';
import { type ObjectMetadataMaps } from 'src/engine/metadata-modules/types/object-metadata-maps';
import { type ObjectMetadataItemWithFieldMaps } from 'src/engine/metadata-modules/types/object-metadata-item-with-field-maps';
import { getMockObjectMetadataItemWithFieldsMaps } from 'src/utils/__test__/get-object-metadata-item-with-fields-maps.mock';

// Minimal chainable query builder mock
const createQueryBuilderMock = () => {
const qb: any = {
withDeleted: jest.fn().mockReturnThis(),
orderBy: jest.fn().mockReturnThis(),
addOrderBy: jest.fn().mockReturnThis(),
};

return qb;
};

describe('GraphqlQueryParser', () => {
let objectMetadataMaps: ObjectMetadataMaps;
let objectMetadataItem: ObjectMetadataItemWithFieldMaps;

beforeEach(() => {
jest.clearAllMocks();

const nameSingular = 'mockObject';
const objectId = faker.string.uuid();

objectMetadataItem = getMockObjectMetadataItemWithFieldsMaps({
id: objectId,
nameSingular,
namePlural: `${nameSingular}s`,
workspaceId: faker.string.uuid(),
fieldsById: {},
fieldIdByJoinColumnName: {},
fieldIdByName: {},
indexMetadatas: [],
});

objectMetadataMaps = {
byId: { [objectId]: objectMetadataItem },
idByNameSingular: { [nameSingular]: objectId },
};
});

describe('applyDeletedAtToBuilder', () => {
test('calls withDeleted when deletedAt filter at top-level', () => {
const qb = createQueryBuilderMock();
const parser = new GraphqlQueryParser(
objectMetadataItem,
objectMetadataMaps,
);

parser.applyDeletedAtToBuilder(qb as any, { deletedAt: null } as any);

expect(qb.withDeleted).toHaveBeenCalledTimes(1);
});

test('calls withDeleted when deletedAt filter nested in object', () => {
const qb = createQueryBuilderMock();
const parser = new GraphqlQueryParser(
objectMetadataItem,
objectMetadataMaps,
);

parser.applyDeletedAtToBuilder(
qb as any,
{ some: { deep: { path: { deletedAt: { $ne: null } } } } } as any,
);

expect(qb.withDeleted).toHaveBeenCalledTimes(1);
});

test('calls withDeleted when deletedAt filter present in array', () => {
const qb = createQueryBuilderMock();
const parser = new GraphqlQueryParser(
objectMetadataItem,
objectMetadataMaps,
);

parser.applyDeletedAtToBuilder(
qb as any,
[{ a: 1 }, { deletedAt: 1 }] as any,
);

expect(qb.withDeleted).toHaveBeenCalledTimes(1);
});

test('does not call withDeleted when deletedAt is absent', () => {
const qb = createQueryBuilderMock();
const parser = new GraphqlQueryParser(
objectMetadataItem,
objectMetadataMaps,
);

parser.applyDeletedAtToBuilder(qb as any, { foo: 'bar' } as any);

expect(qb.withDeleted).not.toHaveBeenCalled();
});
});

describe('applyOrderToBuilder', () => {
test('delegates to orderBy with parsed order when not grouped', () => {
const qb = createQueryBuilderMock();
const parser = new GraphqlQueryParser(
objectMetadataItem,
objectMetadataMaps,
);

const parsed = { 't.name': 'ASC', 't.createdAt': 'DESC' } as any;
const orderSpy = jest
.spyOn(GraphqlQueryOrderFieldParser.prototype, 'parse')
.mockReturnValue(parsed);

const returned = parser.applyOrderToBuilder(
qb as any,
{ name: 'AscNullsLast' } as any,
objectMetadataItem.nameSingular,
true,
);

expect(orderSpy).toHaveBeenCalledWith(
{ name: 'AscNullsLast' },
objectMetadataItem.nameSingular,
true,
false,
);
expect(qb.orderBy).toHaveBeenCalledWith(parsed);
expect(returned).toBe(qb);
});

test('uses orderBy then addOrderBy when grouped', () => {
const qb = createQueryBuilderMock();
const parser = new GraphqlQueryParser(
objectMetadataItem,
objectMetadataMaps,
);

const parsedGrouped = {
'COUNT(t.id)': { order: 'ASC', nulls: 'NULLS LAST' },
't.name': { order: 'DESC', nulls: undefined },
} as const;

jest
.spyOn(GraphqlQueryOrderFieldParser.prototype, 'parse')
.mockReturnValue(parsedGrouped);

const returned = parser.applyOrderToBuilder(
qb as any,
{ name: 'AscNullsLast' } as any,
objectMetadataItem.nameSingular,
true,
);

const entries = Object.entries(parsedGrouped);

expect(qb.orderBy).toHaveBeenCalledWith(
entries[0][0],
entries[0][1].order,
entries[0][1].nulls,
);
expect(qb.addOrderBy).toHaveBeenCalledWith(
entries[1][0],
entries[1][1].order,
entries[1][1].nulls,
);
expect(returned).toBe(qb);
});
});

describe('applyFilterToBuilder', () => {
test('delegates to filter condition parser and returns its result', () => {
const qb = createQueryBuilderMock();
const newQb = { ...createQueryBuilderMock(), marker: 'returned' } as any;
const parser = new GraphqlQueryParser(
objectMetadataItem,
objectMetadataMaps,
);

const filterSpy = jest
.spyOn(GraphqlQueryFilterConditionParser.prototype, 'parse')
.mockReturnValue(newQb);

const result = parser.applyFilterToBuilder(
qb as any,
objectMetadataItem.nameSingular,
{ id: { eq: '1' } } as any,
);

expect(filterSpy).toHaveBeenCalledWith(
qb,
objectMetadataItem.nameSingular,
{ id: { eq: '1' } },
);
expect(result).toBe(newQb);
});
});

describe('parseSelectedFields', () => {
test('throws when object metadata for parent is not found', () => {
const missingMaps: ObjectMetadataMaps = {
byId: {},
idByNameSingular: {},
};
const parser = new GraphqlQueryParser(objectMetadataItem, missingMaps);

expect(() =>
parser.parseSelectedFields(objectMetadataItem, { id: {} }, missingMaps),
).toThrow(GraphqlQueryRunnerException);

try {
parser.parseSelectedFields(objectMetadataItem, { id: {} }, missingMaps);
} catch (e) {
const err = e as GraphqlQueryRunnerException;

expect(err.code).toBe(
GraphqlQueryRunnerExceptionCode.OBJECT_METADATA_NOT_FOUND,
);
expect(err.message).toContain(objectMetadataItem.nameSingular);
}
});

test('delegates to GraphqlQuerySelectedFieldsParser when metadata exists', () => {
const parser = new GraphqlQueryParser(
objectMetadataItem,
objectMetadataMaps,
);
const selectedFieldsResult = {
selectedColumns: ['t.id'],
selectedRelations: {},
selectedAggregates: {},
} as any;

const selectedSpy = jest
.spyOn(GraphqlQuerySelectedFieldsParser.prototype, 'parse')
.mockReturnValue(selectedFieldsResult);

const result = parser.parseSelectedFields(
objectMetadataItem,
{ id: {} },
objectMetadataMaps,
);

expect(selectedSpy).toHaveBeenCalled();
expect(result).toBe(selectedFieldsResult);
});
});
});
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,8 @@ import { type FieldMetadataEntity } from 'src/engine/metadata-modules/field-meta
import { isCompositeFieldMetadataType } from 'src/engine/metadata-modules/field-metadata/utils/is-composite-field-metadata-type.util';
import { type ObjectMetadataItemWithFieldMaps } from 'src/engine/metadata-modules/types/object-metadata-item-with-field-maps';
import { formatColumnNamesFromCompositeFieldAndSubfields } from 'src/engine/twenty-orm/utils/format-column-names-from-composite-field-and-subfield.util';
import { EXTERNAL_STORAGE_ALIAS } from 'src/engine/twenty-orm/utils/external-storage-alias.constant';
import { wrapperWithDoubleQuoteWhenUpperCase } from 'src/engine/api/graphql/graphql-query-runner/utils/wrapper-with-double-quote-when-uppercase';

export type OrderByCondition = {
order: 'ASC' | 'DESC';
Expand Down Expand Up @@ -79,7 +81,18 @@ export class GraphqlQueryOrderFieldParser {
const orderByCasting =
this.getOptionalOrderByCasting(fieldMetadata);

acc[`"${objectNameSingular}"."${key}"${orderByCasting}`] =
const isExternal =
isDefined(fieldMetadata.storage) &&
fieldMetadata.storage !== 'postgres';

// @todo when I use wrapperWithDoubleQuoteWhenUpperCase for external field the getMany works but the order fail
const selector = isExternal
? wrapperWithDoubleQuoteWhenUpperCase(
`${EXTERNAL_STORAGE_ALIAS}${fieldMetadata.name}`,
)
: `${objectNameSingular}.${fieldMetadata.name}`;

acc[`${selector}${orderByCasting}`] =
convertOrderByToFindOptionsOrder(
value as OrderByDirection,
isForwardPagination,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -110,7 +110,16 @@ export class GraphqlQueryParser {
isForwardPagination,
);

return queryBuilder.orderBy(parsedOrderBys);
Object.entries(parsedOrderBys).forEach(([expression, direction]) => {
queryBuilder.addOrderBy(
// TODO: dirty. Avoid using replace on regex
expression,
direction.order,
direction.nulls,
);
});

return queryBuilder;
}

public applyGroupByOrderToBuilder(
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -21,12 +21,18 @@ import { ObjectRecordsToGraphqlConnectionHelper } from 'src/engine/api/graphql/g
import { buildColumnsToSelect } from 'src/engine/api/graphql/graphql-query-runner/utils/build-columns-to-select';
import { buildDuplicateConditions } from 'src/engine/api/utils/build-duplicate-conditions.utils';
import { getObjectMetadataMapItemByNameSingular } from 'src/engine/metadata-modules/utils/get-object-metadata-map-item-by-name-singular.util';
import { RedisStorageDriver } from 'src/engine/twenty-orm/storage/drivers/redis-storage.driver';
import { listStorageDrivers } from 'src/engine/twenty-orm/storage/list-storage-drivers.util';

@Injectable()
export class GraphqlQueryFindDuplicatesResolverService extends GraphqlQueryBaseResolverService<
FindDuplicatesResolverArgs,
IConnection<ObjectRecord>[]
> {
constructor(private readonly redisStorageDriver: RedisStorageDriver) {
super();
}

async resolve(
executionArgs: GraphqlQueryResolverExecutionArgs<FindDuplicatesResolverArgs>,
): Promise<IConnection<ObjectRecord>[]> {
Expand All @@ -38,6 +44,11 @@ export class GraphqlQueryFindDuplicatesResolverService extends GraphqlQueryBaseR
objectMetadataItemWithFieldMaps.nameSingular,
);

await existingRecordsQueryBuilder.appendExternalFields(
objectMetadataItemWithFieldMaps,
listStorageDrivers(this.redisStorageDriver),
);

const objectMetadataItemWithFieldsMaps =
getObjectMetadataMapItemByNameSingular(
objectMetadataMaps,
Expand Down Expand Up @@ -104,6 +115,11 @@ export class GraphqlQueryFindDuplicatesResolverService extends GraphqlQueryBaseR
objectMetadataItemWithFieldMaps.nameSingular,
);

await duplicateRecordsQueryBuilder.appendExternalFields(
objectMetadataItemWithFieldMaps,
listStorageDrivers(this.redisStorageDriver),
);

graphqlQueryParser.applyFilterToBuilder(
duplicateRecordsQueryBuilder,
objectMetadataItemWithFieldMaps.nameSingular,
Expand Down
Loading
Loading