@malloydata/malloy 0.0.321 → 0.0.323

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,4 +1,4 @@
1
- import type { Expr, Sampling, AtomicTypeDef, MeasureTimeExpr, TimeTruncExpr, TimeExtractExpr, TimeDeltaExpr, TypecastExpr, RegexMatchExpr, TimeLiteralNode, RecordLiteralNode, ArrayLiteralNode, BasicAtomicTypeDef, OrderBy } from '../model/malloy_types';
1
+ import type { Expr, Sampling, AtomicTypeDef, MeasureTimeExpr, TimeExtractExpr, TypecastExpr, RegexMatchExpr, TimeLiteralNode, RecordLiteralNode, ArrayLiteralNode, BasicAtomicTypeDef, OrderBy, TimestampUnit, TimeExpr } from '../model/malloy_types';
2
2
  import type { DialectFunctionOverloadDef } from './functions';
3
3
  interface DialectField {
4
4
  typeDef: AtomicTypeDef;
@@ -91,12 +91,118 @@ export declare abstract class Dialect {
91
91
  sqlLiteralNumber(literal: string): string;
92
92
  ignoreInProject(_fieldName: string): boolean;
93
93
  abstract sqlNowExpr(): string;
94
- abstract sqlTruncExpr(qi: QueryInfo, toTrunc: TimeTruncExpr): string;
95
94
  abstract sqlTimeExtractExpr(qi: QueryInfo, xFrom: TimeExtractExpr): string;
96
95
  abstract sqlMeasureTimeExpr(e: MeasureTimeExpr): string;
97
- abstract sqlAlterTimeExpr(df: TimeDeltaExpr): string;
98
96
  abstract sqlCast(qi: QueryInfo, cast: TypecastExpr): string;
99
97
  abstract sqlRegexpMatch(df: RegexMatchExpr): string;
98
+ /**
99
+ * Converts a UTC timestamp expression to civil (local) time in the specified timezone.
100
+ * Used when performing timezone-aware calendar arithmetic.
101
+ *
102
+ * Example outputs:
103
+ * - BigQuery: `DATETIME(timestamp_expr, 'Europe/Dublin')`
104
+ * - PostgreSQL: `(timestamp_expr)::TIMESTAMPTZ AT TIME ZONE 'Europe/Dublin'`
105
+ * - DuckDB: `(timestamp_expr)::TIMESTAMPTZ AT TIME ZONE 'Europe/Dublin'`
106
+ * - Trino: `timestamp_expr AT TIME ZONE 'Europe/Dublin'`
107
+ *
108
+ * @param expr The SQL expression for the UTC timestamp
109
+ * @param timezone The target timezone (e.g., 'Europe/Dublin', 'America/Los_Angeles')
110
+ * @returns SQL expression representing the timestamp in civil (local) time
111
+ */
112
+ abstract sqlConvertToCivilTime(expr: string, timezone: string): string;
113
+ /**
114
+ * Converts a civil (local) time expression back to a UTC timestamp.
115
+ * The inverse of sqlConvertToCivilTime.
116
+ *
117
+ * Example outputs:
118
+ * - BigQuery: `TIMESTAMP(datetime_expr, 'Europe/Dublin')`
119
+ * - PostgreSQL: `((datetime_expr) AT TIME ZONE 'Europe/Dublin')::TIMESTAMP`
120
+ * - DuckDB: `((datetime_expr) AT TIME ZONE 'Europe/Dublin')::TIMESTAMP`
121
+ * - Trino: `CAST(at_timezone(timestamptz_expr, 'UTC') AS TIMESTAMP)`
122
+ *
123
+ * @param expr The SQL expression for the civil time
124
+ * @param timezone The timezone of the civil time
125
+ * @returns SQL expression representing the UTC timestamp
126
+ */
127
+ abstract sqlConvertFromCivilTime(expr: string, timezone: string): string;
128
+ /**
129
+ * Truncates a time expression to the specified unit.
130
+ *
131
+ * @param expr The SQL expression to truncate
132
+ * @param unit The unit to truncate to (year, month, day, hour, etc.)
133
+ * @param typeDef The Malloy type of the expression (date, timestamp, etc.)
134
+ * @param inCivilTime If true, the expression is already in civil (local) time and should not
135
+ * be converted. If false, may need timezone conversion for timestamps.
136
+ * @param timezone Optional timezone for the operation. Only provided when timezone-aware
137
+ * truncation is needed but inCivilTime is false.
138
+ * @returns SQL expression representing the truncated time
139
+ */
140
+ abstract sqlTruncate(expr: string, unit: TimestampUnit, typeDef: AtomicTypeDef, inCivilTime: boolean, timezone?: string): string;
141
+ /**
142
+ * Adds or subtracts a time interval from a time expression.
143
+ *
144
+ * @param expr The SQL expression to offset
145
+ * @param op The operation: '+' for addition, '-' for subtraction
146
+ * @param magnitude The SQL expression for the interval magnitude (e.g., '6', '(delta_val)')
147
+ * @param unit The interval unit (year, month, day, hour, etc.)
148
+ * @param typeDef The Malloy type of the expression (date, timestamp, etc.)
149
+ * @param inCivilTime If true, the expression is already in civil (local) time and should not
150
+ * be converted. If false, may need timezone conversion for timestamps.
151
+ * @param timezone Optional timezone for the operation. Only provided when timezone-aware
152
+ * offset is needed but inCivilTime is false.
153
+ * @returns SQL expression representing the offset time
154
+ */
155
+ abstract sqlOffsetTime(expr: string, op: '+' | '-', magnitude: string, unit: TimestampUnit, typeDef: AtomicTypeDef, inCivilTime: boolean, timezone?: string): string;
156
+ /**
157
+ * Determines whether a truncation and/or offset operation needs to be performed in
158
+ * civil (local) time, and if so, which timezone to use.
159
+ *
160
+ * Calendar-based operations (day, week, month, quarter, year) typically need civil time
161
+ * computation because these units can cross DST boundaries, changing the UTC offset.
162
+ *
163
+ * Default implementation:
164
+ * - Returns true for timestamps with truncation or calendar-unit offsets
165
+ * - Uses the query timezone from QueryInfo, or 'UTC' if none specified
166
+ * - Returns false for dates or sub-day offsets (hour, minute, second)
167
+ *
168
+ * Dialects can override this if they have different rules about which operations
169
+ * require civil time computation.
170
+ *
171
+ * @param typeDef The Malloy type of the base expression
172
+ * @param truncateTo The truncation unit, if any
173
+ * @param offsetUnit The offset unit, if any
174
+ * @param qi Query information including timezone settings
175
+ * @returns Object with `needed` boolean and optional `tz` string
176
+ */
177
+ needsCivilTimeComputation(typeDef: AtomicTypeDef, truncateTo: TimestampUnit | undefined, offsetUnit: TimestampUnit | undefined, qi: QueryInfo): {
178
+ needed: boolean;
179
+ tz: string | undefined;
180
+ };
181
+ /**
182
+ * Unified function for truncation and/or offset operations. This turns out to
183
+ * be a very common operation and one which can be optimized by doing it together.
184
+ *
185
+ * Much of the complexity has to do with getting timezone values set up so that
186
+ * they will truncate/offset in the query timezone instead of UTC.
187
+ *
188
+ * The intention is that this implementation will work for all dialects, and all
189
+ * the dialect peculiarities are handled with the new primitives introduced to
190
+ * support this function:
191
+ * - needsCivilTimeComputation: Determines if operation needs civil time
192
+ * - sqlConvertToCivilTime/sqlConvertFromCivilTime: Timezone conversion
193
+ * - sqlTruncate: Truncation operation
194
+ * - sqlOffsetTime: Interval arithmetic
195
+ *
196
+ * @param baseExpr The time expression to operate on (already compiled, with .sql populated)
197
+ * @param qi Query information including timezone
198
+ * @param truncateTo Optional truncation unit (year, month, day, etc.)
199
+ * @param offset Optional offset to apply (after truncation if both present)
200
+ */
201
+ sqlTruncAndOffset(baseExpr: TimeExpr, qi: QueryInfo, truncateTo?: TimestampUnit, offset?: {
202
+ op: '+' | '-';
203
+ magnitude: string;
204
+ unit: TimestampUnit;
205
+ }): string;
100
206
  abstract sqlLiteralTime(qi: QueryInfo, df: TimeLiteralNode): string;
101
207
  abstract sqlLiteralString(literal: string): string;
102
208
  abstract sqlLiteralRegexp(literal: string): string;
@@ -104,6 +104,84 @@ class Dialect {
104
104
  ignoreInProject(_fieldName) {
105
105
  return false;
106
106
  }
107
+ /**
108
+ * Determines whether a truncation and/or offset operation needs to be performed in
109
+ * civil (local) time, and if so, which timezone to use.
110
+ *
111
+ * Calendar-based operations (day, week, month, quarter, year) typically need civil time
112
+ * computation because these units can cross DST boundaries, changing the UTC offset.
113
+ *
114
+ * Default implementation:
115
+ * - Returns true for timestamps with truncation or calendar-unit offsets
116
+ * - Uses the query timezone from QueryInfo, or 'UTC' if none specified
117
+ * - Returns false for dates or sub-day offsets (hour, minute, second)
118
+ *
119
+ * Dialects can override this if they have different rules about which operations
120
+ * require civil time computation.
121
+ *
122
+ * @param typeDef The Malloy type of the base expression
123
+ * @param truncateTo The truncation unit, if any
124
+ * @param offsetUnit The offset unit, if any
125
+ * @param qi Query information including timezone settings
126
+ * @returns Object with `needed` boolean and optional `tz` string
127
+ */
128
+ needsCivilTimeComputation(typeDef, truncateTo, offsetUnit, qi) {
129
+ // Calendar units that can cross DST boundaries
130
+ const calendarUnits = ['day', 'week', 'month', 'quarter', 'year'];
131
+ const isCalendarTruncate = truncateTo !== undefined && calendarUnits.includes(truncateTo);
132
+ const isCalendarOffset = offsetUnit !== undefined && calendarUnits.includes(offsetUnit);
133
+ const tz = qtz(qi);
134
+ // Timestamps with calendar truncation/offset need civil time computation
135
+ // BUT only if there's actually a timezone to convert to/from
136
+ const needed = malloy_types_1.TD.isTimestamp(typeDef) &&
137
+ (isCalendarTruncate || isCalendarOffset) &&
138
+ tz !== undefined;
139
+ return { needed, tz };
140
+ }
141
+ /**
142
+ * Unified function for truncation and/or offset operations. This turns out to
143
+ * be a very common operation and one which can be optimized by doing it together.
144
+ *
145
+ * Much of the complexity has to do with getting timezone values set up so that
146
+ * they will truncate/offset in the query timezone instead of UTC.
147
+ *
148
+ * The intention is that this implementation will work for all dialects, and all
149
+ * the dialect peculiarities are handled with the new primitives introduced to
150
+ * support this function:
151
+ * - needsCivilTimeComputation: Determines if operation needs civil time
152
+ * - sqlConvertToCivilTime/sqlConvertFromCivilTime: Timezone conversion
153
+ * - sqlTruncate: Truncation operation
154
+ * - sqlOffsetTime: Interval arithmetic
155
+ *
156
+ * @param baseExpr The time expression to operate on (already compiled, with .sql populated)
157
+ * @param qi Query information including timezone
158
+ * @param truncateTo Optional truncation unit (year, month, day, etc.)
159
+ * @param offset Optional offset to apply (after truncation if both present)
160
+ */
161
+ sqlTruncAndOffset(baseExpr, qi, truncateTo, offset) {
162
+ // Determine if we need to work in civil (local) time
163
+ const { needed: needsCivil, tz } = this.needsCivilTimeComputation(baseExpr.typeDef, truncateTo, offset === null || offset === void 0 ? void 0 : offset.unit, qi);
164
+ if (needsCivil && tz) {
165
+ // Civil time path: convert to local time, operate, convert back to UTC
166
+ let expr = this.sqlConvertToCivilTime(baseExpr.sql, tz);
167
+ if (truncateTo) {
168
+ expr = this.sqlTruncate(expr, truncateTo, baseExpr.typeDef, true, tz);
169
+ }
170
+ if (offset) {
171
+ expr = this.sqlOffsetTime(expr, offset.op, offset.magnitude, offset.unit, baseExpr.typeDef, true, tz);
172
+ }
173
+ return this.sqlConvertFromCivilTime(expr, tz);
174
+ }
175
+ // Simple path: no civil time conversion needed
176
+ let sql = baseExpr.sql;
177
+ if (truncateTo) {
178
+ sql = this.sqlTruncate(sql, truncateTo, baseExpr.typeDef, false, qtz(qi));
179
+ }
180
+ if (offset) {
181
+ sql = this.sqlOffsetTime(sql, offset.op, offset.magnitude, offset.unit, baseExpr.typeDef, false, qtz(qi));
182
+ }
183
+ return sql;
184
+ }
107
185
  /**
108
186
  * The dialect has a chance to over-ride how expressions are translated. If
109
187
  * "undefined" is returned then the translation is left to the query translator.
@@ -120,10 +198,27 @@ class Dialect {
120
198
  return this.sqlNowExpr();
121
199
  case 'timeDiff':
122
200
  return this.sqlMeasureTimeExpr(df);
123
- case 'delta':
124
- return this.sqlAlterTimeExpr(df);
201
+ case 'delta': {
202
+ // Optimize: if delta's base is a trunc, combine them
203
+ const base = df.kids.base;
204
+ if (base.node === 'trunc') {
205
+ // Combined trunc + offset - pass the base of the truncation
206
+ return this.sqlTruncAndOffset(base.e, qi, base.units, {
207
+ op: df.op,
208
+ magnitude: df.kids.delta.sql,
209
+ unit: df.units,
210
+ });
211
+ }
212
+ // Just offset, no truncation - pass the delta's base
213
+ return this.sqlTruncAndOffset(base, qi, undefined, {
214
+ op: df.op,
215
+ magnitude: df.kids.delta.sql,
216
+ unit: df.units,
217
+ });
218
+ }
125
219
  case 'trunc':
126
- return this.sqlTruncExpr(qi, df);
220
+ // Just truncation, no offset
221
+ return this.sqlTruncAndOffset(df.e, qi, df.units);
127
222
  case 'extract':
128
223
  return this.sqlTimeExtractExpr(qi, df);
129
224
  case 'cast':
@@ -1,4 +1,4 @@
1
- import type { Sampling, AtomicTypeDef, TimeDeltaExpr, RegexMatchExpr, MeasureTimeExpr, BasicAtomicTypeDef, RecordLiteralNode, OrderBy } from '../../model/malloy_types';
1
+ import type { Sampling, AtomicTypeDef, RegexMatchExpr, MeasureTimeExpr, BasicAtomicTypeDef, RecordLiteralNode, OrderBy, TimestampUnit } from '../../model/malloy_types';
2
2
  import type { DialectFunctionOverloadDef } from '../functions';
3
3
  import type { DialectFieldList, FieldReferenceType } from '../dialect';
4
4
  import { PostgresBase } from '../pg_impl';
@@ -60,7 +60,10 @@ export declare class DuckDBDialect extends PostgresBase {
60
60
  castToString(expression: string): string;
61
61
  concat(...values: string[]): string;
62
62
  validateTypeName(sqlType: string): boolean;
63
- sqlAlterTimeExpr(df: TimeDeltaExpr): string;
63
+ sqlConvertToCivilTime(expr: string, timezone: string): string;
64
+ sqlConvertFromCivilTime(expr: string, timezone: string): string;
65
+ sqlTruncate(expr: string, unit: TimestampUnit, _typeDef: AtomicTypeDef, _inCivilTime: boolean, _timezone?: string): string;
66
+ sqlOffsetTime(expr: string, op: '+' | '-', magnitude: string, unit: TimestampUnit, _typeDef: AtomicTypeDef, _inCivilTime: boolean, _timezone?: string): string;
64
67
  sqlRegexpMatch(df: RegexMatchExpr): string;
65
68
  sqlMeasureTimeExpr(df: MeasureTimeExpr): string;
66
69
  sqlLiteralRecord(lit: RecordLiteralNode): string;
@@ -323,19 +323,30 @@ class DuckDBDialect extends pg_impl_1.PostgresBase {
323
323
  // Brackets: INT[ ]
324
324
  return sqlType.match(/^[A-Za-z\s(),[\]0-9]*$/) !== null;
325
325
  }
326
- sqlAlterTimeExpr(df) {
327
- let timeframe = df.units;
328
- let n = df.kids.delta.sql;
329
- if (timeframe === 'quarter') {
330
- timeframe = 'month';
331
- n = `${n}*3`;
326
+ sqlConvertToCivilTime(expr, timezone) {
327
+ return `(${expr})::TIMESTAMPTZ AT TIME ZONE '${timezone}'`;
328
+ }
329
+ sqlConvertFromCivilTime(expr, timezone) {
330
+ return `((${expr}) AT TIME ZONE '${timezone}')::TIMESTAMP`;
331
+ }
332
+ sqlTruncate(expr, unit, _typeDef, _inCivilTime, _timezone) {
333
+ // DuckDB starts weeks on Monday, Malloy wants Sunday
334
+ // Add 1 day before truncating, subtract 1 day after
335
+ if (unit === 'week') {
336
+ return `(DATE_TRUNC('${unit}', (${expr} + INTERVAL '1' DAY)) - INTERVAL '1' DAY)`;
332
337
  }
333
- else if (timeframe === 'week') {
334
- timeframe = 'day';
335
- n = `${n}*7`;
338
+ return `DATE_TRUNC('${unit}', ${expr})`;
339
+ }
340
+ sqlOffsetTime(expr, op, magnitude, unit, _typeDef, _inCivilTime, _timezone) {
341
+ // DuckDB doesn't support INTERVAL '1' WEEK, convert to days
342
+ let offsetUnit = unit;
343
+ let offsetMag = magnitude;
344
+ if (unit === 'week') {
345
+ offsetUnit = 'day';
346
+ offsetMag = `(${magnitude})*7`;
336
347
  }
337
- const interval = `INTERVAL (${n}) ${timeframe}`;
338
- return `${df.kids.base.sql} ${df.op} ${interval}`;
348
+ const interval = `INTERVAL (${offsetMag}) ${offsetUnit}`;
349
+ return `(${expr} ${op} ${interval})`;
339
350
  }
340
351
  sqlRegexpMatch(df) {
341
352
  return `REGEXP_MATCHES(${df.kids.expr.sql},${df.kids.regex.sql})`;
@@ -1,4 +1,4 @@
1
- import type { Sampling, MeasureTimeExpr, TimeLiteralNode, RegexMatchExpr, TimeDeltaExpr, TimeTruncExpr, TimeExtractExpr, TypecastExpr, BasicAtomicTypeDef, AtomicTypeDef, ArrayLiteralNode, RecordLiteralNode } from '../../model/malloy_types';
1
+ import type { Sampling, MeasureTimeExpr, TimeLiteralNode, RegexMatchExpr, TimeExtractExpr, TypecastExpr, BasicAtomicTypeDef, AtomicTypeDef, ArrayLiteralNode, RecordLiteralNode } from '../../model/malloy_types';
2
2
  import type { BooleanTypeSupport, DialectFieldList, FieldReferenceType, OrderByClauseType, QueryInfo } from '../dialect';
3
3
  import { Dialect } from '../dialect';
4
4
  import type { DialectFunctionOverloadDef } from '../functions';
@@ -56,10 +56,11 @@ export declare class MySQLDialect extends Dialect {
56
56
  sqlMaybeQuoteIdentifier(identifier: string): string;
57
57
  sqlCreateTableAsSelect(_tableName: string, _sql: string): string;
58
58
  sqlNowExpr(): string;
59
- sqlTruncExpr(qi: QueryInfo, trunc: TimeTruncExpr): string;
60
- truncToUnit(expr: string, units: string): string;
59
+ sqlConvertToCivilTime(expr: string, timezone: string): string;
60
+ sqlConvertFromCivilTime(expr: string, timezone: string): string;
61
+ sqlTruncate(expr: string, unit: string, _typeDef: AtomicTypeDef, _inCivilTime: boolean, _timezone?: string): string;
62
+ sqlOffsetTime(expr: string, op: '+' | '-', magnitude: string, unit: string, _typeDef: AtomicTypeDef, _inCivilTime: boolean, _timezone?: string): string;
61
63
  sqlTimeExtractExpr(qi: QueryInfo, te: TimeExtractExpr): string;
62
- sqlAlterTimeExpr(df: TimeDeltaExpr): string;
63
64
  sqlCast(qi: QueryInfo, cast: TypecastExpr): string;
64
65
  sqlRegexpMatch(df: RegexMatchExpr): string;
65
66
  sqlLiteralTime(qi: QueryInfo, lt: TimeLiteralNode): string;
@@ -291,38 +291,21 @@ class MySQLDialect extends dialect_1.Dialect {
291
291
  sqlNowExpr() {
292
292
  return 'LOCALTIMESTAMP';
293
293
  }
294
- sqlTruncExpr(qi, trunc) {
295
- const truncThis = trunc.e.sql || 'internal-error-in-sql-generation';
296
- const week = trunc.units === 'week';
297
- // Only do timezone conversion for timestamps, not dates
298
- if (malloy_types_1.TD.isTimestamp(trunc.e.typeDef)) {
299
- const tz = (0, dialect_1.qtz)(qi);
300
- if (tz) {
301
- // Convert timestamp to the query timezone (civil time)
302
- const civilSource = `(CONVERT_TZ(${truncThis}, 'UTC','${tz}'))`;
303
- // For week truncation, we need to adjust to Sunday in the civil timezone
304
- // DAYOFWEEK returns 1=Sunday, 2=Monday, etc., so subtract (DAYOFWEEK-1) days
305
- const adjustedSource = week
306
- ? `DATE_SUB(${civilSource}, INTERVAL DAYOFWEEK(${civilSource}) - 1 DAY)`
307
- : civilSource;
308
- // Truncate to the appropriate unit in civil time
309
- const civilTrunc = `${this.truncToUnit(adjustedSource, trunc.units)}`;
310
- // Convert the truncated civil time back to UTC
311
- const truncTsTz = `CONVERT_TZ(${civilTrunc}, '${tz}', 'UTC')`;
312
- return `(${truncTsTz})`; // TODO: should it cast?
313
- }
314
- }
315
- // For dates (civil time) or timestamps without query timezone
316
- // do the week adjustment before truncating
317
- const adjustedThis = week
318
- ? `DATE_SUB(${truncThis}, INTERVAL DAYOFWEEK(${truncThis}) - 1 DAY)`
319
- : truncThis;
320
- const result = `${this.truncToUnit(adjustedThis, trunc.units)}`;
321
- return result;
322
- }
323
- truncToUnit(expr, units) {
294
+ sqlConvertToCivilTime(expr, timezone) {
295
+ return `CONVERT_TZ(${expr}, 'UTC', '${timezone}')`;
296
+ }
297
+ sqlConvertFromCivilTime(expr, timezone) {
298
+ return `CONVERT_TZ(${expr}, '${timezone}', 'UTC')`;
299
+ }
300
+ sqlTruncate(expr, unit, _typeDef, _inCivilTime, _timezone) {
301
+ // For week truncation, adjust to Sunday first
302
+ // DAYOFWEEK returns 1=Sunday, 2=Monday, etc., so subtract (DAYOFWEEK-1) days
303
+ const adjustedExpr = unit === 'week'
304
+ ? `DATE_SUB(${expr}, INTERVAL DAYOFWEEK(${expr}) - 1 DAY)`
305
+ : expr;
306
+ // Generate truncation using DATE_FORMAT
324
307
  let format = "'%Y-%m-%d %H:%i:%s'";
325
- switch (units) {
308
+ switch (unit) {
326
309
  case 'minute':
327
310
  format = "'%Y-%m-%d %H:%i:00'";
328
311
  break;
@@ -337,13 +320,28 @@ class MySQLDialect extends dialect_1.Dialect {
337
320
  format = "'%Y-%m-01 00:00:00'";
338
321
  break;
339
322
  case 'quarter':
340
- format = `CASE WHEN MONTH(${expr}) > 9 THEN '%Y-10-01 00:00:00' WHEN MONTH(${expr}) > 6 THEN '%Y-07-01 00:00:00' WHEN MONTH(${expr}) > 3 THEN '%Y-04-01 00:00:00' ELSE '%Y-01-01 00:00:00' end`;
323
+ format = `CASE WHEN MONTH(${adjustedExpr}) > 9 THEN '%Y-10-01 00:00:00' WHEN MONTH(${adjustedExpr}) > 6 THEN '%Y-07-01 00:00:00' WHEN MONTH(${adjustedExpr}) > 3 THEN '%Y-04-01 00:00:00' ELSE '%Y-01-01 00:00:00' end`;
341
324
  break;
342
325
  case 'year':
343
326
  format = "'%Y-01-01 00:00:00'";
344
327
  break;
345
328
  }
346
- return `TIMESTAMP(DATE_FORMAT(${expr}, ${format}))`;
329
+ return `TIMESTAMP(DATE_FORMAT(${adjustedExpr}, ${format}))`;
330
+ }
331
+ sqlOffsetTime(expr, op, magnitude, unit, _typeDef, _inCivilTime, _timezone) {
332
+ // Convert quarter/week to supported units
333
+ let offsetUnit = unit;
334
+ let offsetMag = magnitude;
335
+ if (unit === 'quarter') {
336
+ offsetUnit = 'month';
337
+ offsetMag = `${magnitude}*3`;
338
+ }
339
+ else if (unit === 'week') {
340
+ offsetUnit = 'day';
341
+ offsetMag = `${magnitude}*7`;
342
+ }
343
+ const interval = `INTERVAL ${offsetMag} ${offsetUnit}`;
344
+ return `(${expr} ${op} ${interval})`;
347
345
  }
348
346
  sqlTimeExtractExpr(qi, te) {
349
347
  const msUnits = msExtractionMap[te.units] || te.units;
@@ -356,20 +354,6 @@ class MySQLDialect extends dialect_1.Dialect {
356
354
  }
357
355
  return `${msUnits}(${extractFrom})`;
358
356
  }
359
- sqlAlterTimeExpr(df) {
360
- let timeframe = df.units;
361
- let n = df.kids.delta.sql;
362
- if (timeframe === 'quarter') {
363
- timeframe = 'month';
364
- n = `${n}*3`;
365
- }
366
- else if (timeframe === 'week') {
367
- timeframe = 'day';
368
- n = `${n}*7`;
369
- }
370
- const interval = `INTERVAL ${n} ${timeframe} `;
371
- return `(${df.kids.base.sql})${df.op}${interval}`;
372
- }
373
357
  sqlCast(qi, cast) {
374
358
  const srcSQL = cast.e.sql || 'internal-error-in-sql-generation';
375
359
  const { op, srcTypeDef, dstTypeDef, dstSQLType } = this.sqlCastPrep(cast);
@@ -1,4 +1,4 @@
1
- import type { ArrayLiteralNode, RecordLiteralNode, RegexMatchExpr, TimeExtractExpr, TimeLiteralNode, TimeTruncExpr, TypecastExpr } from '../model/malloy_types';
1
+ import type { ArrayLiteralNode, RecordLiteralNode, RegexMatchExpr, TimeExtractExpr, TimeLiteralNode, TypecastExpr } from '../model/malloy_types';
2
2
  import type { QueryInfo } from './dialect';
3
3
  import { Dialect } from './dialect';
4
4
  export declare const timeExtractMap: Record<string, string>;
@@ -7,7 +7,6 @@ export declare const timeExtractMap: Record<string, string>;
7
7
  * same implementations for the much of the SQL code generation
8
8
  */
9
9
  export declare abstract class PostgresBase extends Dialect {
10
- sqlTruncExpr(qi: QueryInfo, df: TimeTruncExpr): string;
11
10
  sqlNowExpr(): string;
12
11
  sqlTimeExtractExpr(qi: QueryInfo, from: TimeExtractExpr): string;
13
12
  sqlCast(qi: QueryInfo, cast: TypecastExpr): string;
@@ -18,36 +18,6 @@ exports.timeExtractMap = {
18
18
  * same implementations for the much of the SQL code generation
19
19
  */
20
20
  class PostgresBase extends dialect_1.Dialect {
21
- sqlTruncExpr(qi, df) {
22
- // adjusting for monday/sunday weeks
23
- const week = df.units === 'week';
24
- const truncThis = week ? `${df.e.sql} + INTERVAL '1' DAY` : df.e.sql;
25
- // Only do timezone conversion for timestamps, not dates
26
- if (malloy_types_1.TD.isTimestamp(df.e.typeDef)) {
27
- const tz = (0, dialect_1.qtz)(qi);
28
- if (tz) {
29
- // get a civil version of the time in the query time zone
30
- const civilSource = `((${truncThis})::TIMESTAMPTZ AT TIME ZONE '${tz}')`;
31
- // do truncation in that time space
32
- let civilTrunc = `DATE_TRUNC('${df.units}', ${civilSource})`;
33
- if (week) {
34
- civilTrunc = `(${civilTrunc} - INTERVAL '1' DAY)`;
35
- }
36
- // make a tstz from the civil time ... "AT TIME ZONE" of
37
- // a TIMESTAMP will produce a TIMESTAMPTZ in that zone
38
- // where the civi appeareance is the same as the TIMESTAMP
39
- const truncTsTz = `${civilTrunc} AT TIME ZONE '${tz}'`;
40
- // Now just make a system TIMESTAMP from that
41
- return `(${truncTsTz})::TIMESTAMP`;
42
- }
43
- }
44
- // For dates (civil time) or timestamps without query timezone
45
- let result = `DATE_TRUNC('${df.units}', ${truncThis})`;
46
- if (week) {
47
- result = `(${result} - INTERVAL '1' DAY)`;
48
- }
49
- return result;
50
- }
51
21
  sqlNowExpr() {
52
22
  return 'LOCALTIMESTAMP';
53
23
  }
@@ -1,4 +1,4 @@
1
- import type { Sampling, AtomicTypeDef, TimeDeltaExpr, TypecastExpr, MeasureTimeExpr, BasicAtomicTypeDef, RecordLiteralNode, ArrayLiteralNode, TimeExtractExpr } from '../../model/malloy_types';
1
+ import type { Sampling, AtomicTypeDef, TypecastExpr, MeasureTimeExpr, BasicAtomicTypeDef, RecordLiteralNode, ArrayLiteralNode, TimeExtractExpr, TimestampUnit } from '../../model/malloy_types';
2
2
  import type { DialectFunctionOverloadDef } from '../functions';
3
3
  import { type DialectFieldList, type FieldReferenceType, type QueryInfo } from '../dialect';
4
4
  import { PostgresBase } from '../pg_impl';
@@ -44,7 +44,10 @@ export declare class PostgresDialect extends PostgresBase {
44
44
  sqlFinalStage(lastStageName: string, _fields: string[]): string;
45
45
  sqlSelectAliasAsStruct(alias: string): string;
46
46
  sqlCreateTableAsSelect(_tableName: string, _sql: string): string;
47
- sqlAlterTimeExpr(df: TimeDeltaExpr): string;
47
+ sqlConvertToCivilTime(expr: string, timezone: string): string;
48
+ sqlConvertFromCivilTime(expr: string, timezone: string): string;
49
+ sqlTruncate(expr: string, unit: TimestampUnit, _typeDef: AtomicTypeDef, _inCivilTime: boolean, _timezone?: string): string;
50
+ sqlOffsetTime(expr: string, op: '+' | '-', magnitude: string, unit: TimestampUnit, _typeDef: AtomicTypeDef, _inCivilTime: boolean, _timezone?: string): string;
48
51
  sqlCast(qi: QueryInfo, cast: TypecastExpr): string;
49
52
  sqlMeasureTimeExpr(df: MeasureTimeExpr): string;
50
53
  sqlSumDistinct(key: string, value: string, funcName: string): string;
@@ -209,19 +209,36 @@ class PostgresDialect extends pg_impl_1.PostgresBase {
209
209
  sqlCreateTableAsSelect(_tableName, _sql) {
210
210
  throw new Error('Not implemented Yet');
211
211
  }
212
- sqlAlterTimeExpr(df) {
213
- let timeframe = df.units;
214
- let n = df.kids.delta.sql;
215
- if (timeframe === 'quarter') {
216
- timeframe = 'month';
217
- n = `${n}*3`;
212
+ sqlConvertToCivilTime(expr, timezone) {
213
+ return `(${expr})::TIMESTAMPTZ AT TIME ZONE '${timezone}'`;
214
+ }
215
+ sqlConvertFromCivilTime(expr, timezone) {
216
+ return `((${expr}) AT TIME ZONE '${timezone}')::TIMESTAMP`;
217
+ }
218
+ sqlTruncate(expr, unit, _typeDef, _inCivilTime, _timezone) {
219
+ // PostgreSQL starts weeks on Monday, Malloy wants Sunday
220
+ // Add 1 day before truncating, subtract 1 day after
221
+ if (unit === 'week') {
222
+ return `(DATE_TRUNC('${unit}', (${expr} + INTERVAL '1' DAY)) - INTERVAL '1' DAY)`;
223
+ }
224
+ return `DATE_TRUNC('${unit}', ${expr})`;
225
+ }
226
+ sqlOffsetTime(expr, op, magnitude, unit, _typeDef, _inCivilTime, _timezone) {
227
+ // Convert quarter/week to supported units
228
+ let offsetUnit = unit;
229
+ let offsetMag = magnitude;
230
+ if (unit === 'quarter') {
231
+ offsetUnit = 'month';
232
+ offsetMag = `(${magnitude})*3`;
218
233
  }
219
- else if (timeframe === 'week') {
220
- timeframe = 'day';
221
- n = `${n}*7`;
234
+ else if (unit === 'week') {
235
+ offsetUnit = 'day';
236
+ offsetMag = `(${magnitude})*7`;
222
237
  }
223
- const interval = `make_interval(${pgMakeIntervalMap[timeframe]}=>(${n})::integer)`;
224
- return `(${df.kids.base.sql})${df.op}${interval}`;
238
+ // Map to make_interval parameter name
239
+ const intervalParam = pgMakeIntervalMap[offsetUnit];
240
+ const interval = `make_interval(${intervalParam}=>(${offsetMag})::integer)`;
241
+ return `(${expr} ${op} ${interval})`;
225
242
  }
226
243
  sqlCast(qi, cast) {
227
244
  if (cast.safe) {
@@ -1,4 +1,4 @@
1
- import type { Sampling, AtomicTypeDef, TimeTruncExpr, TimeExtractExpr, TimeDeltaExpr, TypecastExpr, TimeLiteralNode, MeasureTimeExpr, RegexMatchExpr, BasicAtomicTypeDef, ArrayLiteralNode, RecordLiteralNode } from '../../model/malloy_types';
1
+ import type { Sampling, AtomicTypeDef, TimeExtractExpr, TypecastExpr, TimeLiteralNode, MeasureTimeExpr, RegexMatchExpr, BasicAtomicTypeDef, ArrayLiteralNode, RecordLiteralNode } from '../../model/malloy_types';
2
2
  import type { DialectFunctionOverloadDef } from '../functions';
3
3
  import type { DialectFieldList, FieldReferenceType, QueryInfo } from '../dialect';
4
4
  import { Dialect } from '../dialect';
@@ -45,9 +45,11 @@ export declare class SnowflakeDialect extends Dialect {
45
45
  sqlSelectAliasAsStruct(alias: string): string;
46
46
  sqlMaybeQuoteIdentifier(identifier: string): string;
47
47
  sqlCreateTableAsSelect(tableName: string, sql: string): string;
48
- sqlTruncExpr(qi: QueryInfo, te: TimeTruncExpr): string;
48
+ sqlConvertToCivilTime(expr: string, timezone: string): string;
49
+ sqlConvertFromCivilTime(expr: string, timezone: string): string;
50
+ sqlTruncate(expr: string, unit: string, _typeDef: AtomicTypeDef, _inCivilTime: boolean, _timezone?: string): string;
51
+ sqlOffsetTime(expr: string, op: '+' | '-', magnitude: string, unit: string, typeDef: AtomicTypeDef, _inCivilTime: boolean, _timezone?: string): string;
49
52
  sqlTimeExtractExpr(qi: QueryInfo, from: TimeExtractExpr): string;
50
- sqlAlterTimeExpr(df: TimeDeltaExpr): string;
51
53
  private atTz;
52
54
  sqlNowExpr(): string;
53
55
  sqlCast(qi: QueryInfo, cast: TypecastExpr): string;
@@ -249,13 +249,24 @@ ${(0, utils_1.indent)(sql)}
249
249
  );
250
250
  `;
251
251
  }
252
- sqlTruncExpr(qi, te) {
253
- const tz = (0, dialect_1.qtz)(qi);
254
- let truncThis = te.e.sql;
255
- if (tz && malloy_types_1.TD.isTimestamp(te.e.typeDef)) {
256
- truncThis = `CONVERT_TIMEZONE('${tz}',${truncThis})`;
257
- }
258
- return `DATE_TRUNC('${te.units}',${truncThis})`;
252
+ sqlConvertToCivilTime(expr, timezone) {
253
+ // 3-arg form: explicitly convert from UTC to specified timezone
254
+ return `CONVERT_TIMEZONE('UTC', '${timezone}', ${expr})`;
255
+ }
256
+ sqlConvertFromCivilTime(expr, timezone) {
257
+ // After civil time operations, we have a TIMESTAMP_NTZ in the target timezone
258
+ // Convert from timezone to UTC, returning TIMESTAMP_NTZ
259
+ return `CONVERT_TIMEZONE('${timezone}', 'UTC', (${expr})::TIMESTAMP_NTZ)`;
260
+ }
261
+ sqlTruncate(expr, unit, _typeDef, _inCivilTime, _timezone) {
262
+ // Snowflake session is configured with WEEK_START=7 (Sunday)
263
+ // so DATE_TRUNC already truncates to Sunday - no adjustment needed
264
+ return `DATE_TRUNC('${unit}', ${expr})`;
265
+ }
266
+ sqlOffsetTime(expr, op, magnitude, unit, typeDef, _inCivilTime, _timezone) {
267
+ const funcName = typeDef.type === 'date' ? 'DATEADD' : 'TIMESTAMPADD';
268
+ const n = op === '+' ? magnitude : `-(${magnitude})`;
269
+ return `${funcName}(${unit}, ${n}, ${expr})`;
259
270
  }
260
271
  sqlTimeExtractExpr(qi, from) {
261
272
  const extractUnits = extractionMap[from.units] || from.units;
@@ -266,12 +277,6 @@ ${(0, utils_1.indent)(sql)}
266
277
  }
267
278
  return `EXTRACT(${extractUnits} FROM ${extractFrom})`;
268
279
  }
269
- sqlAlterTimeExpr(df) {
270
- var _a;
271
- const add = ((_a = df.typeDef) === null || _a === void 0 ? void 0 : _a.type) === 'date' ? 'DATEADD' : 'TIMESTAMPADD';
272
- const n = df.op === '+' ? df.kids.delta.sql : `-(${df.kids.delta.sql})`;
273
- return `${add}(${df.units},${n},${df.kids.base.sql})`;
274
- }
275
280
  atTz(sqlExpr, tz) {
276
281
  if (tz !== undefined) {
277
282
  return `(
@@ -315,19 +320,15 @@ ${(0, utils_1.indent)(sql)}
315
320
  }
316
321
  sqlLiteralTime(qi, lf) {
317
322
  var _a;
323
+ if (malloy_types_1.TD.isDate(lf.typeDef)) {
324
+ return `TO_DATE('${lf.literal}')`;
325
+ }
318
326
  const tz = (0, dialect_1.qtz)(qi);
319
- // just making it explicit that timestring does not have timezone info
320
327
  let ret = `'${lf.literal}'::TIMESTAMP_NTZ`;
321
- // now do the hack to add timezone to a timestamp ntz
322
328
  const targetTimeZone = (_a = lf.timezone) !== null && _a !== void 0 ? _a : tz;
323
329
  if (targetTimeZone) {
324
- const targetTimeZoneSuffix = `TO_CHAR(CONVERT_TIMEZONE('${targetTimeZone}', '1970-01-01 00:00:00'), 'TZHTZM')`;
325
- const retTimeString = `TO_CHAR(${ret}, 'YYYY-MM-DD HH24:MI:SS.FF9')`;
326
- ret = `${retTimeString} || ${targetTimeZoneSuffix}`;
327
- ret = `(${ret})::TIMESTAMP_TZ`;
328
- }
329
- if (malloy_types_1.TD.isDate(lf.typeDef)) {
330
- return `TO_DATE(${ret})`;
330
+ // Interpret the literal as being in targetTimeZone, convert to UTC
331
+ ret = `CONVERT_TIMEZONE('${targetTimeZone}', 'UTC', ${ret})`;
331
332
  }
332
333
  return ret;
333
334
  }