diff --git a/packages/cubejs-schema-compiler/src/compiler/CubeSymbols.ts b/packages/cubejs-schema-compiler/src/compiler/CubeSymbols.ts index ba7e0ec33e049..bcb74a7c78b0d 100644 --- a/packages/cubejs-schema-compiler/src/compiler/CubeSymbols.ts +++ b/packages/cubejs-schema-compiler/src/compiler/CubeSymbols.ts @@ -31,6 +31,7 @@ interface CubeDefinition { excludes?: any; cubes?: any; isView?: boolean; + calendar?: boolean; isSplitView?: boolean; includedMembers?: any[]; } @@ -979,8 +980,17 @@ export class CubeSymbols { const [cubeName, dimName, gr, granName] = Array.isArray(path) ? path : path.split('.'); const cube = refCube || this.symbols[cubeName]; - // Predefined granularity + // Calendar cubes time dimensions may define custom sql for predefined granularities, + // so we need to check if such granularity exists in cube definition. if (typeof granName === 'string' && /^(second|minute|hour|day|week|month|quarter|year)$/i.test(granName)) { + const customGranularity = cube?.[dimName]?.[gr]?.[granName]; + if (customGranularity) { + return { + ...customGranularity, + interval: `1 ${granName}`, // It's still important to have interval for granularity math + }; + } + return { interval: `1 ${granName}` }; } diff --git a/packages/cubejs-schema-compiler/src/compiler/CubeValidator.ts b/packages/cubejs-schema-compiler/src/compiler/CubeValidator.ts index 0c1accf8c60d2..bf604b83cceaa 100644 --- a/packages/cubejs-schema-compiler/src/compiler/CubeValidator.ts +++ b/packages/cubejs-schema-compiler/src/compiler/CubeValidator.ts @@ -185,6 +185,10 @@ const BaseDimensionWithoutSubQuery = { return isValid ? value : helper.message(msg); }), offset: GranularityOffset.optional(), + }), + Joi.object().keys({ + title: Joi.string(), + sql: Joi.func().required() }) ])).optional(), otherwise: Joi.forbidden() @@ -786,6 +790,7 @@ const baseSchema = { const cubeSchema = inherit(baseSchema, { sql: Joi.func(), sqlTable: Joi.func(), + calendar: Joi.boolean().strict(), }).xor('sql', 'sqlTable').messages({ 'object.xor': 'You must use either sql or sqlTable within a model, but not both' }); diff --git a/packages/cubejs-schema-compiler/test/integration/postgres/calendars.test.ts b/packages/cubejs-schema-compiler/test/integration/postgres/calendars.test.ts new file mode 100644 index 0000000000000..85c6d44335650 --- /dev/null +++ b/packages/cubejs-schema-compiler/test/integration/postgres/calendars.test.ts @@ -0,0 +1,363 @@ +import { getEnv } from '@cubejs-backend/shared'; +import { PostgresQuery } from '../../../src/adapter'; +import { prepareYamlCompiler } from '../../unit/PrepareCompiler'; +import { dbRunner } from './PostgresDBRunner'; + +describe('Calendar cubes', () => { + jest.setTimeout(200000); + + // language=YAML + const { compiler, joinGraph, cubeEvaluator } = prepareYamlCompiler(` +cubes: + - name: calendar_orders + sql: > + SELECT + gs.id, + 100 + gs.id AS user_id, + (ARRAY['new', 'processed', 'shipped'])[(gs.id % 3) + 1] AS status, + make_timestamp( + 2025, + (gs.id % 12) + 1, + 1 + (gs.id * 7 % 25), + 0, + 0, + 0 + ) AS created_at + FROM generate_series(1, 40) AS gs(id) + + joins: + - name: custom_calendar + sql: "{CUBE}.created_at = {custom_calendar.date_val}" + relationship: many_to_one + + dimensions: + - name: id + sql: id + type: number + primary_key: true + public: true + + - name: user_id + sql: user_id + type: number + + - name: status + sql: status + type: string + meta: + addDesc: The status of order + moreNum: 42 + + - name: created_at + sql: created_at + type: time + + measures: + - name: count + type: count + + - name: count_shifted + type: count + multi_stage: true + sql: "{count}" + time_shift: + - time_dimension: created_at + interval: 1 year + type: prior + + - name: completed_count + type: count + filters: + - sql: "{CUBE}.status = 'completed'" + + - name: completed_percentage + sql: "({completed_count} / NULLIF({count}, 0)) * 100.0" + type: number + format: percent + + - name: total + type: count + rolling_window: + trailing: unbounded + + - name: custom_calendar + sql: > + WITH base AS ( + SELECT + gs.n - 1 AS day_offset, + DATE '2025-02-02' + (gs.n - 1) AS date_val + FROM generate_series(1, 364) AS gs(n) + ), + retail_calc AS ( + SELECT + date_val, + date_val AS retail_date, + '2025' AS retail_year_name, + (day_offset / 7) + 1 AS retail_week, + -- Group of months 4-5-4 (13 weeks = 3 months) + ((day_offset / 7) / 13) + 1 AS retail_quarter, + (day_offset / 7) % 13 AS week_in_quarter, + DATE '2025-02-02' AS retail_year_begin_date + FROM base + ), + final AS ( + SELECT + date_val, + retail_date, + retail_year_name, + ('Retail Month ' || ((retail_quarter - 1) * 3 + + CASE + WHEN week_in_quarter < 4 THEN 1 + WHEN week_in_quarter < 9 THEN 2 + ELSE 3 + END)) AS retail_month_long_name, + ('WK' || LPAD(retail_week::text, 2, '0')) AS retail_week_name, + retail_year_begin_date, + ('Q' || retail_quarter || ' 2025') AS retail_quarter_year, + (SELECT MIN(date_val) FROM retail_calc r2 + WHERE r2.retail_quarter = r.retail_quarter + AND CASE + WHEN week_in_quarter < 4 THEN 1 + WHEN week_in_quarter < 9 THEN 2 + ELSE 3 + END = + CASE + WHEN r.week_in_quarter < 4 THEN 1 + WHEN r.week_in_quarter < 9 THEN 2 + ELSE 3 + END + ) AS retail_month_begin_date, + date_val - (extract(dow from date_val)::int) AS retail_week_begin_date, + ('2025-WK' || LPAD(retail_week::text, 2, '0')) AS retail_year_week + FROM retail_calc r + ) + SELECT * + FROM final + ORDER BY date_val + + calendar: true + + dimensions: + # Plain date value + - name: date_val + sql: "{CUBE}.date_val" + type: time + primary_key: true + + ##### Retail Dates #### + - name: retail_date + sql: retail_date + type: time + + granularities: + - name: year + sql: "{CUBE.retail_year_begin_date}" + + - name: quarter + sql: "{CUBE.retail_quarter_year}" + + - name: month + sql: "{CUBE.retail_month_begin_date}" + + - name: week + sql: "{CUBE.retail_week_begin_date}" + + # Casually defining custom granularities should also work. + # While maybe not very sound from a business standpoint, + # such definition should be allowed in this data model + - name: fortnight + interval: 2 week + origin: "2025-01-01" + + - name: retail_year + sql: "{CUBE}.retail_year_name" + type: string + + - name: retail_month_long_name + sql: "{CUBE}.retail_month_long_name" + type: string + + - name: retail_week_name + sql: "{CUBE}.retail_week_name" + type: string + + - name: retail_year_begin_date + sql: "{CUBE}.retail_year_begin_date" + type: time + + - name: retail_quarter_year + sql: "{CUBE}.retail_quarter_year" + type: string + + - name: retail_month_begin_date + sql: "{CUBE}.retail_month_begin_date" + type: string + + - name: retail_week_begin_date + sql: "{CUBE}.retail_week_begin_date" + type: string + + - name: retail_year_week + sql: "{CUBE}.retail_year_week" + type: string + `); + + async function runQueryTest(q: any, expectedResult: any) { + // Calendars are working only with Tesseract SQL planner + if (!getEnv('nativeSqlPlanner')) { + return; + } + + await compiler.compile(); + const query = new PostgresQuery( + { joinGraph, cubeEvaluator, compiler }, + { ...q, timezone: 'UTC', preAggregationsSchema: '' } + ); + + const qp = query.buildSqlAndParams(); + console.log(qp); + + const res = await dbRunner.testQuery(qp); + console.log(JSON.stringify(res)); + + expect(res).toEqual( + expectedResult + ); + } + + it('Count by retail year', async () => runQueryTest({ + measures: ['calendar_orders.count'], + timeDimensions: [{ + dimension: 'custom_calendar.retail_date', + granularity: 'year', + dateRange: ['2025-02-01', '2026-03-01'] + }], + order: [{ id: 'custom_calendar.retail_date' }] + }, [ + { + calendar_orders__count: '36', + custom_calendar__retail_date_year: '2025-02-02T00:00:00.000Z', + } + ])); + + it('Count by retail month', async () => runQueryTest({ + measures: ['calendar_orders.count'], + timeDimensions: [{ + dimension: 'custom_calendar.retail_date', + granularity: 'month', + dateRange: ['2025-02-01', '2026-03-01'] + }], + order: [{ id: 'custom_calendar.retail_date' }] + }, [ + { + calendar_orders__count: '3', + custom_calendar__retail_date_month: '2025-02-02T00:00:00.000Z', + }, + { + calendar_orders__count: '4', + custom_calendar__retail_date_month: '2025-03-02T00:00:00.000Z', + }, + { + calendar_orders__count: '4', + custom_calendar__retail_date_month: '2025-04-06T00:00:00.000Z', + }, + { + calendar_orders__count: '4', + custom_calendar__retail_date_month: '2025-05-04T00:00:00.000Z', + }, + { + calendar_orders__count: '4', + custom_calendar__retail_date_month: '2025-06-01T00:00:00.000Z', + }, + { + calendar_orders__count: '2', + custom_calendar__retail_date_month: '2025-07-06T00:00:00.000Z', + }, + { + calendar_orders__count: '3', + custom_calendar__retail_date_month: '2025-08-03T00:00:00.000Z', + }, + { + calendar_orders__count: '3', + custom_calendar__retail_date_month: '2025-08-31T00:00:00.000Z', + }, + { + calendar_orders__count: '3', + custom_calendar__retail_date_month: '2025-10-05T00:00:00.000Z', + }, + { + calendar_orders__count: '3', + custom_calendar__retail_date_month: '2025-11-02T00:00:00.000Z', + }, + { + calendar_orders__count: '3', + custom_calendar__retail_date_month: '2025-11-30T00:00:00.000Z', + } + ])); + + it('Count by retail week', async () => runQueryTest({ + measures: ['calendar_orders.count'], + timeDimensions: [{ + dimension: 'custom_calendar.retail_date', + granularity: 'week', + dateRange: ['2025-02-01', '2025-04-01'] + }], + order: [{ id: 'custom_calendar.retail_date' }] + }, [ + { + calendar_orders__count: '1', + custom_calendar__retail_date_week: '2025-02-02T00:00:00.000Z', + }, + { + calendar_orders__count: '1', + custom_calendar__retail_date_week: '2025-02-09T00:00:00.000Z', + }, + { + calendar_orders__count: '1', + custom_calendar__retail_date_week: '2025-02-16T00:00:00.000Z', + }, + { + calendar_orders__count: '1', + custom_calendar__retail_date_week: '2025-03-02T00:00:00.000Z', + }, + { + calendar_orders__count: '1', + custom_calendar__retail_date_week: '2025-03-09T00:00:00.000Z', + }, + { + calendar_orders__count: '1', + custom_calendar__retail_date_week: '2025-03-16T00:00:00.000Z', + }, + { + calendar_orders__count: '1', + custom_calendar__retail_date_week: '2025-03-23T00:00:00.000Z', + } + ])); + + it('Count by fortnight custom granularity', async () => runQueryTest({ + measures: ['calendar_orders.count'], + timeDimensions: [{ + dimension: 'custom_calendar.retail_date', + granularity: 'fortnight', + dateRange: ['2025-02-01', '2025-04-01'] + }], + order: [{ id: 'custom_calendar.retail_date' }] + }, [ + { + calendar_orders__count: '2', + custom_calendar__retail_date_fortnight: '2025-01-29T00:00:00.000Z', // Notice it starts on 2025-01-29, not 2025-02-01 + }, + { + calendar_orders__count: '1', + custom_calendar__retail_date_fortnight: '2025-02-12T00:00:00.000Z', + }, + { + calendar_orders__count: '1', + custom_calendar__retail_date_fortnight: '2025-02-26T00:00:00.000Z', + }, + { + calendar_orders__count: '3', + custom_calendar__retail_date_fortnight: '2025-03-12T00:00:00.000Z', + } + ])); +}); diff --git a/packages/cubejs-schema-compiler/test/unit/__snapshots__/schema.test.ts.snap b/packages/cubejs-schema-compiler/test/unit/__snapshots__/schema.test.ts.snap index fcc4dddbf9a36..5857397186fc4 100644 --- a/packages/cubejs-schema-compiler/test/unit/__snapshots__/schema.test.ts.snap +++ b/packages/cubejs-schema-compiler/test/unit/__snapshots__/schema.test.ts.snap @@ -1,5 +1,339 @@ // Jest Snapshot v1, https://goo.gl/fbAQLP +exports[`Schema Testing Calendar Cubes Valid calendar cubes: customCalendarJsCube 1`] = ` +Object { + "accessPolicy": undefined, + "allDefinitions": [Function], + "calendar": true, + "dimensions": Object { + "date_val": Object { + "ownedByCube": true, + "primaryKey": true, + "shown": true, + "sql": [Function], + "type": "time", + }, + "fiscal_month_number": Object { + "ownedByCube": true, + "sql": [Function], + "type": "string", + }, + "fiscal_month_short_name": Object { + "ownedByCube": true, + "sql": [Function], + "type": "string", + }, + "fiscal_quarter_year": Object { + "ownedByCube": true, + "sql": [Function], + "type": "string", + }, + "fiscal_week_begin_date": Object { + "ownedByCube": true, + "sql": [Function], + "type": "time", + }, + "fiscal_week_end_date": Object { + "ownedByCube": true, + "sql": [Function], + "type": "time", + }, + "fiscal_week_name": Object { + "ownedByCube": true, + "sql": [Function], + "type": "string", + }, + "fiscal_year": Object { + "ownedByCube": true, + "sql": [Function], + "type": "string", + }, + "fiscal_year_month_name": Object { + "ownedByCube": true, + "sql": [Function], + "type": "string", + }, + "fiscal_year_month_number": Object { + "ownedByCube": true, + "sql": [Function], + "type": "number", + }, + "fiscal_year_period_name": Object { + "ownedByCube": true, + "sql": [Function], + "type": "number", + }, + "retail_date": Object { + "granularities": Object { + "month": Object { + "sql": [Function], + }, + "quarter": Object { + "sql": [Function], + }, + "week": Object { + "sql": [Function], + }, + "year": Object { + "sql": [Function], + }, + }, + "ownedByCube": true, + "sql": [Function], + "type": "time", + }, + "retail_month_begin_date": Object { + "ownedByCube": true, + "sql": [Function], + "type": "string", + }, + "retail_month_long_name": Object { + "ownedByCube": true, + "sql": [Function], + "type": "string", + }, + "retail_quarter_year": Object { + "ownedByCube": true, + "sql": [Function], + "type": "string", + }, + "retail_week_begin_date": Object { + "ownedByCube": true, + "sql": [Function], + "type": "string", + }, + "retail_week_in_month": Object { + "ownedByCube": true, + "sql": [Function], + "type": "string", + }, + "retail_week_name": Object { + "ownedByCube": true, + "sql": [Function], + "type": "string", + }, + "retail_year": Object { + "ownedByCube": true, + "sql": [Function], + "type": "string", + }, + "retail_year_begin_date": Object { + "ownedByCube": true, + "sql": [Function], + "type": "time", + }, + "retail_year_end_date": Object { + "ownedByCube": true, + "sql": [Function], + "type": "time", + }, + "retail_year_week": Object { + "ownedByCube": true, + "sql": [Function], + "type": "string", + }, + }, + "fileName": "custom_calendar.js", + "hierarchies": Object {}, + "joins": Object {}, + "measures": Object { + "count": Object { + "ownedByCube": true, + "type": "count", + }, + }, + "name": "custom_calendar_js", + "preAggregations": Object {}, + "rawCubes": [Function], + "rawFolders": [Function], + "segments": Object {}, + "sql": [Function], +} +`; + +exports[`Schema Testing Calendar Cubes Valid calendar cubes: customCalendarYamlCube 1`] = ` +Object { + "accessPolicy": undefined, + "allDefinitions": [Function], + "calendar": true, + "dimensions": Object { + "date_val": Object { + "ownedByCube": true, + "primaryKey": true, + "sql": [Function], + "type": "time", + }, + "fiscal_month_number": Object { + "ownedByCube": true, + "sql": [Function], + "type": "string", + }, + "fiscal_month_short_name": Object { + "ownedByCube": true, + "sql": [Function], + "type": "string", + }, + "fiscal_quarter_year": Object { + "ownedByCube": true, + "sql": [Function], + "type": "string", + }, + "fiscal_week_begin_date": Object { + "ownedByCube": true, + "sql": [Function], + "type": "time", + }, + "fiscal_week_end_date": Object { + "ownedByCube": true, + "sql": [Function], + "type": "time", + }, + "fiscal_week_name": Object { + "ownedByCube": true, + "sql": [Function], + "type": "string", + }, + "fiscal_year": Object { + "ownedByCube": true, + "sql": [Function], + "type": "string", + }, + "fiscal_year_month_name": Object { + "ownedByCube": true, + "sql": [Function], + "type": "string", + }, + "fiscal_year_month_number": Object { + "ownedByCube": true, + "sql": [Function], + "type": "number", + }, + "fiscal_year_period_name": Object { + "ownedByCube": true, + "sql": [Function], + "type": "number", + }, + "retail_date": Object { + "granularities": Object { + "fortnight": Object { + "interval": "2 week", + "origin": "2025-01-01", + }, + "month": Object { + "sql": [Function], + }, + "quarter": Object { + "sql": [Function], + }, + "week": Object { + "sql": [Function], + }, + "year": Object { + "sql": [Function], + }, + }, + "ownedByCube": true, + "sql": [Function], + "type": "time", + }, + "retail_month_begin_date": Object { + "ownedByCube": true, + "sql": [Function], + "type": "string", + }, + "retail_month_long_name": Object { + "ownedByCube": true, + "sql": [Function], + "type": "string", + }, + "retail_quarter_year": Object { + "ownedByCube": true, + "sql": [Function], + "type": "string", + }, + "retail_week_begin_date": Object { + "ownedByCube": true, + "sql": [Function], + "type": "string", + }, + "retail_week_in_month": Object { + "ownedByCube": true, + "sql": [Function], + "type": "string", + }, + "retail_week_name": Object { + "ownedByCube": true, + "sql": [Function], + "type": "string", + }, + "retail_year": Object { + "ownedByCube": true, + "sql": [Function], + "type": "string", + }, + "retail_year_begin_date": Object { + "ownedByCube": true, + "sql": [Function], + "type": "time", + }, + "retail_year_end_date": Object { + "ownedByCube": true, + "sql": [Function], + "type": "time", + }, + "retail_year_week": Object { + "ownedByCube": true, + "sql": [Function], + "type": "string", + }, + }, + "evaluatedHierarchies": Array [ + Object { + "levels": Array [ + "custom_calendar.fiscal_year", + "custom_calendar.fiscal_quarter_year", + "custom_calendar.fiscal_month_number", + ], + "name": "Fiscal_Calendar_Hierarchy", + "title": "Fiscal Calendar Hierarchy", + }, + Object { + "levels": Array [ + "custom_calendar.retail_year", + "custom_calendar.retail_month_long_name", + "custom_calendar.retail_week_name", + ], + "name": "Retail_Calendar_Hierarchy", + "title": "Retail Calendar Hierarchy", + }, + ], + "fileName": "custom_calendar.yml", + "hierarchies": Object { + "Fiscal_Calendar_Hierarchy": Object { + "levels": [Function], + "title": "Fiscal Calendar Hierarchy", + }, + "Retail_Calendar_Hierarchy": Object { + "levels": [Function], + "title": "Retail Calendar Hierarchy", + }, + }, + "joins": Object {}, + "measures": Object { + "count": Object { + "ownedByCube": true, + "type": "count", + }, + }, + "name": "custom_calendar", + "preAggregations": Object {}, + "rawCubes": [Function], + "rawFolders": [Function], + "segments": Object {}, + "sqlTable": [Function], +} +`; + exports[`Schema Testing Inheritance CubeB.js correctly extends cubeA.js (no additions): accessPolicy 1`] = ` Array [ Object { diff --git a/packages/cubejs-schema-compiler/test/unit/fixtures/calendar_orders.yml b/packages/cubejs-schema-compiler/test/unit/fixtures/calendar_orders.yml new file mode 100644 index 0000000000000..db039a67552cc --- /dev/null +++ b/packages/cubejs-schema-compiler/test/unit/fixtures/calendar_orders.yml @@ -0,0 +1,62 @@ +cubes: + - name: calendar_orders + sql_table: public.orders + + joins: + - name: custom_calendar + sql: "{CUBE}.created_at = {custom_calendar.date_val}" + relationship: many_to_one + + - name: custom_calendar_js + sql: "{CUBE}.created_at = {custom_calendar_js.date_val}" + relationship: many_to_one + + dimensions: + - name: id + sql: id + type: number + primary_key: true + public: true + + - name: user_id + sql: user_id + type: number + + - name: status + sql: status + type: string + meta: + addDesc: The status of order + moreNum: 42 + + - name: created_at + sql: created_at + type: time + + measures: + - name: count + type: count + + - name: count_shifted + type: count + multi_stage: true + sql: "{count}" + time_shift: + - time_dimension: created_at + interval: 1 year + type: prior + + - name: completed_count + type: count + filters: + - sql: "{CUBE}.status = 'completed'" + + - name: completed_percentage + sql: "({completed_count} / NULLIF({count}, 0)) * 100.0" + type: number + format: percent + + - name: total + type: count + rolling_window: + trailing: unbounded diff --git a/packages/cubejs-schema-compiler/test/unit/fixtures/custom_calendar.js b/packages/cubejs-schema-compiler/test/unit/fixtures/custom_calendar.js new file mode 100644 index 0000000000000..395e251a3ec36 --- /dev/null +++ b/packages/cubejs-schema-compiler/test/unit/fixtures/custom_calendar.js @@ -0,0 +1,140 @@ +cube(`custom_calendar_js`, { + sql: `SELECT * FROM public.custom_calendar`, + + calendar: true, + + measures: { + count: { + type: `count`, + } + }, + + dimensions: { + date_val: { + sql: `date_val`, + type: `time`, + primaryKey: true, + shown:true + }, + + retail_date: { + sql: `retail_date`, + type: `time`, + granularities: { + week: { + sql: `{CUBE.retail_week_begin_date}`, + }, + month: { + sql: `{CUBE.retail_month_begin_date}`, + }, + quarter: { + sql: `{CUBE.retail_quarter_year}`, + }, + year: { + sql: `{CUBE.retail_year_begin_date}`, + } + } + }, + + retail_year: { + sql: `retail_year_name`, + type: `string` + }, + + retail_month_long_name: { + sql: `retail_month_long_name`, + type: `string` + }, + + retail_week_name: { + sql: `retail_week_name`, + type: `string` + }, + + retail_year_begin_date: { + sql: `retail_year_begin_date`, + type: `time` + }, + + retail_year_end_date: { + sql: `retail_year_end_date`, + type: `time` + }, + + retail_quarter_year: { + sql: `retail_quarter_year`, + type: `string` + }, + + retail_month_begin_date: { + sql: `retail_month_begin_date`, + type: `string` + }, + + retail_week_begin_date: { + sql: `retail_week_begin_date`, + type: `string` + }, + + retail_year_week: { + sql: `retail_year_week`, + type: `string` + }, + + retail_week_in_month: { + sql: `retail_week_in_month`, + type: `string` + }, + + fiscal_year: { + sql: `fiscal_year`, + type: `string` + }, + + fiscal_quarter_year: { + sql: `fiscal_quarter_year`, + type: `string` + }, + + fiscal_year_month_number: { + sql: `fiscal_year_month_number`, + type: `number` + }, + + fiscal_year_month_name: { + sql: `fiscal_year_month_name`, + type: `string` + }, + + fiscal_year_period_name: { + sql: `fiscal_year_period_name`, + type: `number` + }, + + fiscal_month_number: { + sql: `fiscal_month_number`, + type: `string` + }, + + fiscal_month_short_name: { + sql: `fiscal_month_short_name`, + type: `string` + }, + + fiscal_week_name: { + sql: `fiscal_week_name`, + type: `string` + }, + + fiscal_week_begin_date: { + sql: `fiscal_week_begin_date`, + type: `time` + }, + + fiscal_week_end_date: { + sql: `fiscal_week_end_date`, + type: `time` + }, + + } +}); diff --git a/packages/cubejs-schema-compiler/test/unit/fixtures/custom_calendar.yml b/packages/cubejs-schema-compiler/test/unit/fixtures/custom_calendar.yml new file mode 100644 index 0000000000000..dee8571c0ac67 --- /dev/null +++ b/packages/cubejs-schema-compiler/test/unit/fixtures/custom_calendar.yml @@ -0,0 +1,141 @@ +cubes: + - name: custom_calendar + sql_table: public.custom_calendar + calendar: true + + dimensions: + # Plain date value + - name: date_val + sql: "{CUBE}.date_val" + type: time + primary_key: true + + ##### Retail Dates #### + - name: retail_date + sql: retail_date + type: time + + # This follows the syntax for custom granularities and + # extends it with the new `sql` parameter (that we wanted to introduce later anyway) + granularities: + - name: year + sql: "{CUBE.retail_year_begin_date}" + + - name: quarter + sql: "{CUBE.retail_quarter_year}" + + - name: month + sql: "{CUBE.retail_month_begin_date}" + + - name: week + sql: "{CUBE.retail_week_begin_date}" + + # Casually defining custom granularities should also work. + # While maybe not very sound from a business standpoint, + # such definition should be allowed in this data model + - name: fortnight + interval: 2 week + origin: "2025-01-01" + + - name: retail_year + sql: "{CUBE}.retail_year_name" + type: string + + - name: retail_month_long_name + sql: "{CUBE}.retail_month_long_name" + type: string + + - name: retail_week_name + sql: "{CUBE}.retail_week_name" + type: string + + - name: retail_year_begin_date + sql: "{CUBE}.retail_year_begin_date" + type: time + + - name: retail_year_end_date + sql: "{CUBE}.retail_year_end_date" + type: time + + - name: retail_quarter_year + sql: "{CUBE}.retail_quarter_year" + type: string + + - name: retail_month_begin_date + sql: "{CUBE}.retail_month_begin_date" + type: string + + - name: retail_week_begin_date + sql: "{CUBE}.retail_week_begin_date" + type: string + + - name: retail_year_week + sql: "{CUBE}.retail_year_week" + type: string + + - name: retail_week_in_month + sql: "{CUBE}.retail_week_in_month" + type: string + + ##### Fiscal Dates #### + + - name: fiscal_year + sql: "{CUBE}.fiscal_year" + type: string + + - name: fiscal_quarter_year + sql: "{CUBE}.fiscal_quarter_year" + type: string + + - name: fiscal_year_month_number + sql: "{CUBE}.fiscal_year_month_number" + type: number + + - name: fiscal_year_month_name + sql: "{CUBE}.fiscal_year_month_name" + type: string + + - name: fiscal_year_period_name + sql: "{CUBE}.fiscal_year_period_name" + type: number + + - name: fiscal_month_number + sql: "{CUBE}.fiscal_month_number" + type: string + + - name: fiscal_month_short_name + sql: "{CUBE}.fiscal_month_short_name" + type: string + + - name: fiscal_week_name + sql: "{CUBE}.fiscal_week_name" + type: string + + - name: fiscal_week_begin_date + sql: "{CUBE}.fiscal_week_begin_date" + type: time + + - name: fiscal_week_end_date + sql: "{CUBE}.fiscal_week_end_date" + type: time + + measures: + - name: count + type: count + + hierarchies: + - name: Fiscal_Calendar_Hierarchy + title: Fiscal Calendar Hierarchy + levels: + - fiscal_year + - fiscal_quarter_year + - fiscal_month_number + # - date + + - name: Retail_Calendar_Hierarchy + title: Retail Calendar Hierarchy + levels: + - retail_year + - retail_month_long_name + - retail_week_name + # - date diff --git a/packages/cubejs-schema-compiler/test/unit/schema.test.ts b/packages/cubejs-schema-compiler/test/unit/schema.test.ts index aa86d18455fa4..9fb37a1ca66ed 100644 --- a/packages/cubejs-schema-compiler/test/unit/schema.test.ts +++ b/packages/cubejs-schema-compiler/test/unit/schema.test.ts @@ -1232,4 +1232,100 @@ describe('Schema Testing', () => { } }); }); + + describe('Calendar Cubes', () => { + it('Valid calendar cubes', async () => { + const orders = fs.readFileSync( + path.join(process.cwd(), '/test/unit/fixtures/calendar_orders.yml'), + 'utf8' + ); + const customCalendarJs = fs.readFileSync( + path.join(process.cwd(), '/test/unit/fixtures/custom_calendar.js'), + 'utf8' + ); + const customCalendarYaml = fs.readFileSync( + path.join(process.cwd(), '/test/unit/fixtures/custom_calendar.yml'), + 'utf8' + ); + + const { compiler, cubeEvaluator } = prepareCompiler([ + { + content: orders, + fileName: 'calendar_orders.yml', + }, + { + content: customCalendarJs, + fileName: 'custom_calendar.js', + }, + { + content: customCalendarYaml, + fileName: 'custom_calendar.yml', + }, + ]); + await compiler.compile(); + compiler.throwIfAnyErrors(); + + const customCalendarJsCube = cubeEvaluator.cubeFromPath('custom_calendar_js'); + const customCalendarYamlCube = cubeEvaluator.cubeFromPath('custom_calendar'); + + expect(customCalendarJsCube).toMatchSnapshot('customCalendarJsCube'); + expect(customCalendarYamlCube).toMatchSnapshot('customCalendarYamlCube'); + }); + + it('CubeB.js correctly extends cubeA.js (no additions)', async () => { + const customCalendarJs = fs.readFileSync( + path.join(process.cwd(), '/test/unit/fixtures/custom_calendar.js'), + 'utf8' + ); + const customCalendarJsExt = 'cube(\'custom_calendar_js_ext\', { extends: custom_calendar_js })'; + + const { compiler, cubeEvaluator } = prepareCompiler([ + { + content: customCalendarJs, + fileName: 'custom_calendar.js', + }, + { + content: customCalendarJsExt, + fileName: 'custom_calendar_ext.js', + }, + ]); + await compiler.compile(); + compiler.throwIfAnyErrors(); + + const cubeA = cubeEvaluator.cubeFromPath('custom_calendar_js'); + const cubeB = cubeEvaluator.cubeFromPath('custom_calendar_js_ext'); + + CUBE_COMPONENTS.forEach(c => { + expect(cubeA[c]).toEqual(cubeB[c]); + }); + }); + + it('CubeB.yml correctly extends cubeA.js (no additions)', async () => { + const customCalendarYaml = fs.readFileSync( + path.join(process.cwd(), '/test/unit/fixtures/custom_calendar.yml'), + 'utf8' + ); + const customCalendarJsExt = 'cube(\'custom_calendar_js_ext\', { extends: custom_calendar })'; + + const { compiler, cubeEvaluator } = prepareCompiler([ + { + content: customCalendarYaml, + fileName: 'custom_calendar.yml', + }, + { + content: customCalendarJsExt, + fileName: 'custom_calendar_ext.js', + }, + ]); + await compiler.compile(); + compiler.throwIfAnyErrors(); + + const cubeA = cubeEvaluator.cubeFromPath('custom_calendar'); + const cubeB = cubeEvaluator.cubeFromPath('custom_calendar_js_ext'); + + CUBE_COMPONENTS.forEach(c => { + expect(cubeA[c]).toEqual(cubeB[c]); + }); + }); + }); }); diff --git a/rust/cubesqlplanner/cubesqlplanner/src/cube_bridge/dimension_definition.rs b/rust/cubesqlplanner/cubesqlplanner/src/cube_bridge/dimension_definition.rs index 1d9c9bd2f5af6..9283854f7a45b 100644 --- a/rust/cubesqlplanner/cubesqlplanner/src/cube_bridge/dimension_definition.rs +++ b/rust/cubesqlplanner/cubesqlplanner/src/cube_bridge/dimension_definition.rs @@ -11,14 +11,8 @@ use serde::{Deserialize, Serialize}; use std::any::Any; use std::rc::Rc; -#[derive(Deserialize, Serialize, Clone, Debug, PartialEq, Eq, Hash)] -pub struct GranularityDefinition { - pub interval: String, - pub origin: Option, - pub offset: Option, -} #[derive(Serialize, Deserialize, Debug)] -pub struct DimenstionDefinitionStatic { +pub struct DimensionDefinitionStatic { #[serde(rename = "type")] pub dimension_type: String, #[serde(rename = "ownedByCube")] @@ -31,7 +25,7 @@ pub struct DimenstionDefinitionStatic { pub propagate_filters_to_sub_query: Option, } -#[nativebridge::native_bridge(DimenstionDefinitionStatic)] +#[nativebridge::native_bridge(DimensionDefinitionStatic)] pub trait DimensionDefinition { #[nbridge(field, optional)] fn sql(&self) -> Result>, CubeError>; diff --git a/rust/cubesqlplanner/cubesqlplanner/src/cube_bridge/evaluator.rs b/rust/cubesqlplanner/cubesqlplanner/src/cube_bridge/evaluator.rs index c66a41d485321..14b3c3d239cb9 100644 --- a/rust/cubesqlplanner/cubesqlplanner/src/cube_bridge/evaluator.rs +++ b/rust/cubesqlplanner/cubesqlplanner/src/cube_bridge/evaluator.rs @@ -1,13 +1,14 @@ use super::cube_definition::{CubeDefinition, NativeCubeDefinition}; -use super::dimension_definition::{ - DimensionDefinition, GranularityDefinition, NativeDimensionDefinition, -}; +use super::dimension_definition::{DimensionDefinition, NativeDimensionDefinition}; use super::measure_definition::{MeasureDefinition, NativeMeasureDefinition}; use super::member_sql::{MemberSql, NativeMemberSql}; use super::pre_aggregation_description::{ NativePreAggregationDescription, PreAggregationDescription, }; use super::segment_definition::{NativeSegmentDefinition, SegmentDefinition}; +use crate::cube_bridge::granularity_definition::{ + GranularityDefinition, NativeGranularityDefinition, +}; use cubenativeutils::wrappers::serializer::{ NativeDeserialize, NativeDeserializer, NativeSerialize, }; @@ -54,7 +55,10 @@ pub trait CubeEvaluator { cube_name: String, sql: Rc, ) -> Result, CubeError>; - fn resolve_granularity(&self, path: Vec) -> Result; + fn resolve_granularity( + &self, + path: Vec, + ) -> Result, CubeError>; #[nbridge(vec)] fn pre_aggregations_for_cube_as_array( &self, diff --git a/rust/cubesqlplanner/cubesqlplanner/src/cube_bridge/granularity_definition.rs b/rust/cubesqlplanner/cubesqlplanner/src/cube_bridge/granularity_definition.rs new file mode 100644 index 0000000000000..5f08ad1e20e30 --- /dev/null +++ b/rust/cubesqlplanner/cubesqlplanner/src/cube_bridge/granularity_definition.rs @@ -0,0 +1,23 @@ +use super::member_sql::{MemberSql, NativeMemberSql}; +use cubenativeutils::wrappers::serializer::{ + NativeDeserialize, NativeDeserializer, NativeSerialize, +}; +use cubenativeutils::wrappers::NativeContextHolder; +use cubenativeutils::wrappers::NativeObjectHandle; +use cubenativeutils::CubeError; +use serde::{Deserialize, Serialize}; +use std::any::Any; +use std::rc::Rc; + +#[derive(Deserialize, Serialize, Clone, Debug, PartialEq, Eq, Hash)] +pub struct GranularityDefinitionStatic { + pub interval: String, + pub origin: Option, + pub offset: Option, +} + +#[nativebridge::native_bridge(GranularityDefinitionStatic)] +pub trait GranularityDefinition { + #[nbridge(field, optional)] + fn sql(&self) -> Result>, CubeError>; +} diff --git a/rust/cubesqlplanner/cubesqlplanner/src/cube_bridge/mod.rs b/rust/cubesqlplanner/cubesqlplanner/src/cube_bridge/mod.rs index 1262ca05575c5..c0c6295b367af 100644 --- a/rust/cubesqlplanner/cubesqlplanner/src/cube_bridge/mod.rs +++ b/rust/cubesqlplanner/cubesqlplanner/src/cube_bridge/mod.rs @@ -11,6 +11,7 @@ pub mod evaluator; pub mod filter_group; pub mod filter_params; pub mod geo_item; +pub mod granularity_definition; pub mod join_definition; pub mod join_graph; pub mod join_hints; diff --git a/rust/cubesqlplanner/cubesqlplanner/src/planner/base_time_dimension.rs b/rust/cubesqlplanner/cubesqlplanner/src/planner/base_time_dimension.rs index c5018b93fb745..6a28036061e55 100644 --- a/rust/cubesqlplanner/cubesqlplanner/src/planner/base_time_dimension.rs +++ b/rust/cubesqlplanner/cubesqlplanner/src/planner/base_time_dimension.rs @@ -1,5 +1,5 @@ use super::query_tools::QueryTools; -use super::sql_evaluator::{MemberSymbol, TimeDimensionSymbol}; +use super::sql_evaluator::{Compiler, MemberSymbol, TimeDimensionSymbol}; use super::BaseDimension; use super::Granularity; use super::GranularityHelper; @@ -96,6 +96,7 @@ impl BaseTimeDimension { pub fn try_new_required( query_tools: Rc, member_evaluator: Rc, + compiler: &mut Compiler, granularity: Option, date_range: Option>, ) -> Result, CubeError> { @@ -116,6 +117,7 @@ impl BaseTimeDimension { let granularity_obj = GranularityHelper::make_granularity_obj( query_tools.cube_evaluator().clone(), + compiler, query_tools.timezone().clone(), &dimension.cube_name(), &dimension.name(), @@ -123,7 +125,7 @@ impl BaseTimeDimension { )?; let date_range_tuple = if let Some(date_range) = &date_range { - assert!(date_range.len() == 2); + assert_eq!(date_range.len(), 2); Some((date_range[0].clone(), date_range[1].clone())) } else { None @@ -150,15 +152,19 @@ impl BaseTimeDimension { &self, new_granularity: Option, ) -> Result, CubeError> { + let evaluator_compiler_cell = self.query_tools.evaluator_compiler().clone(); + let mut evaluator_compiler = evaluator_compiler_cell.borrow_mut(); + let new_granularity_obj = GranularityHelper::make_granularity_obj( self.query_tools.cube_evaluator().clone(), + &mut evaluator_compiler, self.query_tools.timezone(), &self.dimension.name(), &self.dimension.cube_name(), new_granularity.clone(), )?; let date_range_tuple = if let Some(date_range) = &self.date_range { - assert!(date_range.len() == 2); + assert_eq!(date_range.len(), 2); Some((date_range[0].clone(), date_range[1].clone())) } else { None diff --git a/rust/cubesqlplanner/cubesqlplanner/src/planner/query_properties.rs b/rust/cubesqlplanner/cubesqlplanner/src/planner/query_properties.rs index e2b3bc5ee33c6..73c3587c80d43 100644 --- a/rust/cubesqlplanner/cubesqlplanner/src/planner/query_properties.rs +++ b/rust/cubesqlplanner/cubesqlplanner/src/planner/query_properties.rs @@ -169,6 +169,7 @@ impl QueryProperties { BaseTimeDimension::try_new_required( query_tools.clone(), evaluator, + &mut evaluator_compiler, d.granularity.clone(), d.date_range.clone(), ) diff --git a/rust/cubesqlplanner/cubesqlplanner/src/planner/sql_evaluator/dependecy.rs b/rust/cubesqlplanner/cubesqlplanner/src/planner/sql_evaluator/dependecy.rs index 9a00ffe4767c9..6016ce4f9758f 100644 --- a/rust/cubesqlplanner/cubesqlplanner/src/planner/sql_evaluator/dependecy.rs +++ b/rust/cubesqlplanner/cubesqlplanner/src/planner/sql_evaluator/dependecy.rs @@ -172,6 +172,7 @@ impl<'a> DependenciesBuilder<'a> { let granularity = &call_deps[*child_ind].name; if let Some(granularity_obj) = GranularityHelper::make_granularity_obj( self.cube_evaluator.clone(), + self.compiler, self.timezone.clone(), cube_name, &dep.name, diff --git a/rust/cubesqlplanner/cubesqlplanner/src/planner/sql_evaluator/sql_nodes/time_dimension.rs b/rust/cubesqlplanner/cubesqlplanner/src/planner/sql_evaluator/sql_nodes/time_dimension.rs index 004d74bb65563..4376d59887642 100644 --- a/rust/cubesqlplanner/cubesqlplanner/src/planner/sql_evaluator/sql_nodes/time_dimension.rs +++ b/rust/cubesqlplanner/cubesqlplanner/src/planner/sql_evaluator/sql_nodes/time_dimension.rs @@ -44,6 +44,15 @@ impl SqlNode for TimeDimensionNode { match node.as_ref() { MemberSymbol::TimeDimension(ev) => { let res = if let Some(granularity_obj) = ev.granularity_obj() { + if let Some(calendar_sql) = granularity_obj.calendar_sql() { + return calendar_sql.eval( + visitor, + node_processor.clone(), + query_tools.clone(), + templates, + ); + } + let converted_tz = if self .dimensions_with_ignored_timezone .contains(&ev.full_name()) diff --git a/rust/cubesqlplanner/cubesqlplanner/src/planner/sql_evaluator/symbols/time_dimension_symbol.rs b/rust/cubesqlplanner/cubesqlplanner/src/planner/sql_evaluator/symbols/time_dimension_symbol.rs index b658cb67dda33..4f5b4a839db72 100644 --- a/rust/cubesqlplanner/cubesqlplanner/src/planner/sql_evaluator/symbols/time_dimension_symbol.rs +++ b/rust/cubesqlplanner/cubesqlplanner/src/planner/sql_evaluator/symbols/time_dimension_symbol.rs @@ -60,10 +60,6 @@ impl TimeDimensionSymbol { self.alias_suffix.clone() } - pub fn get_dependencies(&self) -> Vec> { - self.base_symbol.get_dependencies() - } - pub fn owned_by_cube(&self) -> bool { self.base_symbol.owned_by_cube() } @@ -94,8 +90,28 @@ impl TimeDimensionSymbol { .collect() } + pub fn get_dependencies(&self) -> Vec> { + let mut deps = vec![]; + if let Some(granularity_obj) = &self.granularity_obj { + if let Some(calendar_sql) = granularity_obj.calendar_sql() { + calendar_sql.extract_symbol_deps(&mut deps); + } + } + + deps.append(&mut self.base_symbol.get_dependencies()); + deps + } + pub fn get_dependencies_with_path(&self) -> Vec<(Rc, Vec)> { - self.base_symbol.get_dependencies_with_path() + let mut deps = vec![]; + if let Some(granularity_obj) = &self.granularity_obj { + if let Some(calendar_sql) = granularity_obj.calendar_sql() { + calendar_sql.extract_symbol_deps_with_path(&mut deps); + } + } + + deps.append(&mut self.base_symbol.get_dependencies_with_path()); + deps } pub fn cube_name(&self) -> String { @@ -107,6 +123,12 @@ impl TimeDimensionSymbol { } pub fn is_reference(&self) -> bool { + if let Some(granularity_obj) = &self.granularity_obj { + if granularity_obj.calendar_sql().is_some() { + return false; + } + } + self.base_symbol.is_reference() } diff --git a/rust/cubesqlplanner/cubesqlplanner/src/planner/time_dimension/granularity.rs b/rust/cubesqlplanner/cubesqlplanner/src/planner/time_dimension/granularity.rs index cdaee8d8dfcb9..0e5e0d242ff5a 100644 --- a/rust/cubesqlplanner/cubesqlplanner/src/planner/time_dimension/granularity.rs +++ b/rust/cubesqlplanner/cubesqlplanner/src/planner/time_dimension/granularity.rs @@ -1,10 +1,12 @@ use super::{GranularityHelper, QueryDateTime, SqlInterval}; +use crate::planner::sql_evaluator::SqlCall; use chrono_tz::Tz; use cubenativeutils::CubeError; use itertools::Itertools; +use std::rc::Rc; use std::str::FromStr; -#[derive(Clone, Debug)] +#[derive(Clone)] pub struct Granularity { granularity: String, granularity_interval: String, @@ -12,6 +14,7 @@ pub struct Granularity { origin: QueryDateTime, is_predefined_granularity: bool, is_natural_aligned: bool, + calendar_sql: Option>, } impl Granularity { @@ -26,6 +29,7 @@ impl Granularity { origin, is_predefined_granularity: true, is_natural_aligned: true, + calendar_sql: None, }) } pub fn try_new_custom( @@ -34,7 +38,21 @@ impl Granularity { origin: Option, granularity_interval: String, granularity_offset: Option, + calendar_sql: Option>, ) -> Result { + // sql() is mutual exclusive with interval and offset/origin + if calendar_sql.is_some() { + return Ok(Self { + granularity, + granularity_interval, + granularity_offset: None, + origin: Self::default_origin(timezone)?, + is_predefined_granularity: false, + is_natural_aligned: false, + calendar_sql, + }); + } + let origin = if let Some(origin) = origin { QueryDateTime::from_date_str(timezone, &origin)? } else if let Some(offset) = &granularity_offset { @@ -68,6 +86,7 @@ impl Granularity { origin, is_predefined_granularity: false, is_natural_aligned, + calendar_sql, }) } @@ -79,6 +98,10 @@ impl Granularity { &self.granularity_offset } + pub fn calendar_sql(&self) -> &Option> { + &self.calendar_sql + } + pub fn granularity(&self) -> &String { &self.granularity } diff --git a/rust/cubesqlplanner/cubesqlplanner/src/planner/time_dimension/granularity_helper.rs b/rust/cubesqlplanner/cubesqlplanner/src/planner/time_dimension/granularity_helper.rs index bc04336bd358d..336113a76c8a0 100644 --- a/rust/cubesqlplanner/cubesqlplanner/src/planner/time_dimension/granularity_helper.rs +++ b/rust/cubesqlplanner/cubesqlplanner/src/planner/time_dimension/granularity_helper.rs @@ -1,4 +1,5 @@ use crate::cube_bridge::evaluator::CubeEvaluator; +use crate::planner::sql_evaluator::Compiler; use crate::planner::BaseTimeDimension; use crate::planner::Granularity; use chrono::prelude::*; @@ -93,7 +94,7 @@ impl GranularityHelper { } pub fn granularity_parents(granularity: &str) -> Result<&Vec, CubeError> { - if let Some(parents) = Self::standard_granularity_parents().get(granularity) { + if let Some(parents) = Self::standard_granularity_hierarchy().get(granularity) { Ok(parents) } else { Err(CubeError::user(format!( @@ -104,12 +105,12 @@ impl GranularityHelper { } pub fn is_predefined_granularity(granularity: &str) -> bool { - Self::standard_granularity_parents().contains_key(granularity) + Self::standard_granularity_hierarchy().contains_key(granularity) } - pub fn standard_granularity_parents() -> &'static HashMap> { + pub fn standard_granularity_hierarchy() -> &'static HashMap> { lazy_static! { - static ref STANDARD_GRANULARITIES_PARENTS: HashMap> = { + static ref STANDARD_GRANULARITY_HIERARCHIES: HashMap> = { let mut map = HashMap::new(); map.insert( "year".to_string(), @@ -179,7 +180,7 @@ impl GranularityHelper { map }; } - &STANDARD_GRANULARITIES_PARENTS + &STANDARD_GRANULARITY_HIERARCHIES } pub fn parse_date_time_in_tz(date: &str, timezone: &Tz) -> Result, CubeError> { @@ -217,26 +218,34 @@ impl GranularityHelper { pub fn make_granularity_obj( cube_evaluator: Rc, + compiler: &mut Compiler, timezone: Tz, cube_name: &String, name: &String, granularity: Option, ) -> Result, CubeError> { let granularity_obj = if let Some(granularity) = &granularity { - if !Self::is_predefined_granularity(&granularity) { - let path = vec![ - cube_name.clone(), - name.clone(), - "granularities".to_string(), - granularity.clone(), - ]; - let granularity_definition = cube_evaluator.resolve_granularity(path)?; + let path = vec![ + cube_name.clone(), + name.clone(), + "granularities".to_string(), + granularity.clone(), + ]; + let granularity_definition = cube_evaluator.resolve_granularity(path)?; + let gran_eval_sql = if let Some(gran_sql) = granularity_definition.sql()? { + Some(compiler.compile_sql_call(&cube_name, gran_sql)?) + } else { + None + }; + + if gran_eval_sql.is_some() || !Self::is_predefined_granularity(&granularity) { Some(Granularity::try_new_custom( timezone.clone(), granularity.clone(), - granularity_definition.origin, - granularity_definition.interval, - granularity_definition.offset, + granularity_definition.static_data().origin.clone(), + granularity_definition.static_data().interval.clone(), + granularity_definition.static_data().offset.clone(), + gran_eval_sql, )?) } else { Some(Granularity::try_new_predefined(