@malloydata/malloy 0.0.322 → 0.0.324

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.
Files changed (57) hide show
  1. package/dist/api/core.js +2 -0
  2. package/dist/api/util.js +16 -24
  3. package/dist/dialect/dialect.d.ts +200 -4
  4. package/dist/dialect/dialect.js +170 -5
  5. package/dist/dialect/duckdb/duckdb.d.ts +3 -3
  6. package/dist/dialect/duckdb/duckdb.js +22 -15
  7. package/dist/dialect/mysql/mysql.d.ts +11 -5
  8. package/dist/dialect/mysql/mysql.js +45 -54
  9. package/dist/dialect/pg_impl.d.ts +11 -3
  10. package/dist/dialect/pg_impl.js +78 -44
  11. package/dist/dialect/postgres/postgres.d.ts +3 -3
  12. package/dist/dialect/postgres/postgres.js +20 -15
  13. package/dist/dialect/snowflake/snowflake.d.ts +12 -4
  14. package/dist/dialect/snowflake/snowflake.js +92 -42
  15. package/dist/dialect/standardsql/standardsql.d.ts +15 -4
  16. package/dist/dialect/standardsql/standardsql.js +59 -48
  17. package/dist/dialect/tiny_parser.js +1 -1
  18. package/dist/dialect/trino/trino.d.ts +21 -4
  19. package/dist/dialect/trino/trino.js +192 -62
  20. package/dist/index.d.ts +1 -1
  21. package/dist/lang/ast/error-factory.js +1 -1
  22. package/dist/lang/ast/expressions/expr-granular-time.js +26 -8
  23. package/dist/lang/ast/expressions/expr-props.d.ts +24 -0
  24. package/dist/lang/ast/expressions/for-range.d.ts +1 -1
  25. package/dist/lang/ast/expressions/for-range.js +5 -4
  26. package/dist/lang/ast/expressions/time-literal.d.ts +9 -7
  27. package/dist/lang/ast/expressions/time-literal.js +43 -50
  28. package/dist/lang/ast/field-space/static-space.js +5 -1
  29. package/dist/lang/ast/query-items/field-declaration.js +1 -2
  30. package/dist/lang/ast/time-utils.js +1 -1
  31. package/dist/lang/ast/typedesc-utils.d.ts +1 -0
  32. package/dist/lang/ast/typedesc-utils.js +14 -1
  33. package/dist/lang/ast/types/expression-def.js +1 -1
  34. package/dist/lang/ast/types/granular-result.js +2 -1
  35. package/dist/lang/ast/types/malloy-element.js +10 -2
  36. package/dist/lang/composite-source-utils.js +1 -1
  37. package/dist/lang/lib/Malloy/MalloyLexer.d.ts +76 -75
  38. package/dist/lang/lib/Malloy/MalloyLexer.js +1252 -1243
  39. package/dist/lang/lib/Malloy/MalloyParser.d.ts +77 -75
  40. package/dist/lang/lib/Malloy/MalloyParser.js +515 -510
  41. package/dist/lang/malloy-to-stable-query.js +13 -14
  42. package/dist/lang/test/expr-to-str.js +5 -1
  43. package/dist/malloy.d.ts +3 -2
  44. package/dist/malloy.js +6 -0
  45. package/dist/model/expression_compiler.js +172 -145
  46. package/dist/model/field_instance.js +1 -1
  47. package/dist/model/filter_compilers.d.ts +2 -3
  48. package/dist/model/filter_compilers.js +140 -62
  49. package/dist/model/malloy_types.d.ts +50 -16
  50. package/dist/model/malloy_types.js +49 -6
  51. package/dist/model/query_node.d.ts +2 -2
  52. package/dist/model/query_node.js +1 -0
  53. package/dist/model/query_query.js +15 -3
  54. package/dist/to_stable.js +13 -1
  55. package/dist/version.d.ts +1 -1
  56. package/dist/version.js +1 -1
  57. package/package.json +6 -6
package/dist/api/core.js CHANGED
@@ -101,6 +101,8 @@ function typeDefFromField(type) {
101
101
  return { type: 'boolean' };
102
102
  case 'timestamp_type':
103
103
  return { type: 'timestamp', timeframe: type.timeframe };
104
+ case 'timestamptz_type':
105
+ return { type: 'timestamptz', timeframe: type.timeframe };
104
106
  case 'date_type':
105
107
  return { type: 'date', timeframe: type.timeframe };
106
108
  case 'sql_native_type':
package/dist/api/util.js CHANGED
@@ -15,7 +15,6 @@ exports.nodeToLiteralValue = nodeToLiteralValue;
15
15
  exports.mapLogs = mapLogs;
16
16
  const malloy_tag_1 = require("@malloydata/malloy-tag");
17
17
  const annotation_1 = require("../annotation");
18
- const model_1 = require("../model");
19
18
  const to_stable_1 = require("../to_stable");
20
19
  const luxon_1 = require("luxon");
21
20
  function wrapLegacyInfoConnection(connection) {
@@ -92,7 +91,8 @@ function mapData(data, schema) {
92
91
  return { kind: 'null_cell' };
93
92
  }
94
93
  else if (field.type.kind === 'date_type' ||
95
- field.type.kind === 'timestamp_type') {
94
+ field.type.kind === 'timestamp_type' ||
95
+ field.type.kind === 'timestamptz_type') {
96
96
  const time_value = valueToDate(value).toISOString();
97
97
  if (field.type.kind === 'date_type') {
98
98
  return { kind: 'date_cell', date_value: time_value };
@@ -250,28 +250,20 @@ function nodeToLiteralValue(expr) {
250
250
  return { kind: 'boolean_literal', boolean_value: true };
251
251
  case 'false':
252
252
  return { kind: 'boolean_literal', boolean_value: false };
253
- case 'timeLiteral': {
254
- if (expr.typeDef.type === 'date') {
255
- if (expr.typeDef.timeframe === undefined ||
256
- (0, model_1.isDateUnit)(expr.typeDef.timeframe)) {
257
- return {
258
- kind: 'date_literal',
259
- date_value: expr.literal,
260
- timezone: expr.timezone,
261
- granularity: expr.typeDef.timeframe,
262
- };
263
- }
264
- return undefined;
265
- }
266
- else {
267
- return {
268
- kind: 'timestamp_literal',
269
- timestamp_value: expr.literal,
270
- timezone: expr.timezone,
271
- granularity: expr.typeDef.timeframe,
272
- };
273
- }
274
- }
253
+ case 'dateLiteral':
254
+ return {
255
+ kind: 'date_literal',
256
+ date_value: expr.literal,
257
+ granularity: expr.typeDef.timeframe,
258
+ };
259
+ case 'timestampLiteral':
260
+ case 'timestamptzLiteral':
261
+ return {
262
+ kind: 'timestamp_literal',
263
+ timestamp_value: expr.literal,
264
+ timezone: expr.timezone,
265
+ granularity: expr.typeDef.timeframe,
266
+ };
275
267
  default:
276
268
  return undefined;
277
269
  }
@@ -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, TimeLiteralExpr, RecordLiteralNode, ArrayLiteralNode, BasicAtomicTypeDef, OrderBy, TimestampUnit, ATimestampTypeDef, TimeExpr, TemporalFieldType } from '../model/malloy_types';
2
2
  import type { DialectFunctionOverloadDef } from './functions';
3
3
  interface DialectField {
4
4
  typeDef: AtomicTypeDef;
@@ -46,6 +46,7 @@ export declare abstract class Dialect {
46
46
  cantPartitionWindowFunctionsOnExpressions: boolean;
47
47
  supportsPipelinesInViews: boolean;
48
48
  supportsArraysInData: boolean;
49
+ hasTimestamptz: boolean;
49
50
  readsNestedData: boolean;
50
51
  orderByClause: OrderByClauseType;
51
52
  nullMatchesFunctionSignature: boolean;
@@ -61,6 +62,11 @@ export declare abstract class Dialect {
61
62
  compoundObjectInSchema: boolean;
62
63
  booleanType: BooleanTypeSupport;
63
64
  likeEscape: boolean;
65
+ /**
66
+ * Create the appropriate time literal IR node based on dialect support.
67
+ * Static method so it can be called with undefined dialect (e.g., ConstantFieldSpace).
68
+ */
69
+ static makeTimeLiteralNode(dialect: Dialect | undefined, literal: string, timezone: string | undefined, units: TimestampUnit | undefined, typ: TemporalFieldType): TimeLiteralExpr;
64
70
  abstract getDialectFunctionOverrides(): {
65
71
  [name: string]: DialectFunctionOverloadDef[];
66
72
  };
@@ -91,13 +97,203 @@ export declare abstract class Dialect {
91
97
  sqlLiteralNumber(literal: string): string;
92
98
  ignoreInProject(_fieldName: string): boolean;
93
99
  abstract sqlNowExpr(): string;
94
- abstract sqlTruncExpr(qi: QueryInfo, toTrunc: TimeTruncExpr): string;
95
100
  abstract sqlTimeExtractExpr(qi: QueryInfo, xFrom: TimeExtractExpr): string;
96
101
  abstract sqlMeasureTimeExpr(e: MeasureTimeExpr): string;
97
- abstract sqlAlterTimeExpr(df: TimeDeltaExpr): string;
102
+ /**
103
+ * Generate SQL for type casting expressions.
104
+ *
105
+ * Most casts are simple: `CAST(expr AS type)` or `TRY_CAST(expr AS type)` for safe casts.
106
+ *
107
+ * However, when a query timezone is set, casts between temporal types (date, timestamp, timestamptz)
108
+ * require special handling to ensure correct timezone semantics:
109
+ *
110
+ * **Timezone-Aware Cast Semantics:**
111
+ *
112
+ * 1. **TIMESTAMP → DATE**:
113
+ * - TIMESTAMP represents UTC wall clock
114
+ * - Convert to query timezone, then extract date
115
+ * - Example: TIMESTAMP '2020-02-20 00:00:00' with tz 'America/Mexico_City' → '2020-02-19'
116
+ *
117
+ * 2. **TIMESTAMPTZ → DATE**:
118
+ * - TIMESTAMPTZ represents absolute instant
119
+ * - Convert to query timezone, then extract date
120
+ * - Example: TIMESTAMPTZ '2020-02-20 00:00:00 UTC' with tz 'America/Mexico_City' → '2020-02-19'
121
+ *
122
+ * 3. **DATE → TIMESTAMP**:
123
+ * - DATE represents civil date
124
+ * - Interpret as midnight in query timezone, return UTC wall clock
125
+ * - Example: DATE '2020-02-20' with tz 'America/Mexico_City' → TIMESTAMP '2020-02-20 06:00:00' (UTC)
126
+ *
127
+ * 4. **DATE → TIMESTAMPTZ**:
128
+ * - DATE represents civil date
129
+ * - Interpret as midnight in query timezone, create instant
130
+ * - Example: DATE '2020-02-20' with tz 'America/Mexico_City' → instant at 2020-02-20 06:00:00 UTC
131
+ *
132
+ * 5. **TIMESTAMPTZ → TIMESTAMP**:
133
+ * - TIMESTAMPTZ represents absolute instant
134
+ * - Extract wall clock in query timezone, return as TIMESTAMP
135
+ * - Example: TIMESTAMPTZ '2020-02-20 00:00:00 UTC' with tz 'America/Mexico_City' → TIMESTAMP '2020-02-19 18:00:00'
136
+ *
137
+ * 6. **TIMESTAMP → TIMESTAMPTZ**:
138
+ * - TIMESTAMP represents UTC wall clock
139
+ * - Interpret as being in query timezone
140
+ * - Example: TIMESTAMP '2020-02-20 00:00:00' with tz 'America/Mexico_City' → instant at 2020-02-20 06:00:00 UTC
141
+ *
142
+ * **Implementation Notes:**
143
+ *
144
+ * - Dialects without timestamptz support (MySQL, BigQuery, StandardSQL) only need cases 1-3
145
+ * - Without query timezone, most casts are simple `CAST(expr AS type)`
146
+ *
147
+ * @param qi - Query info containing timezone and other context
148
+ * @param cast - The typecast expression to generate SQL for
149
+ * @returns SQL string for the cast operation
150
+ */
98
151
  abstract sqlCast(qi: QueryInfo, cast: TypecastExpr): string;
99
152
  abstract sqlRegexpMatch(df: RegexMatchExpr): string;
100
- abstract sqlLiteralTime(qi: QueryInfo, df: TimeLiteralNode): string;
153
+ /**
154
+ * Converts a Malloy timestamp to "civil time" for calendar operations in a timezone.
155
+ *
156
+ * Each dialect selects its own SQL type to represent civil time (e.g., plain TIMESTAMP,
157
+ * TIMESTAMP WITH TIME ZONE, or DATETIME). The civil space is where timezone-aware
158
+ * truncation and interval arithmetic happen. Operations like sqlTruncate and sqlOffsetTime
159
+ * are aware of the civil space and work correctly within it.
160
+ *
161
+ * @param expr The SQL expression for the Malloy timestamp (plain or timestamptz)
162
+ * @param timezone The target timezone for civil operations
163
+ * @param typeDef The Malloy type of the input expression
164
+ * @returns Object with SQL expression and the SQL type it evaluates to (the civil type)
165
+ */
166
+ abstract sqlConvertToCivilTime(expr: string, timezone: string, typeDef: AtomicTypeDef): {
167
+ sql: string;
168
+ typeDef: AtomicTypeDef;
169
+ };
170
+ /**
171
+ * Converts from civil time back to a Malloy timestamp type.
172
+ *
173
+ * This is the inverse of sqlConvertToCivilTime. Takes a value in the dialect's
174
+ * civil space and converts it back to either a plain timestamp (UTC) or a
175
+ * timestamptz, depending on the destination type.
176
+ *
177
+ * @param expr The SQL expression in civil time
178
+ * @param timezone The timezone of the civil time
179
+ * @param destTypeDef The destination Malloy timestamp type (plain or timestamptz)
180
+ * @returns SQL expression representing the Malloy timestamp
181
+ */
182
+ abstract sqlConvertFromCivilTime(expr: string, timezone: string, destTypeDef: ATimestampTypeDef): string;
183
+ /**
184
+ * Truncates a time expression to the specified unit.
185
+ *
186
+ * @param expr The SQL expression to truncate
187
+ * @param unit The unit to truncate to (year, month, day, hour, etc.)
188
+ * @param typeDef The Malloy type of the expression (date, timestamp, etc.)
189
+ * @param inCivilTime If true, the expression is already in civil (local) time and should not
190
+ * be converted. If false, may need timezone conversion for timestamps.
191
+ * @param timezone Optional timezone for the operation. Only provided when timezone-aware
192
+ * truncation is needed but inCivilTime is false.
193
+ * @returns SQL expression representing the truncated time
194
+ */
195
+ abstract sqlTruncate(expr: string, unit: TimestampUnit, typeDef: AtomicTypeDef, inCivilTime: boolean, timezone?: string): string;
196
+ /**
197
+ * Adds or subtracts a time interval from a time expression.
198
+ *
199
+ * @param expr The SQL expression to offset
200
+ * @param op The operation: '+' for addition, '-' for subtraction
201
+ * @param magnitude The SQL expression for the interval magnitude (e.g., '6', '(delta_val)')
202
+ * @param unit The interval unit (year, month, day, hour, etc.)
203
+ * @param typeDef The Malloy type of the expression (date, timestamp, etc.)
204
+ * @param inCivilTime If true, the expression is already in civil (local) time and should not
205
+ * be converted. If false, may need timezone conversion for timestamps.
206
+ * @param timezone Optional timezone for the operation. Only provided when timezone-aware
207
+ * offset is needed but inCivilTime is false.
208
+ * @returns SQL expression representing the offset time
209
+ */
210
+ abstract sqlOffsetTime(expr: string, op: '+' | '-', magnitude: string, unit: TimestampUnit, typeDef: AtomicTypeDef, inCivilTime: boolean, timezone?: string): string;
211
+ /**
212
+ * Determines whether a truncation and/or offset operation needs to be performed in
213
+ * civil (local) time, and if so, which timezone to use.
214
+ *
215
+ * Calendar-based operations (day, week, month, quarter, year) typically need civil time
216
+ * computation because these units can cross DST boundaries, changing the UTC offset.
217
+ *
218
+ * Default implementation:
219
+ * - Returns true for timestamps with truncation or calendar-unit offsets
220
+ * - Uses the query timezone from QueryInfo, or 'UTC' if none specified
221
+ * - Returns false for dates or sub-day offsets (hour, minute, second)
222
+ *
223
+ * Dialects can override this if they have different rules about which operations
224
+ * require civil time computation.
225
+ *
226
+ * @param typeDef The Malloy type of the base expression
227
+ * @param truncateTo The truncation unit, if any
228
+ * @param offsetUnit The offset unit, if any
229
+ * @param qi Query information including timezone settings
230
+ * @returns Object with `needed` boolean and optional `tz` string
231
+ */
232
+ needsCivilTimeComputation(typeDef: AtomicTypeDef, truncateTo: TimestampUnit | undefined, offsetUnit: TimestampUnit | undefined, qi: QueryInfo): {
233
+ needed: boolean;
234
+ tz: string | undefined;
235
+ };
236
+ /**
237
+ * Unified function for truncation and/or offset operations. This turns out to
238
+ * be a very common operation and one which can be optimized by doing it together.
239
+ *
240
+ * Much of the complexity has to do with getting timezone values set up so that
241
+ * they will truncate/offset in the query timezone instead of UTC.
242
+ *
243
+ * The intention is that this implementation will work for all dialects, and all
244
+ * the dialect peculiarities are handled with the new primitives introduced to
245
+ * support this function:
246
+ * - needsCivilTimeComputation: Determines if operation needs civil time
247
+ * - sqlConvertToCivilTime/sqlConvertFromCivilTime: Timezone conversion
248
+ * - sqlTruncate: Truncation operation
249
+ * - sqlOffsetTime: Interval arithmetic
250
+ *
251
+ * OFFSET TIMESTAMP BEHAVIOR:
252
+ * - Plain timestamps (offset=false): Always return plain TIMESTAMP in UTC
253
+ * - Offset timestamps (offset=true):
254
+ * - Simple path: Operate in native embedded timezone, return TIMESTAMPTZ
255
+ * - Civil path: Convert to query timezone (preserving instant), operate there,
256
+ * return TIMESTAMPTZ in query timezone via sqlConvertFromCivilTime
257
+ * - BigQuery/MySQL: No offset timestamp support, this logic never runs
258
+ *
259
+ * @param baseExpr The time expression to operate on (already compiled, with .sql populated)
260
+ * @param qi Query information including timezone
261
+ * @param truncateTo Optional truncation unit (year, month, day, etc.)
262
+ * @param offset Optional offset to apply (after truncation if both present)
263
+ */
264
+ sqlTruncAndOffset(baseExpr: TimeExpr, qi: QueryInfo, truncateTo?: TimestampUnit, offset?: {
265
+ op: '+' | '-';
266
+ magnitude: string;
267
+ unit: TimestampUnit;
268
+ }): string;
269
+ /**
270
+ * Generate SQL for a DATE literal.
271
+ * @param literal - The date string in format 'YYYY-MM-DD'
272
+ * @returns SQL that produces a DATE value
273
+ */
274
+ abstract sqlDateLiteral(qi: QueryInfo, literal: string): string;
275
+ /**
276
+ * Generate SQL for a plain TIMESTAMP literal (without timezone offset).
277
+ * @param literal - The timestamp string in format 'YYYY-MM-DD HH:MM:SS'
278
+ * @param timezone - Optional timezone name (e.g., 'America/Los_Angeles')
279
+ * - If undefined: Create plain timestamp literal from the literal string
280
+ * - If defined: The literal string represents a civil time in the given timezone.
281
+ * Convert it to a plain timestamp (typically by interpreting as timestamptz
282
+ * in that timezone, then casting to plain timestamp). This happens when:
283
+ * 1. A constant with timezone is used (constants don't have dialect context)
284
+ * 2. A literal with timezone is used in a dialect that doesn't support offset timestamps
285
+ * @returns SQL that produces a plain TIMESTAMP value
286
+ */
287
+ abstract sqlTimestampLiteral(qi: QueryInfo, literal: string, timezone: string | undefined): string;
288
+ /**
289
+ * Generate SQL for an offset TIMESTAMP literal (TIMESTAMP WITH TIME ZONE).
290
+ * Only called for dialects where hasOffsetTimestamp = true.
291
+ * @param literal - The timestamp string in format 'YYYY-MM-DD HH:MM:SS'
292
+ * @param timezone - The timezone name (e.g., 'America/Los_Angeles')
293
+ * @returns SQL that produces a TIMESTAMP WITH TIME ZONE value representing
294
+ * the civil time in the specified timezone
295
+ */
296
+ abstract sqlTimestamptzLiteral(qi: QueryInfo, literal: string, timezone: string): string;
101
297
  abstract sqlLiteralString(literal: string): string;
102
298
  abstract sqlLiteralRegexp(literal: string): string;
103
299
  abstract sqlLiteralArray(lit: ArrayLiteralNode): string;
@@ -62,6 +62,8 @@ class Dialect {
62
62
  this.supportsPipelinesInViews = true;
63
63
  // Some dialects don't supporrt arrays (mysql)
64
64
  this.supportsArraysInData = true;
65
+ // Does the dialect support timestamptz (TIMESTAMP WITH TIME ZONE)?
66
+ this.hasTimestamptz = false;
65
67
  // can read some version of ga_sample
66
68
  this.readsNestedData = true;
67
69
  // ORDER BY 1 DESC
@@ -89,6 +91,58 @@ class Dialect {
89
91
  // Like characters are escaped with ESCAPE clause
90
92
  this.likeEscape = true;
91
93
  }
94
+ /**
95
+ * Create the appropriate time literal IR node based on dialect support.
96
+ * Static method so it can be called with undefined dialect (e.g., ConstantFieldSpace).
97
+ */
98
+ static makeTimeLiteralNode(dialect, literal, timezone, units, typ) {
99
+ var _a;
100
+ // ConstantFieldSpace.dialectObj() returns undefined, so constants default to false
101
+ const hasTimestamptz = (_a = dialect === null || dialect === void 0 ? void 0 : dialect.hasTimestamptz) !== null && _a !== void 0 ? _a : false;
102
+ if (typ === 'date') {
103
+ return {
104
+ node: 'dateLiteral',
105
+ literal,
106
+ typeDef: {
107
+ type: 'date',
108
+ timeframe: units !== undefined && (0, malloy_types_1.isDateUnit)(units) ? units : undefined,
109
+ },
110
+ };
111
+ }
112
+ // typ === 'timestamp'
113
+ if (timezone && hasTimestamptz) {
114
+ // Dialect supports timestamptz - create timestamptzLiteral
115
+ return {
116
+ node: 'timestamptzLiteral',
117
+ literal,
118
+ typeDef: {
119
+ type: 'timestamptz',
120
+ timeframe: units,
121
+ },
122
+ timezone,
123
+ };
124
+ }
125
+ // Plain timestamp (either no timezone, or dialect doesn't support timestamptz)
126
+ if (timezone) {
127
+ return {
128
+ node: 'timestampLiteral',
129
+ literal,
130
+ typeDef: {
131
+ type: 'timestamp',
132
+ timeframe: units,
133
+ },
134
+ timezone,
135
+ };
136
+ }
137
+ return {
138
+ node: 'timestampLiteral',
139
+ literal,
140
+ typeDef: {
141
+ type: 'timestamp',
142
+ timeframe: units,
143
+ },
144
+ };
145
+ }
92
146
  sqlFinalStage(_lastStageName, _fields) {
93
147
  throw new Error('Dialect has no final Stage but called Anyway');
94
148
  }
@@ -104,6 +158,96 @@ class Dialect {
104
158
  ignoreInProject(_fieldName) {
105
159
  return false;
106
160
  }
161
+ /**
162
+ * Determines whether a truncation and/or offset operation needs to be performed in
163
+ * civil (local) time, and if so, which timezone to use.
164
+ *
165
+ * Calendar-based operations (day, week, month, quarter, year) typically need civil time
166
+ * computation because these units can cross DST boundaries, changing the UTC offset.
167
+ *
168
+ * Default implementation:
169
+ * - Returns true for timestamps with truncation or calendar-unit offsets
170
+ * - Uses the query timezone from QueryInfo, or 'UTC' if none specified
171
+ * - Returns false for dates or sub-day offsets (hour, minute, second)
172
+ *
173
+ * Dialects can override this if they have different rules about which operations
174
+ * require civil time computation.
175
+ *
176
+ * @param typeDef The Malloy type of the base expression
177
+ * @param truncateTo The truncation unit, if any
178
+ * @param offsetUnit The offset unit, if any
179
+ * @param qi Query information including timezone settings
180
+ * @returns Object with `needed` boolean and optional `tz` string
181
+ */
182
+ needsCivilTimeComputation(typeDef, truncateTo, offsetUnit, qi) {
183
+ // Calendar units that can cross DST boundaries
184
+ const calendarUnits = ['day', 'week', 'month', 'quarter', 'year'];
185
+ const isCalendarTruncate = truncateTo !== undefined && calendarUnits.includes(truncateTo);
186
+ const isCalendarOffset = offsetUnit !== undefined && calendarUnits.includes(offsetUnit);
187
+ const tz = qtz(qi);
188
+ // Timestamps with calendar truncation/offset need civil time computation
189
+ // BUT only if there's actually a timezone to convert to/from
190
+ const needed = malloy_types_1.TD.isAnyTimestamp(typeDef) &&
191
+ (isCalendarTruncate || isCalendarOffset) &&
192
+ tz !== undefined;
193
+ return { needed, tz };
194
+ }
195
+ /**
196
+ * Unified function for truncation and/or offset operations. This turns out to
197
+ * be a very common operation and one which can be optimized by doing it together.
198
+ *
199
+ * Much of the complexity has to do with getting timezone values set up so that
200
+ * they will truncate/offset in the query timezone instead of UTC.
201
+ *
202
+ * The intention is that this implementation will work for all dialects, and all
203
+ * the dialect peculiarities are handled with the new primitives introduced to
204
+ * support this function:
205
+ * - needsCivilTimeComputation: Determines if operation needs civil time
206
+ * - sqlConvertToCivilTime/sqlConvertFromCivilTime: Timezone conversion
207
+ * - sqlTruncate: Truncation operation
208
+ * - sqlOffsetTime: Interval arithmetic
209
+ *
210
+ * OFFSET TIMESTAMP BEHAVIOR:
211
+ * - Plain timestamps (offset=false): Always return plain TIMESTAMP in UTC
212
+ * - Offset timestamps (offset=true):
213
+ * - Simple path: Operate in native embedded timezone, return TIMESTAMPTZ
214
+ * - Civil path: Convert to query timezone (preserving instant), operate there,
215
+ * return TIMESTAMPTZ in query timezone via sqlConvertFromCivilTime
216
+ * - BigQuery/MySQL: No offset timestamp support, this logic never runs
217
+ *
218
+ * @param baseExpr The time expression to operate on (already compiled, with .sql populated)
219
+ * @param qi Query information including timezone
220
+ * @param truncateTo Optional truncation unit (year, month, day, etc.)
221
+ * @param offset Optional offset to apply (after truncation if both present)
222
+ */
223
+ sqlTruncAndOffset(baseExpr, qi, truncateTo, offset) {
224
+ // Determine if we need to work in civil (local) time
225
+ if (malloy_types_1.TD.isAnyTimestamp(baseExpr.typeDef)) {
226
+ const { needed: needsCivil, tz } = this.needsCivilTimeComputation(baseExpr.typeDef, truncateTo, offset === null || offset === void 0 ? void 0 : offset.unit, qi);
227
+ if (needsCivil && tz) {
228
+ // Civil time path: convert to local time, operate, convert back to UTC
229
+ const civilResult = this.sqlConvertToCivilTime(baseExpr.sql, tz, baseExpr.typeDef);
230
+ let expr = civilResult.sql;
231
+ const civilTypeDef = civilResult.typeDef;
232
+ if (truncateTo) {
233
+ expr = this.sqlTruncate(expr, truncateTo, civilTypeDef, true, tz);
234
+ }
235
+ if (offset) {
236
+ expr = this.sqlOffsetTime(expr, offset.op, offset.magnitude, offset.unit, civilTypeDef, true, tz);
237
+ }
238
+ return this.sqlConvertFromCivilTime(expr, tz, baseExpr.typeDef);
239
+ }
240
+ }
241
+ // Simple path: no civil time conversion needed
242
+ let sql = baseExpr.sql;
243
+ if (truncateTo) {
244
+ sql = this.sqlTruncate(sql, truncateTo, baseExpr.typeDef, false, qtz(qi));
245
+ }
246
+ if (offset) {
247
+ sql = this.sqlOffsetTime(sql, offset.op, offset.magnitude, offset.unit, baseExpr.typeDef, false, qtz(qi));
248
+ }
249
+ return sql;
250
+ }
107
251
  /**
108
252
  * The dialect has a chance to over-ride how expressions are translated. If
109
253
  * "undefined" is returned then the translation is left to the query translator.
@@ -120,10 +264,27 @@ class Dialect {
120
264
  return this.sqlNowExpr();
121
265
  case 'timeDiff':
122
266
  return this.sqlMeasureTimeExpr(df);
123
- case 'delta':
124
- return this.sqlAlterTimeExpr(df);
267
+ case 'delta': {
268
+ // Optimize: if delta's base is a trunc, combine them
269
+ const base = df.kids.base;
270
+ if (base.node === 'trunc') {
271
+ // Combined trunc + offset - pass the base of the truncation
272
+ return this.sqlTruncAndOffset(base.e, qi, base.units, {
273
+ op: df.op,
274
+ magnitude: df.kids.delta.sql,
275
+ unit: df.units,
276
+ });
277
+ }
278
+ // Just offset, no truncation - pass the delta's base
279
+ return this.sqlTruncAndOffset(base, qi, undefined, {
280
+ op: df.op,
281
+ magnitude: df.kids.delta.sql,
282
+ unit: df.units,
283
+ });
284
+ }
125
285
  case 'trunc':
126
- return this.sqlTruncExpr(qi, df);
286
+ // Just truncation, no offset
287
+ return this.sqlTruncAndOffset(df.e, qi, df.units);
127
288
  case 'extract':
128
289
  return this.sqlTimeExtractExpr(qi, df);
129
290
  case 'cast':
@@ -142,8 +303,12 @@ class Dialect {
142
303
  }
143
304
  return;
144
305
  }
145
- case 'timeLiteral':
146
- return this.sqlLiteralTime(qi, df);
306
+ case 'dateLiteral':
307
+ return this.sqlDateLiteral(qi, df.literal);
308
+ case 'timestampLiteral':
309
+ return this.sqlTimestampLiteral(qi, df.literal, df.timezone);
310
+ case 'timestamptzLiteral':
311
+ return this.sqlTimestamptzLiteral(qi, df.literal, df.timezone);
147
312
  case 'stringLiteral':
148
313
  return this.sqlLiteralString(df.literal);
149
314
  case 'numberLiteral':
@@ -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';
@@ -56,11 +56,11 @@ export declare class DuckDBDialect extends PostgresBase {
56
56
  };
57
57
  malloyTypeToSQLType(malloyType: AtomicTypeDef): string;
58
58
  parseDuckDBType(sqlType: string): AtomicTypeDef;
59
- sqlTypeToMalloyType(sqlType: string): BasicAtomicTypeDef;
59
+ sqlTypeToMalloyType(rawSqlType: string): BasicAtomicTypeDef;
60
60
  castToString(expression: string): string;
61
61
  concat(...values: string[]): string;
62
62
  validateTypeName(sqlType: string): boolean;
63
- sqlAlterTimeExpr(df: TimeDeltaExpr): string;
63
+ sqlOffsetTime(expr: string, op: '+' | '-', magnitude: string, unit: TimestampUnit, _typeDef: AtomicTypeDef, _inCivilTime: boolean, _timezone?: string): string;
64
64
  sqlRegexpMatch(df: RegexMatchExpr): string;
65
65
  sqlMeasureTimeExpr(df: MeasureTimeExpr): string;
66
66
  sqlLiteralRecord(lit: RecordLiteralNode): string;
@@ -282,6 +282,9 @@ class DuckDBDialect extends pg_impl_1.PostgresBase {
282
282
  else if (malloyType.type === 'string') {
283
283
  return 'varchar';
284
284
  }
285
+ if (malloyType.type === 'timestamptz') {
286
+ return 'timestamp with time zone';
287
+ }
285
288
  return malloyType.type;
286
289
  }
287
290
  parseDuckDBType(sqlType) {
@@ -298,8 +301,12 @@ class DuckDBDialect extends pg_impl_1.PostgresBase {
298
301
  }
299
302
  }
300
303
  }
301
- sqlTypeToMalloyType(sqlType) {
304
+ sqlTypeToMalloyType(rawSqlType) {
302
305
  var _a, _b, _c;
306
+ const sqlType = rawSqlType.toUpperCase();
307
+ if (sqlType === 'TIMESTAMP WITH TIME ZONE') {
308
+ return { type: 'timestamptz' };
309
+ }
303
310
  // Remove decimal precision
304
311
  const ddbType = sqlType.replace(/^DECIMAL\(\d+,\d+\)/g, 'DECIMAL');
305
312
  // Remove trailing params
@@ -323,19 +330,16 @@ class DuckDBDialect extends pg_impl_1.PostgresBase {
323
330
  // Brackets: INT[ ]
324
331
  return sqlType.match(/^[A-Za-z\s(),[\]0-9]*$/) !== null;
325
332
  }
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`;
332
- }
333
- else if (timeframe === 'week') {
334
- timeframe = 'day';
335
- n = `${n}*7`;
333
+ sqlOffsetTime(expr, op, magnitude, unit, _typeDef, _inCivilTime, _timezone) {
334
+ // DuckDB doesn't support INTERVAL '1' WEEK, convert to days
335
+ let offsetUnit = unit;
336
+ let offsetMag = magnitude;
337
+ if (unit === 'week') {
338
+ offsetUnit = 'day';
339
+ offsetMag = `(${magnitude})*7`;
336
340
  }
337
- const interval = `INTERVAL (${n}) ${timeframe}`;
338
- return `${df.kids.base.sql} ${df.op} ${interval}`;
341
+ const interval = `INTERVAL (${offsetMag}) ${offsetUnit}`;
342
+ return `(${expr} ${op} ${interval})`;
339
343
  }
340
344
  sqlRegexpMatch(df) {
341
345
  return `REGEXP_MATCHES(${df.kids.expr.sql},${df.kids.regex.sql})`;
@@ -401,10 +405,13 @@ class DuckDBTypeParser extends tiny_parser_1.TinyParser {
401
405
  baseType = { type: 'number', numberType: 'float' };
402
406
  }
403
407
  else if (id === 'TIMESTAMP') {
404
- if (this.peek().text === 'WITH') {
408
+ if (this.peek().text.toUpperCase() === 'WITH') {
405
409
  this.nextText('WITH', 'TIME', 'ZONE');
410
+ baseType = { type: 'timestamptz' };
411
+ }
412
+ else {
413
+ baseType = { type: 'timestamp' };
406
414
  }
407
- baseType = { type: 'timestamp' };
408
415
  }
409
416
  else if (duckDBToMalloyTypes[id]) {
410
417
  baseType = duckDBToMalloyTypes[id];
@@ -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, RegexMatchExpr, TimeExtractExpr, TypecastExpr, BasicAtomicTypeDef, AtomicTypeDef, TimestampTypeDef, 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,13 +56,19 @@ 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, _typeDef: AtomicTypeDef): {
60
+ sql: string;
61
+ typeDef: AtomicTypeDef;
62
+ };
63
+ sqlConvertFromCivilTime(expr: string, timezone: string, _destTypeDef: TimestampTypeDef): string;
64
+ sqlTruncate(expr: string, unit: string, _typeDef: AtomicTypeDef, _inCivilTime: boolean, _timezone?: string): string;
65
+ sqlOffsetTime(expr: string, op: '+' | '-', magnitude: string, unit: string, _typeDef: AtomicTypeDef, _inCivilTime: boolean, _timezone?: string): string;
61
66
  sqlTimeExtractExpr(qi: QueryInfo, te: TimeExtractExpr): string;
62
- sqlAlterTimeExpr(df: TimeDeltaExpr): string;
63
67
  sqlCast(qi: QueryInfo, cast: TypecastExpr): string;
64
68
  sqlRegexpMatch(df: RegexMatchExpr): string;
65
- sqlLiteralTime(qi: QueryInfo, lt: TimeLiteralNode): string;
69
+ sqlDateLiteral(_qi: QueryInfo, literal: string): string;
70
+ sqlTimestampLiteral(qi: QueryInfo, literal: string, timezone: string | undefined): string;
71
+ sqlTimestamptzLiteral(_qi: QueryInfo, _literal: string, _timezone: string): string;
66
72
  sqlMeasureTimeExpr(df: MeasureTimeExpr): string;
67
73
  sqlAggDistinct(_key: string, _values: string[], _func: (valNames: string[]) => string): string;
68
74
  sqlSampleTable(tableSQL: string, sample: Sampling | undefined): string;