diff --git a/src/warehouse/index.js b/src/warehouse/index.js index f62f02af79..3c2b04079d 100644 --- a/src/warehouse/index.js +++ b/src/warehouse/index.js @@ -208,6 +208,23 @@ function setDataFromInputAndComputeColumnTypes( level = 0, ) { if (!input || !isObject(input)) return; + if ( + (completePrefix.endsWith('context_traits_') || completePrefix === 'group_traits_') && + isStringLikeObject(input) + ) { + if (prefix === 'context_traits_') { + appendColumnNameAndType( + utils, + eventType, + `${prefix}`, + stringLikeObjectToString(input), + output, + columnTypes, + options, + ); + } + return; + } Object.keys(input).forEach((key) => { const isValidLegacyJSONPath = isValidLegacyJsonPathKey( eventType, @@ -272,6 +289,60 @@ function setDataFromInputAndComputeColumnTypes( }); } +function isNonNegativeInteger(str) { + if (str.length === 0) return false; + for (let i = 0; i < str.length; i++) { + const charCode = str.charCodeAt(i); + if (charCode < 48 || charCode > 57) return false; + } + return true; +} + +function isStringLikeObject(obj) { + if (typeof obj !== 'object' || obj === null) return false; + + const keys = Object.keys(obj); + if (keys.length === 0) return false; + + let minKey = Infinity; + let maxKey = -Infinity; + + for (let i = 0; i < keys.length; i++) { + const key = keys[i]; + const value = obj[key]; + + if (!isNonNegativeInteger(key)) return false; + if (typeof value !== 'string' || value.length !== 1) return false; + + const numKey = parseInt(key, 10); + if (numKey < minKey) minKey = numKey; + if (numKey > maxKey) maxKey = numKey; + } + + for (let i = minKey; i <= maxKey; i++) { + if (!keys.includes(i.toString())) return false; + } + + return (minKey === 0 || minKey === 1) && maxKey - minKey + 1 === keys.length; +} + +function stringLikeObjectToString(obj) { + if (!isStringLikeObject(obj)) { + return obj; // Return the original input if it's not a valid string-like object + } + + const keys = Object.keys(obj) + .map(Number) + .sort((a, b) => a - b); + let result = ''; + + for (let i = 0; i < keys.length; i++) { + result += obj[keys[i].toString()]; + } + + return result; +} + /* * uuid_ts and loaded_at datatypes are passed from here to create appropriate columns. * Corresponding values are inserted when loading into the warehouse diff --git a/test/__tests__/warehouse.test.js b/test/__tests__/warehouse.test.js index 2c89120686..83b24aee15 100644 --- a/test/__tests__/warehouse.test.js +++ b/test/__tests__/warehouse.test.js @@ -1233,4 +1233,191 @@ describe("isBlank", () => { expect(isBlank(testCase.input)).toEqual(testCase.expected); }); } -}); \ No newline at end of file +}); + +describe("context traits", () => { + const testCases = [ + { + name: "traits with string like object", + input: {"1":"f","2":"o", "3":"o"}, + expectedData: "foo", + expectedMetadata: "string", + expectedColumns: ["context_traits"], + }, + { + name: "traits with string like object with missing keys", + input: {"1":"a","3":"a"}, + expectedData: "a", + expectedMetadata: "string", + expectedColumns: ["context_traits_1", "context_traits_3"], + }, + { + name: "traits with empty object", + input: {}, + expectedData: {}, + expectedColumns: [], + }, + { + name: "traits with empty array", + input: [], + expectedData: [], + expectedMetadata: "array", + expectedColumns: [], + }, + { + name: "traits with null", + input: null, + expectedData: null, + expectedMetadata: "null", + expectedColumns: [], + }, + { + name: "traits with undefined", + input: undefined, + expectedData: undefined, + expectedMetadata: "undefined", + expectedColumns: [], + groupTypeColumns: [] + }, + { + name: "traits with string", + input: "already a string", + expectedData: "already a string", + expectedMetadata: "string", + expectedColumns: ["context_traits"], + }, + { + name: "traits with number", + input: 42, + expectedData: 42, + expectedMetadata: "int", + expectedColumns: ["context_traits"], + }, + { + name: "traits with boolean", + input: true, + expectedData: true, + expectedMetadata: "boolean", + expectedColumns: ["context_traits"], + }, + { + name: "traits with array", + input: ["a", "b", "cd"], + expectedData: ["a", "b", "cd"], + expectedMetadata: "string", + expectedColumns: ["context_traits"], + } + ]; + for (const t of testCases) { + it(`should return ${t.expectedData} for ${t.name}`, () => { + for (const e of eventTypes) { + let i = input(e); + i.message.context = {"traits": t.input}; + if (i.metadata) delete i.metadata.sourceCategory; + transformers.forEach((transformer, index) => { + const received = transformer.process(i); + if(t.expectedColumns.length === 0) { + expect(Object.keys(received[0].metadata.columns).join()).not.toMatch(/context_traits/g); + expect(Object.keys(received[0].data).join()).not.toMatch(/context_traits/g); + } + for (const column of t.expectedColumns) { + expect(received[0].metadata.columns[integrationCasedString(integrations[index], column)]).toEqual(t.expectedMetadata); + expect(received[0].data[integrationCasedString(integrations[index], column)]).toEqual(t.expectedData); + } + }); + } + }); + } +}) + +describe("group traits", () => { + const testCases = [ + { + name: "traits with string like object", + input: {"1":"f","2":"o", "3":"o"}, + expectedData: "foo", + expectedMetadata: "string", + expectedColumns: [], + }, + { + name: "traits with string like object with missing keys", + input: {"1":"a","3":"a"}, + expectedData: "a", + expectedMetadata: "string", + expectedColumns: ["_1", "_3"], + }, + { + name: "traits with empty object", + input: {}, + expectedData: {}, + expectedColumns: [], + }, + { + name: "traits with empty array", + input: [], + expectedData: [], + expectedMetadata: "array", + expectedColumns: [], + }, + { + name: "traits with null", + input: null, + expectedData: null, + expectedMetadata: "null", + expectedColumns: [], + }, + { + name: "traits with undefined", + input: undefined, + expectedData: undefined, + expectedMetadata: "undefined", + expectedColumns: [], + }, + { + name: "traits with string", + input: "already a string", + expectedData: "already a string", + expectedMetadata: "string", + expectedColumns: [], + }, + { + name: "traits with number", + input: 42, + expectedData: 42, + expectedMetadata: "int", + expectedColumns: [], + }, + { + name: "traits with boolean", + input: true, + expectedData: true, + expectedMetadata: "boolean", + expectedColumns: [], + }, + { + name: "traits with array", + input: ["a", "b", "cd"], + expectedData: ["a", "b", "cd"], + expectedMetadata: "string", + expectedColumns: [], + } + ]; + for (const t of testCases){ + it(`should return ${t.expectedData} for ${t.name}`, () => { + let i = input("group"); + i.message.traits = t.input; + if (i.metadata) delete i.metadata.sourceCategory; + transformers.forEach((transformer, index) => { + const received = transformer.process(i); + if(t.expectedColumns.length === 0) { + expect(Object.keys(received[0].metadata.columns).join()).not.toMatch(/group_traits/g); + expect(Object.keys(received[0].data).join()).not.toMatch(/group_traits/g); + } + for (const column of t.expectedColumns) { + expect(received[0].metadata.columns[integrationCasedString(integrations[index], column)]).toEqual(t.expectedMetadata); + expect(received[0].data[integrationCasedString(integrations[index], column)]).toEqual(t.expectedData); + } + }); + }); + } +})