diff --git a/packages/react-api/src/index.d.ts b/packages/react-api/src/index.d.ts index 108625970..e142e1667 100644 --- a/packages/react-api/src/index.d.ts +++ b/packages/react-api/src/index.d.ts @@ -7,4 +7,4 @@ export { executeModel as _executeModel } from './api/model'; export { default as FeaturesDroppedLoader } from './hooks/FeaturesDroppedLoader'; export { default as useCartoLayerProps } from './hooks/useCartoLayerProps'; -export { Credentials, UseCartoLayerFilterProps } from './types'; +export { Credentials, UseCartoLayerFilterProps, SourceProps } from './types'; diff --git a/packages/react-api/src/types.d.ts b/packages/react-api/src/types.d.ts index 09e6f7e18..9e857c498 100644 --- a/packages/react-api/src/types.d.ts +++ b/packages/react-api/src/types.d.ts @@ -2,6 +2,7 @@ import { DataFilterExtension, MaskExtension } from '@deck.gl/extensions/typed'; import { MAP_TYPES, API_VERSIONS } from '@deck.gl/carto/typed'; import { QueryParameters } from '@deck.gl/carto/typed'; import { FeatureCollection } from 'geojson'; +import { Provider } from '@carto/react-core'; type ApiVersionsType = typeof API_VERSIONS; type MapTypesType = typeof MAP_TYPES; @@ -34,7 +35,7 @@ export type SourceProps = { aggregationExp?: string; credentials?: Credentials; queryParameters?: QueryParameters; - provider?: 'bigquery' | 'postgres' | 'snowflake' | 'redshift' | 'databricks'; + provider?: Provider; }; export type LayerConfig = { diff --git a/packages/react-core/src/index.d.ts b/packages/react-core/src/index.d.ts index d35ca3a5a..a7249a0e6 100644 --- a/packages/react-core/src/index.d.ts +++ b/packages/react-core/src/index.d.ts @@ -28,6 +28,8 @@ export { groupValuesByColumn } from './operations/groupBy'; export { histogram } from './operations/histogram'; export { scatterPlot } from './operations/scatterPlot'; +export { Provider } from './operations/constants/Provider' + export { FilterTypes as _FilterTypes } from './filters/FilterTypes'; export { diff --git a/packages/react-core/src/index.js b/packages/react-core/src/index.js index 0fc08528a..2b96cc534 100644 --- a/packages/react-core/src/index.js +++ b/packages/react-core/src/index.js @@ -29,6 +29,8 @@ export { groupValuesByColumn } from './operations/groupBy'; export { histogram } from './operations/histogram'; export { scatterPlot } from './operations/scatterPlot'; +export { Provider } from './operations/constants/Provider'; + export { FilterTypes as _FilterTypes } from './filters/FilterTypes'; export { diff --git a/packages/react-core/src/operations/constants/Provider.js b/packages/react-core/src/operations/constants/Provider.js new file mode 100644 index 000000000..6cd12030f --- /dev/null +++ b/packages/react-core/src/operations/constants/Provider.js @@ -0,0 +1,12 @@ +/** + * Enum for the different connections providers + * @enum {string} + * @readonly + */ +export const Provider = Object.freeze({ + BigQuery: 'bigquery', + Redshift: 'redshift', + Postgres: 'postgres', + Snowflake: 'snowflake', + Databricks: 'databricks' +}); diff --git a/packages/react-core/src/operations/constants/Provider.ts b/packages/react-core/src/operations/constants/Provider.ts new file mode 100644 index 000000000..9020e5c86 --- /dev/null +++ b/packages/react-core/src/operations/constants/Provider.ts @@ -0,0 +1,7 @@ +export enum Provider { + BigQuery = 'bigquery', + Redshift = 'redshift', + Postgres = 'postgres', + Snowflake = 'snowflake', + Databricks = 'databricks' +} \ No newline at end of file diff --git a/packages/react-redux/src/slices/cartoSlice.d.ts b/packages/react-redux/src/slices/cartoSlice.d.ts index 37fdbef0e..dd54ca2d9 100644 --- a/packages/react-redux/src/slices/cartoSlice.d.ts +++ b/packages/react-redux/src/slices/cartoSlice.d.ts @@ -1,14 +1,19 @@ -import { Credentials } from '@carto/react-api/'; -import { SourceProps } from '@carto/react-api/types'; +import { Credentials, SourceProps } from '@carto/react-api/'; import { FiltersLogicalOperators, Viewport, _FilterTypes } from '@carto/react-core'; import { CartoBasemapsNames, GMapsBasemapsNames } from '@carto/react-basemaps/'; import { InitialCartoState, CartoState, ViewState } from '../types'; import { AnyAction, Reducer } from 'redux'; import { Feature, Polygon, MultiPolygon } from 'geojson'; +type FilterValues = string[] | number[] | number[][] + +export type SourceFilters = { + [column: string]: Partial; }>> +} + type Source = SourceProps & { id: string; - filters?: any; + filters?: SourceFilters; filtersLogicalOperator?: FiltersLogicalOperators; isDroppingFeatures?: boolean; }; @@ -23,7 +28,7 @@ type BasemapName = CartoBasemapsNames | GMapsBasemapsNames; type FilterBasic = { type: _FilterTypes; - values: string[] | number[] | number[][]; + values: FilterValues; owner?: string; params?: Record; }; @@ -41,6 +46,7 @@ type SpatialFilter = { type Filter = FilterBasic & FilterCommonProps; + type FeaturesData = { sourceId: string; features: []; diff --git a/packages/react-redux/src/slices/cartoSlice.js b/packages/react-redux/src/slices/cartoSlice.js index 315932203..b7b79af9c 100644 --- a/packages/react-redux/src/slices/cartoSlice.js +++ b/packages/react-redux/src/slices/cartoSlice.js @@ -206,6 +206,7 @@ export const createCartoSlice = (initialState) => { * @param {string} data.type - type of source. Possible values are sql or bigquery. * @param {object=} data.credentials - (optional) Custom credentials to be used in the source. * @param {string} data.connection - connection name for CARTO 3 source. + * @param {import('./cartoSlice').SourceFilters=} data.filters - logical operator that defines how filters for different columns are joined together. * @param {FiltersLogicalOperators=} data.filtersLogicalOperator - logical operator that defines how filters for different columns are joined together. * @param {import('@deck.gl/carto/typed').QueryParameters} data.queryParameters - SQL query parameters. * @param {string=} data.geoColumn - (optional) name of column containing geometries or spatial index data. @@ -218,6 +219,7 @@ export const addSource = ({ type, credentials, connection, + filters = undefined, filtersLogicalOperator = FiltersLogicalOperators.AND, queryParameters = [], geoColumn, @@ -231,6 +233,7 @@ export const addSource = ({ type, credentials, connection, + ...(filters && { filters }), filtersLogicalOperator, queryParameters, geoColumn, diff --git a/packages/react-widgets/__tests__/models/utils.test.js b/packages/react-widgets/__tests__/models/utils.test.js index 267e3b9e8..2bce30deb 100644 --- a/packages/react-widgets/__tests__/models/utils.test.js +++ b/packages/react-widgets/__tests__/models/utils.test.js @@ -1,11 +1,11 @@ import { MAP_TYPES, API_VERSIONS } from '@deck.gl/carto'; import { - formatTableNameWithFilters, + sourceAndFiltersToSQL, wrapModelCall, formatOperationColumn, normalizeObjectKeys } from '../../src/models/utils'; -import { AggregationTypes, _filtersToSQL } from '@carto/react-core'; +import { AggregationTypes, Provider, _filtersToSQL } from '@carto/react-core'; const V2_SOURCE = { id: '__test__', @@ -22,7 +22,8 @@ const V3_SOURCE = { data: '__test__', credentials: { apiVersion: API_VERSIONS.V3 - } + }, + provider: Provider.BigQuery }; const SOURCE_WITH_FILTERS = { @@ -89,29 +90,29 @@ describe('utils', () => { }); }); - describe('formatTableNameWithFilters', () => { + describe('sourceAndFiltersToSQL', () => { test('should format query sources correctly', () => { const source = { ...V3_SOURCE, type: MAP_TYPES.QUERY, data: 'SELECT * FROM test;' }; - const query = formatTableNameWithFilters({ source }); + const query = sourceAndFiltersToSQL(source); - expect(query).toBe(`(SELECT * FROM test) foo`); + expect(query).toBe(`SELECT * FROM (SELECT * FROM test) __source_query `); }); test('should format table sources correctly', () => { - const query = formatTableNameWithFilters({ source: V3_SOURCE }); + const query = sourceAndFiltersToSQL(V3_SOURCE); - expect(query).toBe(V3_SOURCE.data); + expect(query).toBe(`SELECT * FROM \`${V3_SOURCE.data}\` `); }); test('should format source with filters correctly', () => { - const query = formatTableNameWithFilters({ source: SOURCE_WITH_FILTERS }); + const query = sourceAndFiltersToSQL(SOURCE_WITH_FILTERS); expect(query).toBe( - `${SOURCE_WITH_FILTERS.data} WHERE (column_1 in('value_1','value_2'))` + `SELECT * FROM \`${SOURCE_WITH_FILTERS.data}\` WHERE (column_1 in('value_1','value_2'))` ); }); }); diff --git a/packages/react-widgets/src/index.d.ts b/packages/react-widgets/src/index.d.ts index e4d62b187..739f25f82 100644 --- a/packages/react-widgets/src/index.d.ts +++ b/packages/react-widgets/src/index.d.ts @@ -27,3 +27,4 @@ export { default as FeatureSelectionWidget } from './widgets/FeatureSelectionWid export { default as FeatureSelectionLayer } from './layers/FeatureSelectionLayer'; export { default as useGeocoderWidgetController } from './hooks/useGeocoderWidgetController'; export { WidgetState, WidgetStateType } from './types'; +export { isRemoteCalculationSupported as _isRemoteCalculationSupported, sourceAndFiltersToSQL as _sourceAndFiltersToSQL } from './models/utils'; diff --git a/packages/react-widgets/src/index.js b/packages/react-widgets/src/index.js index 23cee17b5..ec25b3cc1 100644 --- a/packages/react-widgets/src/index.js +++ b/packages/react-widgets/src/index.js @@ -23,4 +23,7 @@ export { default as useSourceFilters } from './hooks/useSourceFilters'; export { default as FeatureSelectionLayer } from './layers/FeatureSelectionLayer'; export { default as useGeocoderWidgetController } from './hooks/useGeocoderWidgetController'; export { WidgetStateType } from './hooks/useWidgetFetch'; -export { isRemoteCalculationSupported as _isRemoteCalculationSupported } from './models/utils'; +export { + isRemoteCalculationSupported as _isRemoteCalculationSupported, + sourceAndFiltersToSQL as _sourceAndFiltersToSQL +} from './models/utils'; diff --git a/packages/react-widgets/src/models/fqn.d.ts b/packages/react-widgets/src/models/fqn.d.ts new file mode 100644 index 000000000..b8bad71f0 --- /dev/null +++ b/packages/react-widgets/src/models/fqn.d.ts @@ -0,0 +1,69 @@ +import { Provider } from "@carto/react-core" + +export type Fragment = { + name: string, + quoted: boolean +} +export type GetIdentifierOptions = { + quoted?: boolean, + safe?: boolean +} + +export type GetObjectIdentifierOptions = GetIdentifierOptions & { + suffix?: string +} + +export enum ParsingMode { + LeftToRight = 'leftToRight', + RightToLeft = 'rightToLeft' +} + +export class FullyQualifiedName { + protected databaseFragment: Fragment | null + protected schemaFragment: Fragment | null + protected objectFragment: Fragment | null + + protected provider: Provider + protected originalFQN: string + protected parsingMode: ParsingMode + + static empty (provider: Provider, mode: ParsingMode): FullyQualifiedName + + constructor (fqn: string, provider: Provider, mode?: ParsingMode) + + public toString (): string + + public getDatabaseName (options: { quoted?: boolean, safe: Safe }): Safe extends true ? string | null : string + public getDatabaseName (options?: GetIdentifierOptions): string + public getDatabaseName (options?: GetIdentifierOptions): string | null + + public getSchemaName (options: { quoted?: boolean, safe: Safe }): Safe extends true ? string | null : string + public getSchemaName (options?: GetIdentifierOptions): string + public getSchemaName (options?: GetIdentifierOptions): string | null + + public getObjectName (options: { quoted?: boolean, safe: Safe, suffix?: string }): Safe extends true ? string | null : string + public getObjectName (options?: GetObjectIdentifierOptions): string + public getObjectName (options?: GetObjectIdentifierOptions): string | null | boolean + + public setDatabaseName (databaseName: string): void + + public setSchemaName (schemaName: string): void + + public setObjectName (objectName: string): void + + private parseFQN (fqn: string): Array + + private getFragmentName (fragment: Fragment): string + + private quoteFragmentName (fragment: Fragment, suffix): string + + private unquoteFragmentName (fragment: Fragment, suffix): string + + private isQuotedName (name: string): boolean + + private quoteName (name: string): string + + private unquoteName (name: string, onlyDelimiters): string + + private quoteExternalIdentifier (identifier: string): string +} \ No newline at end of file diff --git a/packages/react-widgets/src/models/fqn.js b/packages/react-widgets/src/models/fqn.js new file mode 100644 index 000000000..5122ff784 --- /dev/null +++ b/packages/react-widgets/src/models/fqn.js @@ -0,0 +1,317 @@ +import { Provider } from '@carto/react-core'; + +export const ParsingMode = Object.freeze({ + LeftToRight: 'leftToRight', + RightToLeft: 'rightToLeft' +}); + +const bqIdentifierRegex = '((?:[^`.]*?)|`(?:(?:[^`.])*?)`)'; +const identifierRegex = '((?:[^".]*?)|"(?:(?:[^"]|"")*?)")'; +const databricksIdentifierRegex = '((?:[^`.]*?)|`(?:(?:[^`]|``)*?)`)'; +const fqnParseRegex = { + [Provider.BigQuery]: new RegExp( + `^\`?${bqIdentifierRegex}(?:\\.${bqIdentifierRegex})?(?:\\.${bqIdentifierRegex})?\`?$` + ), + [Provider.Postgres]: new RegExp( + `^${identifierRegex}(?:\\.${identifierRegex})?(?:\\.${identifierRegex})?$` + ), + [Provider.Snowflake]: new RegExp( + `^${identifierRegex}(?:\\.${identifierRegex})?(?:\\.${identifierRegex})?$` + ), + [Provider.Redshift]: new RegExp( + `^${identifierRegex}(?:\\.${identifierRegex})?(?:\\.${identifierRegex})?$` + ), + [Provider.Databricks]: new RegExp( + `^${databricksIdentifierRegex}(?:\\.${databricksIdentifierRegex})?(?:\\.${databricksIdentifierRegex})?$` + ) +}; + +const escapeCharacter = { + [Provider.BigQuery]: '`', + [Provider.Postgres]: '"', + [Provider.Snowflake]: '"', + [Provider.Redshift]: '"', + [Provider.Databricks]: '`' +}; + +const nameNeedsQuotesChecker = { + [Provider.BigQuery]: /^[^a-z_]|[^a-z_\d]/i, + [Provider.Postgres]: /^[^a-z_]|[^a-z_\d$]/i, + [Provider.Snowflake]: /^[^a-z_]|[^a-z_\d$]/i, + [Provider.Redshift]: /^[^a-z_]|[^a-z_\d$]/i, + [Provider.Databricks]: /[^a-z_\d]/i +}; + +const caseSensitivenessChecker = { + [Provider.BigQuery]: null, + [Provider.Postgres]: /[A-Z]/, + [Provider.Snowflake]: /[a-z]/, + [Provider.Redshift]: null, + [Provider.Databricks]: null +}; + +export class FullyQualifiedName { + databaseFragment = null; + schemaFragment = null; + objectFragment = null; + + provider; + originalFQN; + parsingMode; + + static empty(provider, mode = ParsingMode.RightToLeft) { + return new FullyQualifiedName('', provider, mode); + } + + constructor(fqn, provider, mode = ParsingMode.RightToLeft) { + this.originalFQN = fqn; + this.provider = provider; + this.parsingMode = mode; + + const fqnFragments = this.parseFQN(this.originalFQN).filter( + (fragment) => fragment !== null + ); + + if (mode === ParsingMode.LeftToRight) { + this.databaseFragment = fqnFragments[0]; + this.schemaFragment = fqnFragments[1]; + this.objectFragment = fqnFragments[2]; + } + + if (mode === ParsingMode.RightToLeft) { + if (fqnFragments[2]) { + this.databaseFragment = fqnFragments[0]; + this.schemaFragment = fqnFragments[1]; + this.objectFragment = fqnFragments[2]; + } else if (fqnFragments[1]) { + this.schemaFragment = fqnFragments[0]; + this.objectFragment = fqnFragments[1]; + } else { + this.objectFragment = fqnFragments[0]; + } + } + + // @ts-ignore + if ( + this.provider === Provider.BigQuery && + this.databaseFragment && + /[A-Z]/.test(this.databaseFragment.name) + ) { + throw new Error('BigQuery does not support project ids with uppercase letters'); + } + } + + toString() { + let fullFQN = null; + if (this.parsingMode === ParsingMode.LeftToRight) { + if (this.objectFragment) { + if (!this.schemaFragment || !this.databaseFragment) { + throw new Error(this.originalFQN); + } + fullFQN = `${this.getFragmentName(this.databaseFragment)}.${this.getFragmentName( + this.schemaFragment + )}.${this.getFragmentName(this.objectFragment)}`; + } else if (this.schemaFragment) { + if (!this.databaseFragment) { + throw new Error(this.originalFQN); + } + fullFQN = `${this.getFragmentName(this.databaseFragment)}.${this.getFragmentName( + this.schemaFragment + )}`; + } else if (this.databaseFragment) { + fullFQN = this.getFragmentName(this.databaseFragment); + } + } else { + if (this.databaseFragment) { + if (!this.schemaFragment || !this.objectFragment) { + throw new Error(this.originalFQN); + } + fullFQN = `${this.getFragmentName(this.databaseFragment)}.${this.getFragmentName( + this.schemaFragment + )}.${this.getFragmentName(this.objectFragment)}`; + } else if (this.schemaFragment) { + if (!this.objectFragment) { + throw new Error(this.originalFQN); + } + fullFQN = `${this.getFragmentName(this.schemaFragment)}.${this.getFragmentName( + this.objectFragment + )}`; + } else if (this.objectFragment) { + fullFQN = this.getFragmentName(this.objectFragment); + } + } + + if (!fullFQN) { + throw new Error(this.originalFQN); + } + + if (this.provider === Provider.BigQuery) { + return `\`${fullFQN}\``; + } + return fullFQN; + } + + getDatabaseName(options) { + if (!this.databaseFragment) { + if (options?.safe) { + return null; + } + throw new Error('Database name is not defined'); + } + return options?.quoted + ? this.quoteFragmentName(this.databaseFragment) + : this.unquoteFragmentName(this.databaseFragment); + } + + getSchemaName(options) { + if (!this.schemaFragment) { + if (options?.safe) { + return null; + } + throw new Error('Schema name is not defined'); + } + return options?.quoted + ? this.quoteFragmentName(this.schemaFragment) + : this.unquoteFragmentName(this.schemaFragment); + } + + getObjectName(options) { + if (!this.objectFragment) { + if (options?.safe) { + return null; + } + throw new Error('Object name is not defined'); + } + + return options?.quoted + ? this.quoteFragmentName(this.objectFragment, options?.suffix) + : this.unquoteFragmentName(this.objectFragment, options?.suffix); + } + + setDatabaseName(databaseName) { + if (this.provider === Provider.BigQuery && /[A-Z]/.test(databaseName)) { + throw new Error('BigQuery does not support project ids with uppercase letters'); + } + const databaseFqnFragment = this.parseFQN(this.quoteExternalIdentifier(databaseName)); + this.databaseFragment = databaseFqnFragment[0]; + } + + setSchemaName(schemaName) { + const schemaFqnFragment = this.parseFQN(this.quoteExternalIdentifier(schemaName)); + this.schemaFragment = schemaFqnFragment[0]; + } + + setObjectName(objectName) { + const objectFqnFragment = this.parseFQN(this.quoteExternalIdentifier(objectName)); + this.objectFragment = objectFqnFragment[0]; + } + + parseFQN(fqn) { + const matchResult = fqn.match(fqnParseRegex[this.provider]); + if (!matchResult) { + throw new Error(this.originalFQN); + } + + const identifiers = matchResult.slice(1, 4); + + const fragments = identifiers.map((name) => { + if (!name) { + return null; + } + + return { + name: name, + quoted: this.isQuotedName(name) + }; + }); + + return fragments; + } + + getFragmentName(fragment) { + if (this.provider === Provider.BigQuery) { + return this.unquoteFragmentName(fragment); + } + + return this.quoteFragmentName(fragment); + } + + quoteFragmentName(fragment, suffix = '') { + if (suffix) { + const parsedSuffix = this.parseFQN(`_${suffix}`)[0]?.name || ''; + + if (fragment.quoted) { + return this.quoteName(`${this.unquoteName(fragment.name, true)}${parsedSuffix}`); + } + + if ( + nameNeedsQuotesChecker[this.provider].test(fragment.name) || + nameNeedsQuotesChecker[this.provider].test(parsedSuffix) + ) { + return this.quoteName(`${fragment.name}${parsedSuffix}`); + } + return `${fragment.name}${parsedSuffix}`; + } + + if (fragment.quoted || !nameNeedsQuotesChecker[this.provider].test(fragment.name)) { + return fragment.name; + } + return this.quoteName(fragment.name); + } + + unquoteFragmentName(fragment, suffix = '') { + const unquotedName = `${this.unquoteName(fragment.name)}${ + suffix ? this.parseFQN(`_${suffix}`)[0]?.name : '' + }`; + const needsQuotes = + fragment.quoted || nameNeedsQuotesChecker[this.provider].test(unquotedName); + + // eslint-disable-next-line default-case + switch (this.provider) { + case Provider.BigQuery: + case Provider.Databricks: + return unquotedName; + case Provider.Postgres: + return needsQuotes ? unquotedName : unquotedName.toLowerCase(); + case Provider.Snowflake: + return needsQuotes ? unquotedName : unquotedName.toUpperCase(); + case Provider.Redshift: + return unquotedName.replace(/[A-Z]/g, (match) => match.toLowerCase()); + } + } + + isQuotedName(name) { + return ( + name.startsWith(escapeCharacter[this.provider]) && + name.endsWith(escapeCharacter[this.provider]) + ); + } + + quoteName(name) { + return `${escapeCharacter[this.provider]}${name}${escapeCharacter[this.provider]}`; + } + + unquoteName(name, onlyDelimiters = false) { + const escapeChar = escapeCharacter[this.provider]; + + let unquotedName = name.replace(new RegExp(`^${escapeChar}|${escapeChar}$`, 'g'), ''); + if (!onlyDelimiters) { + unquotedName = unquotedName.replace( + new RegExp(`${escapeChar}{2}`, 'g'), + escapeChar + ); + } + return unquotedName; + } + + quoteExternalIdentifier(identifier) { + const escapeChar = escapeCharacter[this.provider]; + return nameNeedsQuotesChecker[this.provider].test(identifier) || + caseSensitivenessChecker[this.provider]?.test(identifier) + ? `${escapeChar}${identifier.replace( + new RegExp(escapeChar, 'g'), + '$&$&' + )}${escapeChar}` + : identifier; + } +} diff --git a/packages/react-widgets/src/models/utils.d.ts b/packages/react-widgets/src/models/utils.d.ts new file mode 100644 index 000000000..4b1e753f3 --- /dev/null +++ b/packages/react-widgets/src/models/utils.d.ts @@ -0,0 +1,8 @@ +import { SourceProps } from "@carto/react-api"; +import { FiltersLogicalOperators, Provider, _FilterTypes } from "@carto/react-core"; +import { SourceFilters } from "@carto/react-redux"; +import { MAP_TYPES } from "@deck.gl/carto/typed"; + +export function isRemoteCalculationSupported(prop: { source: SourceProps }): boolean + +export function sourceAndFiltersToSQL(props: { data: string, filters?: SourceFilters, filtersLogicalOperator?: FiltersLogicalOperators, provider: Provider, type: typeof MAP_TYPES }): string \ No newline at end of file diff --git a/packages/react-widgets/src/models/utils.js b/packages/react-widgets/src/models/utils.js index 222999156..d6b1ef896 100644 --- a/packages/react-widgets/src/models/utils.js +++ b/packages/react-widgets/src/models/utils.js @@ -2,8 +2,10 @@ import { MAP_TYPES, API_VERSIONS } from '@deck.gl/carto/typed'; import { AggregationTypes, getSpatialIndexFromGeoColumn, - _filtersToSQL + _filtersToSQL, + Provider } from '@carto/react-core'; +import { FullyQualifiedName } from './fqn'; export function isRemoteCalculationSupported(props) { const { source } = props; @@ -48,16 +50,53 @@ export function wrapModelCall(props, fromLocal, fromRemote) { } } -export function formatTableNameWithFilters(props) { - const { source } = props; - const { data, filters, filtersLogicalOperator } = source; +const SOURCE_QUERY_ALIAS = '__source_query'; +export function sourceAndFiltersToSQL({ + data, + filters, + filtersLogicalOperator, + provider, + type +}) { const whereClause = _filtersToSQL(filters, filtersLogicalOperator); const formattedSourceData = - source.type === MAP_TYPES.QUERY ? `(${data.replace(';', '')}) foo` : data; + type === MAP_TYPES.QUERY + ? `(${sanitizeSQLSource(data)}) ${SOURCE_QUERY_ALIAS}` + : getSqlEscapedSource(data, provider); + + return `SELECT * FROM ${formattedSourceData} ${whereClause}`; +} + +function sanitizeSQLSource(sql) { + return sql.trim().replace(/;$/, ''); +} + +function getSqlEscapedSource(table, provider) { + const fqn = new FullyQualifiedName(table, provider); - return `${formattedSourceData} ${whereClause}`.trim(); + if (provider === Provider.Snowflake) { + if (!fqn.getSchemaName({ safe: true })) { + fqn.setSchemaName('PUBLIC'); + } + } + + if (provider === Provider.Postgres || provider === Provider.Redshift) { + if (!fqn.getSchemaName({ safe: true })) { + fqn.setSchemaName('public'); + } + } + + if (provider === Provider.Databricks) { + if (!fqn.getDatabaseName({ safe: true })) { + throw new Error('Database name is required for Databricks'); + } + if (!fqn.getSchemaName({ safe: true })) { + fqn.setSchemaName('default'); + } + } + return fqn.toString(); } // Due to each data warehouse has its own behavior with columns,