@@ -1770,6 +1770,145 @@ const createTemplateTestSuite = (config: TemplateTestConfig) => {
17701770 expect ( apiData ) . to . be . an ( "array" ) ;
17711771 } ) ;
17721772
1773+ // Extra fields test for Python (ENG-1617)
1774+ // Tests that IngestApi accepts payloads with extra fields when the model has extra='allow'.
1775+ // Extra fields are passed through to streaming functions and stored in a JSON column.
1776+ //
1777+ // KEY CONCEPTS:
1778+ // - IngestApi/Stream with extra='allow': CAN accept variable fields
1779+ // - OlapTable: Requires fixed schema (ClickHouse needs to know columns)
1780+ // - Transform: Receives ALL fields via model_extra, outputs to fixed schema with JSON column
1781+ it ( "should pass extra fields to streaming function via Pydantic extra='allow' (PY)" , async function ( ) {
1782+ this . timeout ( TIMEOUTS . TEST_SETUP_MS ) ;
1783+
1784+ const userId = randomUUID ( ) ;
1785+ const timestamp = new Date ( ) . toISOString ( ) ;
1786+
1787+ // Send data with known fields plus arbitrary extra fields
1788+ await withRetries (
1789+ async ( ) => {
1790+ const response = await fetch (
1791+ `${ SERVER_CONFIG . url } /ingest/userEventIngestApi` ,
1792+ {
1793+ method : "POST" ,
1794+ headers : { "Content-Type" : "application/json" } ,
1795+ body : JSON . stringify ( {
1796+ // Known fields defined in the model (snake_case for Python)
1797+ timestamp : timestamp ,
1798+ event_name : "page_view" ,
1799+ user_id : userId ,
1800+ org_id : "org-123" ,
1801+ // Extra fields - allowed by extra='allow', passed to streaming function
1802+ customProperty : "custom-value" ,
1803+ pageUrl : "/dashboard" ,
1804+ sessionDuration : 120 ,
1805+ nested : {
1806+ level1 : "value1" ,
1807+ level2 : { deep : "nested" } ,
1808+ } ,
1809+ } ) ,
1810+ } ,
1811+ ) ;
1812+ if ( ! response . ok ) {
1813+ const text = await response . text ( ) ;
1814+ throw new Error ( `${ response . status } : ${ text } ` ) ;
1815+ }
1816+ } ,
1817+ { attempts : 5 , delayMs : 500 } ,
1818+ ) ;
1819+
1820+ // Wait for the transform to process and write to output table
1821+ await waitForDBWrite (
1822+ devProcess ! ,
1823+ "UserEventOutput" ,
1824+ 1 ,
1825+ 60_000 ,
1826+ "local" ,
1827+ `user_id = '${ userId } '` ,
1828+ ) ;
1829+
1830+ // Verify the data was written correctly
1831+ const client = createClient ( CLICKHOUSE_CONFIG ) ;
1832+ const result = await client . query ( {
1833+ query : `
1834+ SELECT
1835+ user_id,
1836+ event_name,
1837+ org_id,
1838+ properties
1839+ FROM local.UserEventOutput
1840+ WHERE user_id = '${ userId } '
1841+ ` ,
1842+ format : "JSONEachRow" ,
1843+ } ) ;
1844+
1845+ const rows : any [ ] = await result . json ( ) ;
1846+ await client . close ( ) ;
1847+
1848+ if ( rows . length === 0 ) {
1849+ throw new Error (
1850+ `No data found in UserEventOutput for user_id ${ userId } ` ,
1851+ ) ;
1852+ }
1853+
1854+ const row = rows [ 0 ] ;
1855+
1856+ // Verify known fields are correctly passed through (snake_case for Python)
1857+ if ( row . event_name !== "page_view" ) {
1858+ throw new Error (
1859+ `Expected event_name to be 'page_view', got '${ row . event_name } '` ,
1860+ ) ;
1861+ }
1862+ if ( row . org_id !== "org-123" ) {
1863+ throw new Error (
1864+ `Expected org_id to be 'org-123', got '${ row . org_id } '` ,
1865+ ) ;
1866+ }
1867+
1868+ // Verify extra fields are stored in the properties JSON column
1869+ if ( row . properties === undefined ) {
1870+ throw new Error ( "Expected properties JSON column to exist" ) ;
1871+ }
1872+
1873+ // Parse properties if it's a string (ClickHouse may return JSON as string)
1874+ const properties =
1875+ typeof row . properties === "string" ?
1876+ JSON . parse ( row . properties )
1877+ : row . properties ;
1878+
1879+ // Verify extra fields were received by streaming function via model_extra
1880+ if ( properties . customProperty !== "custom-value" ) {
1881+ throw new Error (
1882+ `Expected properties.customProperty to be 'custom-value', got '${ properties . customProperty } '. Properties: ${ JSON . stringify ( properties ) } ` ,
1883+ ) ;
1884+ }
1885+ if ( properties . pageUrl !== "/dashboard" ) {
1886+ throw new Error (
1887+ `Expected properties.pageUrl to be '/dashboard', got '${ properties . pageUrl } '` ,
1888+ ) ;
1889+ }
1890+ // Note: ClickHouse JSON may return numbers as strings
1891+ if ( Number ( properties . sessionDuration ) !== 120 ) {
1892+ throw new Error (
1893+ `Expected properties.sessionDuration to be 120, got '${ properties . sessionDuration } '` ,
1894+ ) ;
1895+ }
1896+ if (
1897+ ! properties . nested ||
1898+ properties . nested . level1 !== "value1" ||
1899+ ! properties . nested . level2 ||
1900+ properties . nested . level2 . deep !== "nested"
1901+ ) {
1902+ throw new Error (
1903+ `Expected nested object to be preserved, got '${ JSON . stringify ( properties . nested ) } '` ,
1904+ ) ;
1905+ }
1906+
1907+ console . log (
1908+ "✅ Extra fields test passed (Python) - extra fields received by streaming function via model_extra and stored in properties column" ,
1909+ ) ;
1910+ } ) ;
1911+
17731912 // DateTime precision test for Python
17741913 it ( "should preserve microsecond precision with clickhouse_datetime64 annotations via streaming transform (PY)" , async function ( ) {
17751914 this . timeout ( TIMEOUTS . TEST_SETUP_MS ) ;
0 commit comments