diff --git a/examples/full-jaffle-shop-demo/dbt/models/orders.yml b/examples/full-jaffle-shop-demo/dbt/models/orders.yml index 5c719d1ae5913..c78e4e89a37de 100644 --- a/examples/full-jaffle-shop-demo/dbt/models/orders.yml +++ b/examples/full-jaffle-shop-demo/dbt/models/orders.yml @@ -1,542 +1,555 @@ version: 2 models: - - name: orders - description: | - This table has basic information about orders, as well as some derived - facts based on payments + - name: orders + description: | + This table has basic information about orders, as well as some derived + facts based on payments - {{ doc("orders_status") }} - config: - tags: ["core", "ai"] - meta: - pre_aggregates: - - name: orders_daily_avg_demo - dimensions: - - status - metrics: - - average_order_size - - total_order_amount - time_dimension: order_date - granularity: day - - name: orders_daily_avg_demo_2 - dimensions: - - status - - order_source - - order_priority - - customers.first_name - metrics: - - average_order_size - - total_order_amount - - total_completed_order_amount - time_dimension: order_date - granularity: day - primary_key: order_id - spotlight: - categories: - - sales - joins: - - join: customers - sql_on: ${customers.customer_id} = ${orders.customer_id} - relationship: many-to-one - label: Order Customer - description: The customer who placed this order (many orders can belong to one customer) - metrics: - completion_percentage: - spotlight: - owner: demo@lightdash.com - type: number - sql: ${total_completed_order_amount}/${total_order_amount} - format: percent - ai_hint: | - Ratio of completed orders to total orders - Completion percentage of orders. - drivers: - - total_order_amount - - total_completed_order_amount - default_time_dimension: - field: order_date - interval: MONTH - explores: - orders_with_custom_dims: - label: Orders with Custom Dimensions - # No need to specify joins - they are inherited from the parent model - additional_dimensions: - full_name: - type: string - sql: "CONCAT(${customers.first_name}, ' ', ${customers.last_name})" - label: Customer Full Name - order_value_category: - type: string - sql: "CASE WHEN ${orders.amount} > 100 THEN 'high' ELSE 'low' END" - label: Order Value Category - order_month: - type: date - sql: DATE(${orders.order_date}) - time_intervals: [MONTH] - columns: - - name: order_id - tests: - - unique - - not_null - description: This is a unique identifier for an order - config: + {{ doc("orders_status") }} + config: + tags: ['core', 'ai'] meta: - metrics: - unique_order_count: - type: count_distinct - spotlight: - categories: ["revenue_growth"] - completed_order_count: - label: Completed order count - description: Total number of completed orders - type: count_distinct - filters: - - is_completed: "true" - spotlight: - categories: ["revenue_growth"] - drivers: - - unique_order_count - completed_or_shipped_order_count: - label: Completed or Shipped Order Count - type: count_distinct - filters: - - status: - - completed - - shipped - meta: - dimension: - type: number - - - name: is_completed - description: Boolean indicating if status is completed - config: - meta: - metrics: - fulfillment_rate: - type: average - format: percent - round: 1 - sql: CASE WHEN ${is_completed} THEN 1 ELSE 0 END - show_underlying_values: - - amount - - customers.first_name - spotlight: - owner: demo2@lightdash.com - categories: ["revenue_growth"] - drivers: - - unique_order_count - fulfillment_rate_with_format_expression: - type: average - format: "#,##0.0%" - sql: CASE WHEN ${is_completed} THEN 1 ELSE 0 END - show_underlying_values: - - amount - - customers.first_name - meta: - dimension: - type: boolean - - name: customer_id - description: Foreign key to the customers table - tests: - - not_null - - relationships: - to: ref('customers') - field: customer_id - meta: - dimension: - type: number - - name: date_dim_param_test - description: "Example of date based on another dimension" - meta: - dimension: - sql: | - CASE - WHEN ${TABLE}.order_date <= ${lightdash.parameters.date_dim_parameter} - THEN 'param is greater' - WHEN ${TABLE}.order_date >= ${lightdash.parameters.date_dim_parameter} - THEN 'param is smaller' - END - spotlight: - filter_by: false - segment_by: false - type: string - - name: date_custom_param_test - description: "Example of custom date" - meta: - dimension: - sql: | - CASE - WHEN ${TABLE}.order_date <= ${lightdash.parameters.date_custom_parameter} - THEN 'I feel less than...' - WHEN ${TABLE}.order_date >= ${lightdash.parameters.date_custom_parameter} - THEN 'I feel great!!!' - END - spotlight: - filter_by: false - segment_by: false - type: string - - name: order_date - description: Date (UTC) that the order was placed - config: - meta: - dimension: - type: date - time_intervals: ["DAY", "WEEK", "MONTH", "YEAR", "MONTH_NAME", "DAY_OF_WEEK_NAME", "QUARTER_NAME", "fiscal_quarter", "biweekly"] - metrics: - date_of_first_order: - type: min - date_of_most_recent_order: - type: max - meta: - dimension: - type: date - - name: status - description: '{{ doc("orders_status") }}' - config: - meta: - dimension: - colors: - # Hex colors are supported in both chart config and echarts - "placed": "#e6fa0f" - "completed": "#00FF00" - tests: - - accepted_values: - values: - - placed - - shipped - - completed - - return_pending - - returned - meta: - dimension: - type: string - - name: amount - description: Total amount (USD) of the order - tests: - - not_null - config: - meta: - dimension: - format: usd - round: 2 - metrics: - average_order_size: - type: average - format: usd - round: 2 - spotlight: - categories: ["revenue_growth"] - drivers: - - total_order_amount - - customers.unique_customer_count # cross-table driver example - total_order_amount: - type: sum - format: usd - round: 2 - default_time_dimension: + # break attributes (string) with commas and perform IN operation + sql_filter: ${TABLE}.status != 'returned' AND ${TABLE}.status = ANY(string_to_array(${ld.attributes.statuses}, ',')) + pre_aggregates: + - name: orders_daily_avg_demo + dimensions: + - status + metrics: + - average_order_size + - total_order_amount + time_dimension: order_date + granularity: day + - name: orders_daily_avg_demo_2 + dimensions: + - status + - order_source + - order_priority + - customers.first_name + metrics: + - average_order_size + - total_order_amount + - total_completed_order_amount + time_dimension: order_date + granularity: day + primary_key: order_id + spotlight: + categories: + - sales + joins: + - join: customers + sql_on: ${customers.customer_id} = ${orders.customer_id} + relationship: many-to-one + label: Order Customer + description: The customer who placed this order (many orders can belong to one customer) + metrics: + completion_percentage: + spotlight: + owner: demo@lightdash.com + type: number + sql: ${total_completed_order_amount}/${total_order_amount} + format: percent + ai_hint: | + Ratio of completed orders to total orders + Completion percentage of orders. + drivers: + - total_order_amount + - total_completed_order_amount + default_time_dimension: field: order_date - interval: DAY - spotlight: - categories: ["revenue_growth"] - average_order_amount: - type: average - format: usd - round: 2 - amount_percent_of_previous: - type: percent_of_previous - sql: ${total_order_amount} - format: "#,##0.00%" - amount_percent_of_total: - type: percent_of_total - sql: ${total_order_amount} - format: "#,##0.00%" - spotlight: - owner: demo@lightdash.com - filter_by: - - status - - currency - - date_dim_param_test # to test that metric filter_by overrides dimension filter_by: false - segment_by: - - is_completed - - order_source - - order_priority - - date_custom_param_test # to test that metric segment_by overrides dimension segment_by: false - amount_running_total: - type: running_total - sql: ${total_order_amount} - format: usd - round: 2 - total_completed_order_amount: - type: sum - format: usd - round: 2 - filters: - - is_completed: "true" - spotlight: - categories: ["revenue_growth"] - drivers: - - total_order_amount - total_completed_order_amount_eur: - type: sum - format: eur - filters: - - is_completed: "true" - total_non_completed_order_amount: - type: number - format: "$#,##0.00" - sql: ${total_order_amount}-${total_completed_order_amount} - meta: - dimension: - type: number - - name: credit_card_amount - description: Amount of the order (AUD) paid for by credit card - tests: - - not_null - config: - meta: - dimension: - hidden: true - meta: - dimension: - type: number - - name: coupon_amount - description: Amount of the order (AUD) paid for by coupon - tests: - - not_null - config: - meta: - dimension: - hidden: true - meta: - dimension: - type: number - - name: bank_transfer_amount - description: Amount of the order (AUD) paid for by bank transfer - tests: - - not_null - config: - meta: - dimension: - hidden: true - meta: - dimension: - type: number - - name: gift_card_amount - description: Amount of the order (AUD) paid for by gift card - tests: - - not_null - config: - meta: - dimension: - hidden: true - meta: - dimension: - type: number + interval: MONTH + explores: + orders_with_custom_dims: + label: Orders with Custom Dimensions + # No need to specify joins - they are inherited from the parent model + additional_dimensions: + full_name: + type: string + sql: "CONCAT(${customers.first_name}, ' ', ${customers.last_name})" + label: Customer Full Name + order_value_category: + type: string + sql: "CASE WHEN ${orders.amount} > 100 THEN 'high' ELSE 'low' END" + label: Order Value Category + order_month: + type: date + sql: DATE(${orders.order_date}) + time_intervals: [MONTH] + columns: + - name: order_id + tests: + - unique + - not_null + description: This is a unique identifier for an order + config: + meta: + metrics: + unique_order_count: + type: count_distinct + spotlight: + categories: ['revenue_growth'] + completed_order_count: + label: Completed order count + description: Total number of completed orders + type: count_distinct + filters: + - is_completed: 'true' + spotlight: + categories: ['revenue_growth'] + drivers: + - unique_order_count + completed_or_shipped_order_count: + label: Completed or Shipped Order Count + type: count_distinct + filters: + - status: + - completed + - shipped + meta: + dimension: + type: number - - name: order_source - description: Channel through which the order was placed - meta: - dimension: + - name: is_completed + description: Boolean indicating if status is completed + config: + meta: + metrics: + fulfillment_rate: + type: average + format: percent + round: 1 + sql: CASE WHEN ${is_completed} THEN 1 ELSE 0 END + show_underlying_values: + - amount + - customers.first_name + spotlight: + owner: demo2@lightdash.com + categories: ['revenue_growth'] + drivers: + - unique_order_count + fulfillment_rate_with_format_expression: + type: average + format: '#,##0.0%' + sql: CASE WHEN ${is_completed} THEN 1 ELSE 0 END + show_underlying_values: + - amount + - customers.first_name + meta: + dimension: + type: boolean + - name: customer_id + description: Foreign key to the customers table + tests: + - not_null + - relationships: + to: ref('customers') + field: customer_id + meta: + dimension: + type: number + - name: date_dim_param_test + description: 'Example of date based on another dimension' + meta: + dimension: + sql: | + CASE + WHEN ${TABLE}.order_date <= ${lightdash.parameters.date_dim_parameter} + THEN 'param is greater' + WHEN ${TABLE}.order_date >= ${lightdash.parameters.date_dim_parameter} + THEN 'param is smaller' + END + spotlight: + filter_by: false + segment_by: false type: string - colors: - website: "#1f77b4" - mobile_app: "#ff7f0e" - phone: "#2ca02c" - - - name: shipping_method - description: Shipping method selected for the order - config: - meta: - metrics: - avg_shipping_cost_by_method: - type: average - sql: shipping_cost - format: usd - round: 2 - label: Average Shipping Cost by Method - orders_by_shipping_method: - type: count_distinct - sql: order_id - label: Orders by Shipping Method - meta: - dimension: + - name: date_custom_param_test + description: 'Example of custom date' + meta: + dimension: + sql: | + CASE + WHEN ${TABLE}.order_date <= ${lightdash.parameters.date_custom_parameter} + THEN 'I feel less than...' + WHEN ${TABLE}.order_date >= ${lightdash.parameters.date_custom_parameter} + THEN 'I feel great!!!' + END + spotlight: + filter_by: false + segment_by: false type: string - colors: - standard: "#d62728" - express: "#ff7f0e" - overnight: "#2ca02c" + - name: order_date + description: Date (UTC) that the order was placed + config: + meta: + dimension: + type: date + time_intervals: + [ + 'DAY', + 'WEEK', + 'MONTH', + 'YEAR', + 'MONTH_NAME', + 'DAY_OF_WEEK_NAME', + 'QUARTER_NAME', + 'fiscal_quarter', + 'biweekly', + ] + metrics: + date_of_first_order: + type: min + date_of_most_recent_order: + type: max + meta: + dimension: + type: date + - name: status + description: '{{ doc("orders_status") }}' + config: + meta: + dimension: + colors: + # Hex colors are supported in both chart config and echarts + 'placed': '#e6fa0f' + 'completed': '#00FF00' + tests: + - accepted_values: + values: + - placed + - shipped + - completed + - return_pending + - returned + meta: + dimension: + type: string + - name: amount + description: Total amount (USD) of the order + tests: + - not_null + config: + meta: + dimension: + format: usd + round: 2 + metrics: + average_order_size: + type: average + format: usd + round: 2 + spotlight: + categories: ['revenue_growth'] + drivers: + - total_order_amount + - customers.unique_customer_count # cross-table driver example + total_order_amount: + type: sum + format: usd + round: 2 + default_time_dimension: + field: order_date + interval: DAY + spotlight: + categories: ['revenue_growth'] + average_order_amount: + type: average + format: usd + round: 2 + amount_percent_of_previous: + type: percent_of_previous + sql: ${total_order_amount} + format: '#,##0.00%' + amount_percent_of_total: + type: percent_of_total + sql: ${total_order_amount} + format: '#,##0.00%' + spotlight: + owner: demo@lightdash.com + filter_by: + - status + - currency + - date_dim_param_test # to test that metric filter_by overrides dimension filter_by: false + segment_by: + - is_completed + - order_source + - order_priority + - date_custom_param_test # to test that metric segment_by overrides dimension segment_by: false + amount_running_total: + type: running_total + sql: ${total_order_amount} + format: usd + round: 2 + total_completed_order_amount: + type: sum + format: usd + round: 2 + filters: + - is_completed: 'true' + spotlight: + categories: ['revenue_growth'] + drivers: + - total_order_amount + total_completed_order_amount_eur: + type: sum + format: eur + filters: + - is_completed: 'true' + total_non_completed_order_amount: + type: number + format: '$#,##0.00' + sql: ${total_order_amount}-${total_completed_order_amount} + meta: + dimension: + type: number + - name: credit_card_amount + description: Amount of the order (AUD) paid for by credit card + tests: + - not_null + config: + meta: + dimension: + hidden: true + meta: + dimension: + type: number + - name: coupon_amount + description: Amount of the order (AUD) paid for by coupon + tests: + - not_null + config: + meta: + dimension: + hidden: true + meta: + dimension: + type: number + - name: bank_transfer_amount + description: Amount of the order (AUD) paid for by bank transfer + tests: + - not_null + config: + meta: + dimension: + hidden: true + meta: + dimension: + type: number + - name: gift_card_amount + description: Amount of the order (AUD) paid for by gift card + tests: + - not_null + config: + meta: + dimension: + hidden: true + meta: + dimension: + type: number - - name: promo_code - description: Promotional code applied to the order - config: - meta: - metrics: - promo_usage_rate: - type: number - sql: COUNT(CASE WHEN promo_code IS NOT NULL AND promo_code != '' THEN 1 END)::NUMERIC / COUNT(*) - format: percent - round: 1 - label: Promo Code Usage Rate - orders_with_promos: - type: count_distinct - sql: order_id - filters: - - promo_code: IS NOT NULL - label: Orders with Promo Codes - meta: - dimension: - type: string + - name: order_source + description: Channel through which the order was placed + meta: + dimension: + type: string + colors: + website: '#1f77b4' + mobile_app: '#ff7f0e' + phone: '#2ca02c' - - name: order_priority - description: Priority level assigned to the order - config: - meta: - metrics: - avg_delivery_time_by_priority: - type: average - sql: estimated_delivery_days - round: 1 - label: Average Delivery Time by Priority - priority_distribution: - type: count_distinct - sql: order_id - label: Orders by Priority - meta: - dimension: - type: string - colors: - normal: "#1f77b4" - high: "#ff7f0e" - urgent: "#d62728" + - name: shipping_method + description: Shipping method selected for the order + config: + meta: + metrics: + avg_shipping_cost_by_method: + type: average + sql: shipping_cost + format: usd + round: 2 + label: Average Shipping Cost by Method + orders_by_shipping_method: + type: count_distinct + sql: order_id + label: Orders by Shipping Method + meta: + dimension: + type: string + colors: + standard: '#d62728' + express: '#ff7f0e' + overnight: '#2ca02c' - - name: estimated_delivery_days - description: Expected number of days for order delivery - config: - meta: - metrics: - avg_estimated_delivery: - type: average - round: 1 - label: Average Estimated Delivery Days - orders_by_delivery_speed: - type: count_distinct - sql: order_id - label: Orders by Delivery Speed - additional_dimensions: - delivery_speed_tier: - type: string - label: Delivery Speed Tier - sql: | - CASE - WHEN ${estimated_delivery_days} = 1 THEN 'Same/Next Day' - WHEN ${estimated_delivery_days} <= 3 THEN 'Fast (2-3 days)' - WHEN ${estimated_delivery_days} <= 5 THEN 'Standard (4-5 days)' - WHEN ${estimated_delivery_days} <= 7 THEN 'Extended (6-7 days)' - ELSE 'Slow (8+ days)' - END - colors: - "Same/Next Day": "#2ca02c" - "Fast (2-3 days)": "#ff7f0e" - "Standard (4-5 days)": "#1f77b4" - "Extended (6-7 days)": "#ffbb78" - "Slow (8+ days)": "#d62728" - meta: - dimension: - type: number + - name: promo_code + description: Promotional code applied to the order + config: + meta: + metrics: + promo_usage_rate: + type: number + sql: COUNT(CASE WHEN promo_code IS NOT NULL AND promo_code != '' THEN 1 END)::NUMERIC / COUNT(*) + format: percent + round: 1 + label: Promo Code Usage Rate + orders_with_promos: + type: count_distinct + sql: order_id + filters: + - promo_code: IS NOT NULL + label: Orders with Promo Codes + meta: + dimension: + type: string - - name: shipping_cost - description: Cost of shipping for the order - config: - meta: - metrics: - total_shipping_revenue: - type: sum - format: usd - label: Total Shipping Revenue - avg_shipping_cost: - type: average - format: usd - round: 2 - label: Average Shipping Cost - shipping_cost_vs_order_value: - type: number - sql: AVG(shipping_cost / NULLIF(amount, 0)) - format: percent - round: 2 - label: Shipping Cost as % of Order Value - additional_dimensions: - shipping_cost_tier: - type: string - label: Shipping Cost Tier - sql: | - CASE - WHEN ${shipping_cost} < 10 THEN 'Low (<$10)' - WHEN ${shipping_cost} < 20 THEN 'Medium ($10-$20)' - WHEN ${shipping_cost} < 30 THEN 'High ($20-$30)' - ELSE 'Premium ($30+)' - END - meta: - dimension: - type: number - format: usd + - name: order_priority + description: Priority level assigned to the order + config: + meta: + metrics: + avg_delivery_time_by_priority: + type: average + sql: estimated_delivery_days + round: 1 + label: Average Delivery Time by Priority + priority_distribution: + type: count_distinct + sql: order_id + label: Orders by Priority + meta: + dimension: + type: string + colors: + normal: '#1f77b4' + high: '#ff7f0e' + urgent: '#d62728' - - name: tax_rate - description: Tax rate applied to the order - config: - meta: - metrics: - avg_tax_rate: - type: average - format: percent - round: 3 - label: Average Tax Rate - total_tax_collected: - type: sum - sql: amount * tax_rate - format: usd - label: Total Tax Collected - meta: - dimension: - type: number - format: percent + - name: estimated_delivery_days + description: Expected number of days for order delivery + config: + meta: + metrics: + avg_estimated_delivery: + type: average + round: 1 + label: Average Estimated Delivery Days + orders_by_delivery_speed: + type: count_distinct + sql: order_id + label: Orders by Delivery Speed + additional_dimensions: + delivery_speed_tier: + type: string + label: Delivery Speed Tier + sql: | + CASE + WHEN ${estimated_delivery_days} = 1 THEN 'Same/Next Day' + WHEN ${estimated_delivery_days} <= 3 THEN 'Fast (2-3 days)' + WHEN ${estimated_delivery_days} <= 5 THEN 'Standard (4-5 days)' + WHEN ${estimated_delivery_days} <= 7 THEN 'Extended (6-7 days)' + ELSE 'Slow (8+ days)' + END + colors: + 'Same/Next Day': '#2ca02c' + 'Fast (2-3 days)': '#ff7f0e' + 'Standard (4-5 days)': '#1f77b4' + 'Extended (6-7 days)': '#ffbb78' + 'Slow (8+ days)': '#d62728' + meta: + dimension: + type: number - - name: currency - description: Currency used for the order - meta: - dimension: - type: string + - name: shipping_cost + description: Cost of shipping for the order + config: + meta: + metrics: + total_shipping_revenue: + type: sum + format: usd + label: Total Shipping Revenue + avg_shipping_cost: + type: average + format: usd + round: 2 + label: Average Shipping Cost + shipping_cost_vs_order_value: + type: number + sql: AVG(shipping_cost / NULLIF(amount, 0)) + format: percent + round: 2 + label: Shipping Cost as % of Order Value + additional_dimensions: + shipping_cost_tier: + type: string + label: Shipping Cost Tier + sql: | + CASE + WHEN ${shipping_cost} < 10 THEN 'Low (<$10)' + WHEN ${shipping_cost} < 20 THEN 'Medium ($10-$20)' + WHEN ${shipping_cost} < 30 THEN 'High ($20-$30)' + ELSE 'Premium ($30+)' + END + meta: + dimension: + type: number + format: usd - - name: fulfillment_center - description: Fulfillment center handling the order - config: - meta: - metrics: - orders_by_fulfillment_center: - type: count_distinct - sql: order_id - label: Orders by Fulfillment Center - revenue_by_fulfillment_center: - type: sum - sql: amount - format: usd - label: Revenue by Fulfillment Center - avg_shipping_cost_by_center: - type: average - sql: shipping_cost - format: usd - round: 2 - label: Average Shipping Cost by Center - meta: - dimension: - type: string - colors: - east_coast: "#1f77b4" - west_coast: "#ff7f0e" - central: "#2ca02c" + - name: tax_rate + description: Tax rate applied to the order + config: + meta: + metrics: + avg_tax_rate: + type: average + format: percent + round: 3 + label: Average Tax Rate + total_tax_collected: + type: sum + sql: amount * tax_rate + format: usd + label: Total Tax Collected + meta: + dimension: + type: number + format: percent - - name: order_notes - description: Additional notes and context about the order - meta: - dimension: - type: string - hidden: true + - name: currency + description: Currency used for the order + meta: + dimension: + type: string + + - name: fulfillment_center + description: Fulfillment center handling the order + config: + meta: + metrics: + orders_by_fulfillment_center: + type: count_distinct + sql: order_id + label: Orders by Fulfillment Center + revenue_by_fulfillment_center: + type: sum + sql: amount + format: usd + label: Revenue by Fulfillment Center + avg_shipping_cost_by_center: + type: average + sql: shipping_cost + format: usd + round: 2 + label: Average Shipping Cost by Center + meta: + dimension: + type: string + colors: + east_coast: '#1f77b4' + west_coast: '#ff7f0e' + central: '#2ca02c' + + - name: order_notes + description: Additional notes and context about the order + meta: + dimension: + type: string + hidden: true diff --git a/packages/backend/package.json b/packages/backend/package.json index aa1e5bb4f4571..cf70522665cf5 100644 --- a/packages/backend/package.json +++ b/packages/backend/package.json @@ -70,6 +70,7 @@ "@octokit/request": "8.4.1", "@octokit/rest": "20.1.1", "@openrouter/ai-sdk-provider": "1.5.4", + "@polyglot-sql/sdk": "0.2.2", "@rudderstack/rudder-sdk-node": "2.1.1", "@sentry/core": "9.31.0", "@sentry/node": "9.46.0", diff --git a/packages/backend/src/ee/preAggregates/matcher.test.ts b/packages/backend/src/ee/preAggregates/matcher.test.ts index cc5ccc1d5cc64..da4c95c97ee88 100644 --- a/packages/backend/src/ee/preAggregates/matcher.test.ts +++ b/packages/backend/src/ee/preAggregates/matcher.test.ts @@ -460,6 +460,30 @@ describe('findMatch', () => { expect(result.hit).toBe(true); }); + it('allows pre-aggregate match when base table has sqlWhere (transpiled at query time)', () => { + const explore = { + ...baseExplore(), + preAggregates: [ + { + name: 'orders_summary', + dimensions: ['status'], + metrics: ['order_count'], + }, + ], + }; + explore.tables.orders.sqlWhere = "status != 'deleted'"; + + const result = preAggregateUtils.findMatch( + makeMetricQuery({ + dimensions: ['orders_status'], + metrics: ['orders_order_count'], + }), + explore, + ); + + expect(result.hit).toBe(true); + }); + it('returns custom_dimension_present when custom dimensions exist', () => { const explore = { ...baseExplore(), diff --git a/packages/backend/src/ee/preAggregates/sqlTranspiler.test.ts b/packages/backend/src/ee/preAggregates/sqlTranspiler.test.ts new file mode 100644 index 0000000000000..8d44a70b76acf --- /dev/null +++ b/packages/backend/src/ee/preAggregates/sqlTranspiler.test.ts @@ -0,0 +1,430 @@ +import { + AnyType, + DimensionType, + FieldType, + SupportedDbtAdapter, + type CompiledDimension, + type Explore, +} from '@lightdash/common'; +import { + Dialect, + generate, + init, + parse, + tokenize, + transpile, +} from '@polyglot-sql/sdk'; +import { transpileExploreSqlFilters } from './sqlTranspiler'; + +// WASM init required in CJS (Jest) before any polyglot calls +beforeAll(async () => { + await init(); +}); + +// --------------------------------------------------------------------------- +// Low-level polyglot smoke tests +// --------------------------------------------------------------------------- +describe('polyglot transpile (raw SDK)', () => { + it('transpiles Snowflake DATEDIFF to DuckDB DATE_DIFF', () => { + const { ast } = parse( + "SELECT 1 WHERE DATEDIFF('second', created_at, CURRENT_TIMESTAMP) > 3600", + Dialect.Snowflake, + ); + const result = generate(ast, Dialect.DuckDB); + + expect(result.success).toBe(true); + console.log('[Snowflake → DuckDB]', result.sql![0]); + }); + + it('transpiles BigQuery backtick-quoted identifiers', () => { + const { ast } = parse( + "SELECT 1 WHERE `project.dataset.table`.status = 'active'", + Dialect.BigQuery, + ); + const result = generate(ast, Dialect.DuckDB); + + expect(result.success).toBe(true); + console.log('[BigQuery → DuckDB]', result.sql![0]); + }); + + it('transpiles Postgres date interval', () => { + const { ast } = parse( + "SELECT 1 WHERE created_at > NOW() - INTERVAL '7 days'", + Dialect.PostgreSQL, + ); + const result = generate(ast, Dialect.DuckDB); + + expect(result.success).toBe(true); + console.log('[Postgres → DuckDB]', result.sql![0]); + }); + + it('transpiles Snowflake IFF to CASE WHEN', () => { + const { ast } = parse( + "SELECT 1 WHERE IFF(status = 'active', 1, 0) = 1", + Dialect.Snowflake, + ); + const result = generate(ast, Dialect.DuckDB); + + expect(result.success).toBe(true); + console.log('[Snowflake IFF → DuckDB]', result.sql![0]); + }); + + it('transpiles Redshift GETDATE() to CURRENT_TIMESTAMP', () => { + const { ast } = parse( + 'SELECT 1 WHERE created_at > GETDATE() - 7', + Dialect.Redshift, + ); + const result = generate(ast, Dialect.DuckDB); + + expect(result.success).toBe(true); + console.log('[Redshift → DuckDB]', result.sql![0]); + }); + + it('transpiles BigQuery SAFE_DIVIDE to CASE WHEN', () => { + const { ast } = parse( + 'SELECT 1 WHERE SAFE_DIVIDE(amount, total) > 0.5', + Dialect.BigQuery, + ); + const result = generate(ast, Dialect.DuckDB); + + expect(result.success).toBe(true); + console.log('[BigQuery SAFE_DIVIDE → DuckDB]', result.sql![0]); + }); + + it('renames column identifiers via tokenizer then transpiles', () => { + const sql = "SELECT 1 WHERE tbl.status != 'returned'"; + const { tokens } = tokenize(sql, Dialect.Snowflake); + const renameMap: Record = { status: 'orders_status' }; + + // Rebuild with renamed tokens + let result = ''; + let lastEnd = 0; + for (const token of tokens!) { + result += sql.substring(lastEnd, token.span.start); + // TODO: typing issue in the Library + const tokenType = (token as AnyType).token_type ?? token.tokenType; + if (tokenType === 'VAR' && token.text in renameMap) { + result += renameMap[token.text]; + } else { + result += sql.substring(token.span.start, token.span.end); + } + lastEnd = token.span.end; + } + result += sql.substring(lastEnd); + + // Then transpile + const transpiled = transpile(result, Dialect.Snowflake, Dialect.DuckDB); + expect(transpiled.success).toBe(true); + expect(transpiled.sql![0]).toContain('orders_status'); + expect(transpiled.sql![0]).not.toMatch(/\bstatus\b/); + console.log('[Tokenizer rename + transpile]', transpiled.sql![0]); + }); +}); + +// --------------------------------------------------------------------------- +// transpileExploreSqlFilters integration tests +// --------------------------------------------------------------------------- +const makeDimension = (name: string, table: string): CompiledDimension => ({ + index: 0, + fieldType: FieldType.DIMENSION, + type: DimensionType.STRING, + name, + label: name, + sql: `\${TABLE}.${name}`, + table, + tableLabel: table, + hidden: false, + compiledSql: `${table}.${table}_${name}`, + tablesReferences: [table], +}); + +const makeExplore = ( + overrides: Partial & { + tables: Explore['tables']; + targetDatabase: SupportedDbtAdapter; + }, +): Explore => ({ + name: 'test', + label: 'Test', + tags: [], + baseTable: 'orders', + joinedTables: [], + ...overrides, +}); + +const makeTable = ( + tableName: string, + dimensionNames: string[], + sqlWhere?: string, +): Explore['tables'][string] => + ({ + name: tableName, + label: tableName, + database: 'db', + schema: 'public', + sqlTable: tableName, + dimensions: Object.fromEntries( + dimensionNames.map((name) => [ + name, + makeDimension(name, tableName), + ]), + ), + metrics: {}, + lineageGraph: {}, + sqlWhere, + }) as Explore['tables'][string]; + +describe('transpileExploreSqlFilters', () => { + it('returns tables unchanged when no sqlWhere exists', async () => { + const explore = makeExplore({ + targetDatabase: SupportedDbtAdapter.SNOWFLAKE, + tables: { + orders: makeTable('orders', ['status']), + }, + }); + + const result = await transpileExploreSqlFilters(explore); + expect(result.orders.sqlWhere).toBeUndefined(); + }); + + it('returns tables unchanged for DuckDB source (no-op)', async () => { + const explore = makeExplore({ + targetDatabase: SupportedDbtAdapter.DUCKDB, + tables: { + orders: makeTable('orders', ['status'], "status != 'returned'"), + }, + }); + + const result = await transpileExploreSqlFilters(explore); + expect(result.orders.sqlWhere).toBe("status != 'returned'"); + }); + + it('transpiles and renames columns in sql_filter', async () => { + const explore = makeExplore({ + targetDatabase: SupportedDbtAdapter.SNOWFLAKE, + tables: { + orders: makeTable('orders', ['status'], "status != 'returned'"), + }, + }); + + const result = await transpileExploreSqlFilters(explore); + expect(result.orders.sqlWhere).toContain('orders_status'); + expect(result.orders.sqlWhere).not.toMatch(/\bstatus\b(?!_)/); + console.log('[Renamed]', result.orders.sqlWhere); + }); + + it('renames table-qualified column references', async () => { + const explore = makeExplore({ + targetDatabase: SupportedDbtAdapter.SNOWFLAKE, + tables: { + orders: makeTable( + 'orders', + ['status'], + "${TABLE}.status != 'returned'", + ), + }, + }); + + const result = await transpileExploreSqlFilters(explore); + expect(result.orders.sqlWhere).toContain('${TABLE}'); + expect(result.orders.sqlWhere).toContain('orders_status'); + console.log('[Table-qualified rename]', result.orders.sqlWhere); + }); + + it('renames hardcoded unquoted table.column references', async () => { + const explore = makeExplore({ + targetDatabase: SupportedDbtAdapter.SNOWFLAKE, + tables: { + orders: makeTable( + 'orders', + ['status'], + "orders.status != 'returned'", + ), + }, + }); + + const result = await transpileExploreSqlFilters(explore); + expect(result.orders.sqlWhere).toContain('orders_status'); + console.log('[Hardcoded table.col]', result.orders.sqlWhere); + }); + + it('renames double-quoted "table"."column" references', async () => { + const explore = makeExplore({ + targetDatabase: SupportedDbtAdapter.SNOWFLAKE, + tables: { + orders: makeTable( + 'orders', + ['status'], + '"orders"."status" != \'returned\'', + ), + }, + }); + + const result = await transpileExploreSqlFilters(explore); + expect(result.orders.sqlWhere).toContain('orders_status'); + console.log('[Quoted "table"."col"]', result.orders.sqlWhere); + }); + + it('renames double-quoted "table".column (mixed quoting)', async () => { + const explore = makeExplore({ + targetDatabase: SupportedDbtAdapter.SNOWFLAKE, + tables: { + orders: makeTable( + 'orders', + ['status'], + '"orders".status != \'returned\'', + ), + }, + }); + + const result = await transpileExploreSqlFilters(explore); + expect(result.orders.sqlWhere).toContain('orders_status'); + console.log('[Mixed "table".col]', result.orders.sqlWhere); + }); + + it('does not rename table qualifier when table and column share a name', async () => { + const explore = makeExplore({ + targetDatabase: SupportedDbtAdapter.SNOWFLAKE, + tables: { + orders: makeTable('orders', ['orders'], '"orders".orders = 1'), + }, + }); + + const result = await transpileExploreSqlFilters(explore); + // The column "orders" should be renamed to "orders_orders" + // but the table qualifier "orders" should stay as-is + expect(result.orders.sqlWhere).toContain('orders_orders'); + expect(result.orders.sqlWhere).toMatch(/"orders"\./); + console.log('[Table=column name collision]', result.orders.sqlWhere); + }); + + it('preserves Lightdash user attribute placeholders', async () => { + const explore = makeExplore({ + targetDatabase: SupportedDbtAdapter.POSTGRES, + tables: { + orders: makeTable( + 'orders', + ['sales_region'], + 'sales_region IN (${lightdash.attributes.sales_region})', + ), + }, + }); + + const result = await transpileExploreSqlFilters(explore); + expect(result.orders.sqlWhere).toContain( + '${lightdash.attributes.sales_region}', + ); + expect(result.orders.sqlWhere).toContain('orders_sales_region'); + console.log('[Placeholder + rename]', result.orders.sqlWhere); + }); + + it('preserves ${ld.attr.X} shorthand placeholders', async () => { + const explore = makeExplore({ + targetDatabase: SupportedDbtAdapter.BIGQUERY, + tables: { + orders: makeTable( + 'orders', + ['segment'], + "${ld.attr.is_admin} = 'true' OR segment != 'Enterprise'", + ), + }, + }); + + const result = await transpileExploreSqlFilters(explore); + expect(result.orders.sqlWhere).toContain('${ld.attr.is_admin}'); + expect(result.orders.sqlWhere).toContain('orders_segment'); + console.log('[Shorthand + rename]', result.orders.sqlWhere); + }); + + it('transpiles joined table sqlWhere with correct rename scope', async () => { + const explore = makeExplore({ + targetDatabase: SupportedDbtAdapter.SNOWFLAKE, + tables: { + orders: makeTable('orders', ['status'], "status != 'returned'"), + customers: makeTable( + 'customers', + ['is_active'], + 'is_active = TRUE', + ), + }, + }); + + const result = await transpileExploreSqlFilters(explore); + expect(result.orders.sqlWhere).toContain('orders_status'); + expect(result.customers.sqlWhere).toContain('customers_is_active'); + console.log('[Base table]', result.orders.sqlWhere); + console.log('[Joined table]', result.customers.sqlWhere); + }); + + it('transpiles complex Snowflake date filter with rename', async () => { + const explore = makeExplore({ + targetDatabase: SupportedDbtAdapter.SNOWFLAKE, + tables: { + orders: makeTable( + 'orders', + ['created_at'], + "DATEDIFF('day', ${TABLE}.created_at, CURRENT_TIMESTAMP()) <= 90", + ), + }, + }); + + const result = await transpileExploreSqlFilters(explore); + expect(result.orders.sqlWhere).toContain('${TABLE}'); + expect(result.orders.sqlWhere).toContain('orders_created_at'); + expect(result.orders.sqlWhere).toContain('DATE_DIFF'); + console.log('[Snowflake date + rename]', result.orders.sqlWhere); + }); + + it('handles multiple column references in one filter', async () => { + const explore = makeExplore({ + targetDatabase: SupportedDbtAdapter.SNOWFLAKE, + tables: { + orders: makeTable( + 'orders', + ['status', 'amount'], + "status != 'returned' AND amount > 0", + ), + }, + }); + + const result = await transpileExploreSqlFilters(explore); + expect(result.orders.sqlWhere).toContain('orders_status'); + expect(result.orders.sqlWhere).toContain('orders_amount'); + console.log('[Multi-column rename]', result.orders.sqlWhere); + }); + + it('transpiles heavy Postgres sql_filter with casts, string_to_array, ANY, and user attributes', async () => { + const explore = makeExplore({ + targetDatabase: SupportedDbtAdapter.POSTGRES, + tables: { + orders: makeTable( + 'orders', + ['status', 'order_date', 'amount'], + [ + "${TABLE}.status != 'returned'", + "AND ${TABLE}.status = ANY(string_to_array(${ld.attributes.statuses}, ','))", + "AND ${TABLE}.order_date::date >= NOW() - INTERVAL '90 days'", + 'AND ${TABLE}.amount::numeric > 0', + ].join(' '), + ), + }, + }); + + const result = await transpileExploreSqlFilters(explore); + const sqlWhere = result.orders.sqlWhere!; + + // Placeholders preserved + expect(sqlWhere).toContain('${TABLE}'); + expect(sqlWhere).toContain('${ld.attributes.statuses}'); + + // Columns renamed to flattened pre-aggregate names + expect(sqlWhere).toContain('orders_status'); + expect(sqlWhere).toContain('orders_order_date'); + expect(sqlWhere).toContain('orders_amount'); + + // Postgres :: casts transpiled to CAST() + expect(sqlWhere).not.toContain('::'); + + console.log('[Heavy Postgres filter]', sqlWhere); + }); +}); diff --git a/packages/backend/src/ee/preAggregates/sqlTranspiler.ts b/packages/backend/src/ee/preAggregates/sqlTranspiler.ts new file mode 100644 index 0000000000000..fd1c96a9c5d27 --- /dev/null +++ b/packages/backend/src/ee/preAggregates/sqlTranspiler.ts @@ -0,0 +1,248 @@ +import { + AnyType, + assertUnreachable, + getItemId, + SupportedDbtAdapter, + type Explore, +} from '@lightdash/common'; +import { + Dialect, + init, + isInitialized, + tokenize, + transpile, +} from '@polyglot-sql/sdk'; + +export function getDialect(adapter: SupportedDbtAdapter): Dialect { + switch (adapter) { + case SupportedDbtAdapter.POSTGRES: + return Dialect.PostgreSQL; + case SupportedDbtAdapter.BIGQUERY: + return Dialect.BigQuery; + case SupportedDbtAdapter.SNOWFLAKE: + return Dialect.Snowflake; + case SupportedDbtAdapter.DATABRICKS: + return Dialect.Databricks; + case SupportedDbtAdapter.REDSHIFT: + return Dialect.Redshift; + case SupportedDbtAdapter.TRINO: + return Dialect.Trino; + case SupportedDbtAdapter.ATHENA: + return Dialect.Athena; + case SupportedDbtAdapter.DUCKDB: + return Dialect.DuckDB; + case SupportedDbtAdapter.CLICKHOUSE: + return Dialect.ClickHouse; + default: + return assertUnreachable( + adapter, + `Unsupported adapter for SQL transpilation: ${adapter}`, + ); + } +} + +async function ensureInitialized(): Promise { + if (!isInitialized()) { + await init(); + } +} + +/** + * Builds a column rename map for a table's sql_filter. + * Maps original dimension names → flattened pre-aggregate column names. + * + * e.g. { status: 'orders_status', created_at: 'orders_created_at' } + */ +function buildColumnRenameMap( + explore: Explore, + tableName: string, +): Record { + const table = explore.tables[tableName]; + if (!table) return {}; + + const renameMap: Record = {}; + + Object.values(table.dimensions).forEach((dimension) => { + const flattenedName = getItemId({ + table: dimension.table, + name: dimension.name, + }); + if (dimension.name !== flattenedName) { + renameMap[dimension.name] = flattenedName; + } + }); + + return renameMap; +} + +/** + * Renames identifier tokens in SQL using the tokenizer. + * This is safe because the tokenizer understands SQL structure — + * identifiers inside string literals are not tokens. + */ +function renameColumnsViaTokenizer( + sql: string, + dialect: Dialect, + renameMap: Record, +): string { + if (Object.keys(renameMap).length === 0) return sql; + + const tokenResult = tokenize(sql, dialect); + if (!tokenResult.success || !tokenResult.tokens) return sql; + + const { tokens } = tokenResult; + const getTokenType = (token: (typeof tokens)[number]): string => + // The SDK's token type is inconsistent + (token as AnyType).token_type ?? token.tokenType; + + let result = ''; + let lastEnd = 0; + + for (let i = 0; i < tokens.length; i += 1) { + const token = tokens[i]; + + // Preserve any text between tokens (whitespace, etc.) + result += sql.substring(lastEnd, token.span.start); + + const tokenType = getTokenType(token); + const isIdentifier = + tokenType === 'VAR' || tokenType === 'QUOTED_IDENTIFIER'; + + // Only rename identifiers in column position: + // - After a DOT (table.COLUMN) → column + // - Before a DOT (TABLE.column) → table qualifier, skip + // - Standalone (no adjacent DOT) → column + const prevType = i > 0 ? getTokenType(tokens[i - 1]) : null; + const nextType = + i < tokens.length - 1 ? getTokenType(tokens[i + 1]) : null; + const isTableQualifier = nextType === 'DOT' && prevType !== 'DOT'; + const isColumnPosition = isIdentifier && !isTableQualifier; + + if (isColumnPosition && token.text in renameMap) { + const renamed = renameMap[token.text]; + result += + tokenType === 'QUOTED_IDENTIFIER' ? `"${renamed}"` : renamed; + } else { + result += sql.substring(token.span.start, token.span.end); + } + + lastEnd = token.span.end; + } + + // Append any trailing text + result += sql.substring(lastEnd); + + return result; +} + +/** + * Renames columns using the tokenizer, then transpiles the expression + * from the source dialect to DuckDB. + */ +function transpileAndRenameExpression( + expression: string, + sourceDialect: Dialect, + columnRenameMap: Record, +): string { + const wrapped = `SELECT 1 WHERE ${expression}`; + + // Step 1: Rename columns using tokenizer (AST-aware, safe for string literals) + const renamed = renameColumnsViaTokenizer( + wrapped, + sourceDialect, + columnRenameMap, + ); + + // Step 2: Transpile from source dialect to DuckDB + const result = transpile(renamed, sourceDialect, Dialect.DuckDB); + + if (!result.success || !result.sql || result.sql.length === 0) { + throw new Error( + `Failed to transpile sql_filter expression: ${result.error ?? 'unknown error'}`, + ); + } + + // Extract the WHERE clause back out + const transpiled = result.sql[0]; + const whereMatch = transpiled.match(/WHERE\s+([\s\S]+)$/i); + if (!whereMatch) { + throw new Error( + `Could not extract WHERE clause from transpiled SQL: ${transpiled}`, + ); + } + + return whereMatch[1].trim(); +} + +// --------------------------------------------------------------------------- +// Placeholder protection +// --------------------------------------------------------------------------- + +const PLACEHOLDER_REGEX = + /\$\{(?:lightdash|ld)\.(?:attributes?|attr|user)\.[^}]+\}|\$\{TABLE\}|\$\{[^}]+\}/g; + +function protectPlaceholders(sql: string): { + protected: string; + restore: (transpiled: string) => string; +} { + const placeholders = new Map(); + let counter = 0; + + const protectedSql = sql.replace(PLACEHOLDER_REGEX, (match) => { + const sentinel = `__ld_placeholder_${counter}__`; + counter += 1; + placeholders.set(sentinel, match); + return sentinel; + }); + + const restore = (transpiled: string): string => { + let result = transpiled; + for (const [sentinel, original] of placeholders) { + result = result.replaceAll(sentinel, original); + } + return result; + }; + + return { protected: protectedSql, restore }; +} + +/** + * Transpiles all sqlWhere expressions in an explore's tables from the + * source warehouse dialect to DuckDB, and renames column references + * to their flattened pre-aggregate column names. + * + * User attribute placeholders (${lightdash.attributes.X}) are temporarily + * replaced with safe sentinel strings before transpilation, then restored. + */ +export async function transpileExploreSqlFilters( + explore: Explore, +): Promise { + const sourceDialect = getDialect(explore.targetDatabase); + + // DuckDB → DuckDB: no transpilation needed + if (sourceDialect === Dialect.DuckDB) { + return explore.tables; + } + + await ensureInitialized(); + + return Object.fromEntries( + Object.entries(explore.tables).map(([tableName, table]) => { + if (!table.sqlWhere) { + return [tableName, table]; + } + + const columnRenameMap = buildColumnRenameMap(explore, tableName); + const { protected: protectedSql, restore } = protectPlaceholders( + table.sqlWhere, + ); + const transpiled = transpileAndRenameExpression( + protectedSql, + sourceDialect, + columnRenameMap, + ); + + return [tableName, { ...table, sqlWhere: restore(transpiled) }]; + }), + ); +} diff --git a/packages/backend/src/ee/services/AsyncQueryService/PreAggregationDuckDbClient.ts b/packages/backend/src/ee/services/AsyncQueryService/PreAggregationDuckDbClient.ts index 82a0fd3e90c83..6265944c6b358 100644 --- a/packages/backend/src/ee/services/AsyncQueryService/PreAggregationDuckDbClient.ts +++ b/packages/backend/src/ee/services/AsyncQueryService/PreAggregationDuckDbClient.ts @@ -31,6 +31,7 @@ import { ProjectService } from '../../../services/ProjectService/ProjectService' import { wrapSentryTransaction } from '../../../utils'; import { PivotQueryBuilder } from '../../../utils/QueryBuilder/PivotQueryBuilder'; import { type PreAggregateModel } from '../../models/PreAggregateModel'; +import { transpileExploreSqlFilters } from '../../preAggregates/sqlTranspiler'; import { getDuckdbPreAggregateSqlTable, getPreAggregateDuckdbLocator, @@ -326,18 +327,20 @@ export class PreAggregationDuckDbClient { ); } + // Transpile sql_filter expressions from source warehouse dialect to DuckDB + const transpiledTables = + await transpileExploreSqlFilters(preAggExplore); + const patchedPreAggExplore = { ...preAggExplore, tables: Object.fromEntries( - Object.entries(preAggExplore.tables).map( - ([tableName, table]) => [ - tableName, - { - ...table, - sqlTable, - }, - ], - ), + Object.entries(transpiledTables).map(([tableName, table]) => [ + tableName, + { + ...table, + sqlTable, + }, + ]), ), }; diff --git a/packages/backend/src/services/ProjectService/ProjectService.ts b/packages/backend/src/services/ProjectService/ProjectService.ts index 4a25b3ed6921b..c5984993b96ff 100644 --- a/packages/backend/src/services/ProjectService/ProjectService.ts +++ b/packages/backend/src/services/ProjectService/ProjectService.ts @@ -3121,11 +3121,21 @@ export class ProjectService extends BaseService { matchResult.preAggregateName, ); try { - explore = await this.getExplore( + const preAggExplore = await this.getExplore( account, projectUuid, preAggExploreName, ); + // Transpile sql_filter from source warehouse dialect to DuckDB + // and rename columns to flattened pre-aggregate names + const { transpileExploreSqlFilters } = + await import('../../ee/preAggregates/sqlTranspiler'); + const transpiledTables = + await transpileExploreSqlFilters(preAggExplore); + explore = { + ...preAggExplore, + tables: transpiledTables, + }; } catch { this.logger.warn( `Pre-aggregate explore "${preAggExploreName}" not found, falling back to source explore`, diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 46fa1949496c6..8bf80466075a0 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -225,6 +225,9 @@ importers: '@openrouter/ai-sdk-provider': specifier: 1.5.4 version: 1.5.4(ai@6.0.71(zod@3.25.55))(zod@3.25.55) + '@polyglot-sql/sdk': + specifier: 0.2.2 + version: 0.2.2 '@rudderstack/rudder-sdk-node': specifier: 2.1.1 version: 2.1.1(tslib@2.8.1) @@ -4329,6 +4332,10 @@ packages: '@pm2/pm2-version-check@1.0.4': resolution: {integrity: sha512-SXsM27SGH3yTWKc2fKR4SYNxsmnvuBQ9dd6QHtEWmiZ/VqaOYPAIlS8+vMcn27YLtAEBGvNRSh3TPNvtjZgfqA==} + '@polyglot-sql/sdk@0.2.2': + resolution: {integrity: sha512-NP1UrwWlepTHkMj0kYs+38n4utU/wjZ2HbeTe9hcEY3LIrBj0wxe56TnN/J+x1cRh3HAvEcbBzGcNOELx9w49A==} + engines: {node: '>=22', pnpm: '>=10'} + '@popperjs/core@2.11.8': resolution: {integrity: sha512-P1st0aksCrn9sGZhp8GMYwBnQsbvAWsZAX44oXNNvLHGqAOcoVxmjZiohstwQ7SqKnbR47akdNi+uleWD8+g6A==} @@ -15136,7 +15143,7 @@ snapshots: '@apm-js-collab/tracing-hooks@0.3.1': dependencies: '@apm-js-collab/code-transformer': 0.8.2 - debug: 4.4.3(supports-color@9.1.0) + debug: 4.4.3(supports-color@8.1.1) module-details-from-path: 1.0.4 transitivePeerDependencies: - supports-color @@ -16048,7 +16055,7 @@ snapshots: '@babel/types': 7.29.0 '@jridgewell/remapping': 2.3.5 convert-source-map: 2.0.0 - debug: 4.4.3(supports-color@9.1.0) + debug: 4.4.3(supports-color@8.1.1) gensync: 1.0.0-beta.2 json5: 2.2.3 semver: 6.3.1 @@ -16220,7 +16227,7 @@ snapshots: '@babel/parser': 7.29.0 '@babel/template': 7.27.2 '@babel/types': 7.29.0 - debug: 4.4.3(supports-color@9.1.0) + debug: 4.4.3(supports-color@8.1.1) transitivePeerDependencies: - supports-color @@ -16746,7 +16753,7 @@ snapshots: '@eslint/eslintrc@2.1.4': dependencies: ajv: 6.12.6 - debug: 4.4.3(supports-color@9.1.0) + debug: 4.4.3(supports-color@8.1.1) espree: 9.6.1 globals: 13.24.0 ignore: 5.3.2 @@ -17089,7 +17096,7 @@ snapshots: '@humanwhocodes/config-array@0.13.0': dependencies: '@humanwhocodes/object-schema': 2.0.3 - debug: 4.4.3(supports-color@9.1.0) + debug: 4.4.3(supports-color@8.1.1) minimatch: 3.1.2 transitivePeerDependencies: - supports-color @@ -17470,7 +17477,7 @@ snapshots: '@kwsites/file-exists@1.1.1': dependencies: - debug: 4.4.3(supports-color@9.1.0) + debug: 4.4.3(supports-color@8.1.1) transitivePeerDependencies: - supports-color @@ -18678,10 +18685,12 @@ snapshots: '@pm2/pm2-version-check@1.0.4': dependencies: - debug: 4.4.3(supports-color@9.1.0) + debug: 4.4.3(supports-color@8.1.1) transitivePeerDependencies: - supports-color + '@polyglot-sql/sdk@0.2.2': {} + '@popperjs/core@2.11.8': {} '@popsql/monaco-sql-languages@0.2.0(monaco-editor@0.44.0)': @@ -20993,7 +21002,7 @@ snapshots: '@typescript-eslint/types': 7.18.0 '@typescript-eslint/typescript-estree': 7.18.0(typescript@6.0.0-beta) '@typescript-eslint/visitor-keys': 7.18.0 - debug: 4.4.3(supports-color@9.1.0) + debug: 4.4.3(supports-color@8.1.1) eslint: 8.57.1 optionalDependencies: typescript: 6.0.0-beta @@ -21006,7 +21015,7 @@ snapshots: '@typescript-eslint/types': 8.43.0 '@typescript-eslint/typescript-estree': 8.43.0(typescript@6.0.0-beta) '@typescript-eslint/visitor-keys': 8.43.0 - debug: 4.4.3(supports-color@9.1.0) + debug: 4.4.3(supports-color@8.1.1) eslint: 8.57.1 typescript: 6.0.0-beta transitivePeerDependencies: @@ -21016,7 +21025,7 @@ snapshots: dependencies: '@typescript-eslint/tsconfig-utils': 8.56.1(typescript@6.0.0-beta) '@typescript-eslint/types': 8.56.1 - debug: 4.4.3(supports-color@9.1.0) + debug: 4.4.3(supports-color@8.1.1) typescript: 6.0.0-beta transitivePeerDependencies: - supports-color @@ -21025,7 +21034,7 @@ snapshots: dependencies: '@typescript-eslint/tsconfig-utils': 8.56.1(typescript@5.9.3) '@typescript-eslint/types': 8.56.1 - debug: 4.4.3(supports-color@9.1.0) + debug: 4.4.3(supports-color@8.1.1) typescript: 5.9.3 transitivePeerDependencies: - supports-color @@ -21034,7 +21043,7 @@ snapshots: dependencies: '@typescript-eslint/tsconfig-utils': 8.56.1(typescript@6.0.0-beta) '@typescript-eslint/types': 8.56.1 - debug: 4.4.3(supports-color@9.1.0) + debug: 4.4.3(supports-color@8.1.1) typescript: 6.0.0-beta transitivePeerDependencies: - supports-color @@ -21075,7 +21084,7 @@ snapshots: dependencies: '@typescript-eslint/typescript-estree': 7.18.0(typescript@6.0.0-beta) '@typescript-eslint/utils': 7.18.0(eslint@8.57.1)(typescript@6.0.0-beta) - debug: 4.4.3(supports-color@9.1.0) + debug: 4.4.3(supports-color@8.1.1) eslint: 8.57.1 ts-api-utils: 1.4.3(typescript@6.0.0-beta) optionalDependencies: @@ -21088,7 +21097,7 @@ snapshots: '@typescript-eslint/types': 8.43.0 '@typescript-eslint/typescript-estree': 8.43.0(typescript@6.0.0-beta) '@typescript-eslint/utils': 8.43.0(eslint@8.57.1)(typescript@6.0.0-beta) - debug: 4.4.3(supports-color@9.1.0) + debug: 4.4.3(supports-color@8.1.1) eslint: 8.57.1 ts-api-utils: 2.4.0(typescript@6.0.0-beta) typescript: 6.0.0-beta @@ -21107,7 +21116,7 @@ snapshots: dependencies: '@typescript-eslint/types': 5.62.0 '@typescript-eslint/visitor-keys': 5.62.0 - debug: 4.4.3(supports-color@9.1.0) + debug: 4.4.3(supports-color@8.1.1) globby: 11.1.0 is-glob: 4.0.3 semver: 7.7.3 @@ -21121,7 +21130,7 @@ snapshots: dependencies: '@typescript-eslint/types': 7.18.0 '@typescript-eslint/visitor-keys': 7.18.0 - debug: 4.4.3(supports-color@9.1.0) + debug: 4.4.3(supports-color@8.1.1) globby: 11.1.0 is-glob: 4.0.3 minimatch: 9.0.5 @@ -21138,7 +21147,7 @@ snapshots: '@typescript-eslint/tsconfig-utils': 8.43.0(typescript@6.0.0-beta) '@typescript-eslint/types': 8.43.0 '@typescript-eslint/visitor-keys': 8.43.0 - debug: 4.4.3(supports-color@9.1.0) + debug: 4.4.3(supports-color@8.1.1) fast-glob: 3.3.3 is-glob: 4.0.3 minimatch: 9.0.5 @@ -21154,7 +21163,7 @@ snapshots: '@typescript-eslint/tsconfig-utils': 8.56.1(typescript@5.9.3) '@typescript-eslint/types': 8.56.1 '@typescript-eslint/visitor-keys': 8.56.1 - debug: 4.4.3(supports-color@9.1.0) + debug: 4.4.3(supports-color@8.1.1) minimatch: 10.2.2 semver: 7.7.3 tinyglobby: 0.2.15 @@ -21169,7 +21178,7 @@ snapshots: '@typescript-eslint/tsconfig-utils': 8.56.1(typescript@6.0.0-beta) '@typescript-eslint/types': 8.56.1 '@typescript-eslint/visitor-keys': 8.56.1 - debug: 4.4.3(supports-color@9.1.0) + debug: 4.4.3(supports-color@8.1.1) minimatch: 10.2.2 semver: 7.7.3 tinyglobby: 0.2.15 @@ -21572,7 +21581,7 @@ snapshots: agent-base@6.0.2: dependencies: - debug: 4.4.3(supports-color@9.1.0) + debug: 4.4.3(supports-color@8.1.1) transitivePeerDependencies: - supports-color @@ -22198,7 +22207,7 @@ snapshots: dependencies: bytes: 3.1.2 content-type: 1.0.5 - debug: 4.4.3(supports-color@9.1.0) + debug: 4.4.3(supports-color@8.1.1) http-errors: 2.0.0 iconv-lite: 0.7.0 on-finished: 2.4.1 @@ -22928,7 +22937,7 @@ snapshots: '@actions/core': 2.0.3 arg: 5.0.2 console.table: 0.10.0 - debug: 4.4.3(supports-color@9.1.0) + debug: 4.4.3(supports-color@8.1.1) fast-shuffle: 6.1.0 find-cypress-specs: 1.54.9(@babel/core@7.28.4) globby: 11.1.0 @@ -23671,7 +23680,7 @@ snapshots: esbuild-register@3.6.0(esbuild@0.25.11): dependencies: - debug: 4.4.3(supports-color@9.1.0) + debug: 4.4.3(supports-color@8.1.1) esbuild: 0.25.11 transitivePeerDependencies: - supports-color @@ -23878,7 +23887,7 @@ snapshots: '@es-joy/resolve.exports': 1.2.0 are-docs-informative: 0.0.2 comment-parser: 1.4.5 - debug: 4.4.3(supports-color@9.1.0) + debug: 4.4.3(supports-color@8.1.1) escape-string-regexp: 4.0.0 eslint: 8.57.1 espree: 11.1.0 @@ -23996,7 +24005,7 @@ snapshots: ajv: 6.12.6 chalk: 4.1.2 cross-spawn: 7.0.6 - debug: 4.4.3(supports-color@9.1.0) + debug: 4.4.3(supports-color@8.1.1) doctrine: 3.0.0 escape-string-regexp: 4.0.0 eslint-scope: 7.2.2 @@ -24230,7 +24239,7 @@ snapshots: content-type: 1.0.5 cookie: 0.7.2 cookie-signature: 1.2.2 - debug: 4.4.3(supports-color@9.1.0) + debug: 4.4.3(supports-color@8.1.1) depd: 2.0.0 encodeurl: 2.0.0 escape-html: 1.0.3 @@ -24431,7 +24440,7 @@ snapshots: finalhandler@2.1.0: dependencies: - debug: 4.4.3(supports-color@9.1.0) + debug: 4.4.3(supports-color@8.1.1) encodeurl: 2.0.0 escape-html: 1.0.3 on-finished: 2.4.1 @@ -24445,7 +24454,7 @@ snapshots: '@actions/core': 2.0.3 arg: 5.0.2 console.table: 0.10.0 - debug: 4.4.3(supports-color@9.1.0) + debug: 4.4.3(supports-color@8.1.1) find-test-names: 1.29.19(@babel/core@7.28.4) minimatch: 5.1.6 pluralize: 8.0.0 @@ -24469,7 +24478,7 @@ snapshots: '@babel/parser': 7.29.0 '@babel/plugin-syntax-jsx': 7.27.1(@babel/core@7.28.4) acorn-walk: 8.2.0 - debug: 4.4.3(supports-color@9.1.0) + debug: 4.4.3(supports-color@8.1.1) simple-bin-help: 1.8.0 tinyglobby: 0.2.15 transitivePeerDependencies: @@ -24761,7 +24770,7 @@ snapshots: dependencies: basic-ftp: 5.0.4 data-uri-to-buffer: 6.0.2 - debug: 4.4.3(supports-color@9.1.0) + debug: 4.4.3(supports-color@8.1.1) fs-extra: 11.3.2 transitivePeerDependencies: - supports-color @@ -25246,14 +25255,14 @@ snapshots: dependencies: '@tootallnate/once': 2.0.0 agent-base: 6.0.2 - debug: 4.4.3(supports-color@9.1.0) + debug: 4.4.3(supports-color@8.1.1) transitivePeerDependencies: - supports-color http-proxy-agent@7.0.2: dependencies: agent-base: 7.1.3 - debug: 4.4.3(supports-color@9.1.0) + debug: 4.4.3(supports-color@8.1.1) transitivePeerDependencies: - supports-color @@ -25266,14 +25275,14 @@ snapshots: https-proxy-agent@5.0.1: dependencies: agent-base: 6.0.2 - debug: 4.4.3(supports-color@9.1.0) + debug: 4.4.3(supports-color@8.1.1) transitivePeerDependencies: - supports-color https-proxy-agent@7.0.6: dependencies: agent-base: 7.1.3 - debug: 4.4.3(supports-color@9.1.0) + debug: 4.4.3(supports-color@8.1.1) transitivePeerDependencies: - supports-color @@ -25427,7 +25436,7 @@ snapshots: dependencies: '@ioredis/commands': 1.2.0 cluster-key-slot: 1.1.0 - debug: 4.4.3(supports-color@9.1.0) + debug: 4.4.3(supports-color@8.1.1) denque: 2.1.0 lodash.defaults: 4.2.0 lodash.isarguments: 3.1.0 @@ -25724,7 +25733,7 @@ snapshots: istanbul-lib-source-maps@4.0.0: dependencies: - debug: 4.4.3(supports-color@9.1.0) + debug: 4.4.3(supports-color@8.1.1) istanbul-lib-coverage: 3.2.0 source-map: 0.6.1 transitivePeerDependencies: @@ -27256,7 +27265,7 @@ snapshots: micromark@2.11.4: dependencies: - debug: 4.4.3(supports-color@9.1.0) + debug: 4.4.3(supports-color@8.1.1) parse-entities: 2.0.0 transitivePeerDependencies: - supports-color @@ -27264,7 +27273,7 @@ snapshots: micromark@4.0.1: dependencies: '@types/debug': 4.1.7 - debug: 4.4.3(supports-color@9.1.0) + debug: 4.4.3(supports-color@8.1.1) decode-named-character-reference: 1.0.2 devlop: 1.1.0 micromark-core-commonmark: 2.0.2 @@ -27609,7 +27618,7 @@ snapshots: nock@13.5.1: dependencies: - debug: 4.4.3(supports-color@9.1.0) + debug: 4.4.3(supports-color@8.1.1) json-stringify-safe: 5.0.1 propagate: 2.0.1 transitivePeerDependencies: @@ -28051,7 +28060,7 @@ snapshots: dependencies: '@tootallnate/quickjs-emscripten': 0.23.0 agent-base: 7.1.3 - debug: 4.4.3(supports-color@9.1.0) + debug: 4.4.3(supports-color@8.1.1) get-uri: 6.0.3 http-proxy-agent: 7.0.2 https-proxy-agent: 7.0.6 @@ -28323,7 +28332,7 @@ snapshots: pm2-axon-rpc@0.7.1: dependencies: - debug: 4.4.3(supports-color@9.1.0) + debug: 4.4.3(supports-color@8.1.1) transitivePeerDependencies: - supports-color @@ -28331,7 +28340,7 @@ snapshots: dependencies: amp: 0.3.1 amp-message: 0.1.2 - debug: 4.4.3(supports-color@9.1.0) + debug: 4.4.3(supports-color@8.1.1) escape-string-regexp: 4.0.0 transitivePeerDependencies: - supports-color @@ -28348,7 +28357,7 @@ snapshots: pm2-sysmonit@1.2.8: dependencies: async: 3.2.6 - debug: 4.4.3(supports-color@9.1.0) + debug: 4.4.3(supports-color@8.1.1) pidusage: 2.0.21 systeminformation: 5.30.7 tx2: 1.0.5 @@ -28370,7 +28379,7 @@ snapshots: commander: 2.15.1 croner: 4.1.97 dayjs: 1.11.15 - debug: 4.4.3(supports-color@9.1.0) + debug: 4.4.3(supports-color@8.1.1) enquirer: 2.3.6 eventemitter2: 5.0.1 fclone: 1.0.11 @@ -28717,7 +28726,7 @@ snapshots: proxy-agent@6.4.0: dependencies: agent-base: 7.1.3 - debug: 4.4.3(supports-color@9.1.0) + debug: 4.4.3(supports-color@8.1.1) http-proxy-agent: 7.0.2 https-proxy-agent: 7.0.6 lru-cache: 7.18.3 @@ -29363,7 +29372,7 @@ snapshots: require-in-the-middle@5.2.0: dependencies: - debug: 4.4.3(supports-color@9.1.0) + debug: 4.4.3(supports-color@8.1.1) module-details-from-path: 1.0.4 resolve: 1.22.11 transitivePeerDependencies: @@ -29371,7 +29380,7 @@ snapshots: require-in-the-middle@7.5.2: dependencies: - debug: 4.4.3(supports-color@9.1.0) + debug: 4.4.3(supports-color@8.1.1) module-details-from-path: 1.0.4 resolve: 1.22.11 transitivePeerDependencies: @@ -29379,7 +29388,7 @@ snapshots: require-in-the-middle@8.0.1: dependencies: - debug: 4.4.3(supports-color@9.1.0) + debug: 4.4.3(supports-color@8.1.1) module-details-from-path: 1.0.4 transitivePeerDependencies: - supports-color @@ -29501,7 +29510,7 @@ snapshots: router@2.2.0: dependencies: - debug: 4.4.3(supports-color@9.1.0) + debug: 4.4.3(supports-color@8.1.1) depd: 2.0.0 is-promise: 4.0.0 parseurl: 1.3.3 @@ -29634,7 +29643,7 @@ snapshots: send@1.2.0: dependencies: - debug: 4.4.3(supports-color@9.1.0) + debug: 4.4.3(supports-color@8.1.1) encodeurl: 2.0.0 escape-html: 1.0.3 etag: 1.8.1 @@ -29799,7 +29808,7 @@ snapshots: dependencies: '@kwsites/file-exists': 1.1.1 '@kwsites/promise-deferred': 1.1.1 - debug: 4.4.3(supports-color@9.1.0) + debug: 4.4.3(supports-color@8.1.1) transitivePeerDependencies: - supports-color @@ -29906,7 +29915,7 @@ snapshots: socks-proxy-agent@7.0.0: dependencies: agent-base: 6.0.2 - debug: 4.4.3(supports-color@9.1.0) + debug: 4.4.3(supports-color@8.1.1) socks: 2.7.3 transitivePeerDependencies: - supports-color @@ -29914,7 +29923,7 @@ snapshots: socks-proxy-agent@8.0.2: dependencies: agent-base: 7.1.3 - debug: 4.4.3(supports-color@9.1.0) + debug: 4.4.3(supports-color@8.1.1) socks: 2.7.3 transitivePeerDependencies: - supports-color @@ -29956,7 +29965,7 @@ snapshots: spec-change@1.11.20: dependencies: arg: 5.0.2 - debug: 4.4.3(supports-color@9.1.0) + debug: 4.4.3(supports-color@8.1.1) deep-equal: 2.2.3 dependency-tree: 11.3.0 lazy-ass: 2.0.3 @@ -31341,7 +31350,7 @@ snapshots: vite-node@3.2.4(@types/node@24.3.1)(jiti@2.6.1)(lightningcss@1.22.0)(sugarss@5.0.1(postcss@8.5.6))(terser@5.46.1)(tsx@4.19.4)(yaml@2.7.0): dependencies: cac: 6.7.14 - debug: 4.4.3(supports-color@9.1.0) + debug: 4.4.3(supports-color@8.1.1) es-module-lexer: 1.7.0 pathe: 2.0.3 vite: 7.1.10(@types/node@24.3.1)(jiti@2.6.1)(lightningcss@1.22.0)(sugarss@5.0.1(postcss@8.5.6))(terser@5.46.1)(tsx@4.19.4)(yaml@2.7.0) @@ -31362,7 +31371,7 @@ snapshots: vite-node@3.2.4(@types/node@24.3.1)(jiti@2.6.1)(lightningcss@1.22.0)(sugarss@5.0.1(postcss@8.5.6))(terser@5.46.1)(tsx@4.19.4)(yaml@2.8.2): dependencies: cac: 6.7.14 - debug: 4.4.3(supports-color@9.1.0) + debug: 4.4.3(supports-color@8.1.1) es-module-lexer: 1.7.0 pathe: 2.0.3 vite: 7.1.10(@types/node@24.3.1)(jiti@2.6.1)(lightningcss@1.22.0)(sugarss@5.0.1(postcss@8.5.6))(terser@5.46.1)(tsx@4.19.4)(yaml@2.8.2) @@ -31398,7 +31407,7 @@ snapshots: '@volar/typescript': 2.4.23 '@vue/language-core': 2.2.0(typescript@6.0.0-beta) compare-versions: 6.1.1 - debug: 4.4.3(supports-color@9.1.0) + debug: 4.4.3(supports-color@8.1.1) kolorist: 1.8.0 local-pkg: 1.1.2 magic-string: 0.30.21 @@ -31505,7 +31514,7 @@ snapshots: '@vitest/spy': 3.2.4 '@vitest/utils': 3.2.4 chai: 5.2.0 - debug: 4.4.3(supports-color@9.1.0) + debug: 4.4.3(supports-color@8.1.1) expect-type: 1.2.1 magic-string: 0.30.21 pathe: 2.0.3 @@ -31547,7 +31556,7 @@ snapshots: '@vitest/spy': 3.2.4 '@vitest/utils': 3.2.4 chai: 5.2.0 - debug: 4.4.3(supports-color@9.1.0) + debug: 4.4.3(supports-color@8.1.1) expect-type: 1.2.1 magic-string: 0.30.21 pathe: 2.0.3