/* SQL Studio 1.4.1 - SQL Studio Frontend, the JavaScript Code Resource. Paste this whole file. sql-studio-frontend.js is the same code with its comments, for reading. */ /* Copyright (c) 2026 Mateusz Dąbrowski. Free to use, change and share, commercial use included, as long as this notice stays. Licensed under the EUPL 1.2: https://github.com/MateuszDabrowski/sqlstudio/blob/main/LICENSE */ /* Includes Salesforce Lightning Design System icons, unchanged: © Salesforce, Inc., CC BY-ND 4.0, https://creativecommons.org/licenses/by-nd/4.0/ */ /* Contents (line numbers are where each part starts in this finished file): 1. SQL data: Data Views, functions and keywords - line 10 2. SQL tools: lint rules, formatter and parser - line 1155 3. Styles - line 20726 4. The interface: editor, tabs, results, dialogs and runs - line 20728 */ /* ================================================ sqlstudio-sql-data.js == */ window.SQLStudioSQL = { sources: [ { title: 'MCE SQL Basics', url: 'https://mateuszdabrowski.pl/docs/salesforce/marketing-cloud-engagement/sql/sql-basics/' }, { title: 'MCE SQL Select', url: 'https://mateuszdabrowski.pl/docs/salesforce/marketing-cloud-engagement/sql/sql-select/' }, { title: 'MCE SQL From', url: 'https://mateuszdabrowski.pl/docs/salesforce/marketing-cloud-engagement/sql/sql-from/' }, { title: 'MCE SQL Join', url: 'https://mateuszdabrowski.pl/docs/salesforce/marketing-cloud-engagement/sql/sql-join/' }, { title: 'MCE SQL Where', url: 'https://mateuszdabrowski.pl/docs/salesforce/marketing-cloud-engagement/sql/sql-where/' }, { title: 'MCE SQL Case', url: 'https://mateuszdabrowski.pl/docs/salesforce/marketing-cloud-engagement/sql/sql-case/' }, { title: 'MCE SQL Like', url: 'https://mateuszdabrowski.pl/docs/salesforce/marketing-cloud-engagement/sql/sql-like/' }, { title: 'MCE SQL String Functions', url: 'https://mateuszdabrowski.pl/docs/salesforce/marketing-cloud-engagement/sql/sql-string-functions/' }, { title: 'MCE SQL Date Functions', url: 'https://mateuszdabrowski.pl/docs/salesforce/marketing-cloud-engagement/sql/sql-date-functions/' }, { title: 'MCE SQL Numeric Functions', url: 'https://mateuszdabrowski.pl/docs/salesforce/marketing-cloud-engagement/sql/sql-numeric-functions/' }, { title: 'MCE SQL Conversion Functions', url: 'https://mateuszdabrowski.pl/docs/salesforce/marketing-cloud-engagement/sql/sql-conversion-functions/' }, { title: 'MCE SQL Aggregate Functions', url: 'https://mateuszdabrowski.pl/docs/salesforce/marketing-cloud-engagement/sql/sql-aggregate-functions/' }, { title: 'MCE SQL Null Functions', url: 'https://mateuszdabrowski.pl/docs/salesforce/marketing-cloud-engagement/sql/sql-null-functions/' }, { title: 'MCE SQL Window Functions', url: 'https://mateuszdabrowski.pl/docs/salesforce/marketing-cloud-engagement/sql/sql-window-functions/' }, { title: 'MCE SQL JSON Functions', url: 'https://mateuszdabrowski.pl/docs/salesforce/marketing-cloud-engagement/sql/sql-json-functions/' }, { title: 'MCE SQL Style Guide', url: 'https://mateuszdabrowski.pl/docs/salesforce/marketing-cloud-engagement/sql/sql-style-guide/' }, { title: 'MCE System Data Views', url: 'https://mateuszdabrowski.pl/docs/salesforce/marketing-cloud-engagement/config/system-data-views/' }, { title: 'MCE Mobile Connect Data Views', url: 'https://mateuszdabrowski.pl/docs/salesforce/marketing-cloud-engagement/config/mobile-connect-data-views/' }, { title: 'MCE MobilePush Data Views', url: 'https://mateuszdabrowski.pl/docs/salesforce/marketing-cloud-engagement/config/mobile-push-data-views/' }, { title: 'Salesforce Help - Data Views', url: 'https://help.salesforce.com/s/articleView?id=mktg.mc_as_data_views.htm' }, { title: 'Salesforce Help - SQL Reference', url: 'https://help.salesforce.com/s/articleView?id=mktg.mc_as_sql_reference.htm' }, { title: 'Salesforce Help - Optimizing a SQL Query Activity', url: 'https://help.salesforce.com/s/articleView?id=mktg.mc_as_optimize_the_query_activity.htm' }, { title: 'Salesforce Help - Build a SQL Query Activity', url: 'https://help.salesforce.com/s/articleView?id=mktg.mc_as_use_the_sql_query_activity.htm' }, { title: 'Microsoft Learn - Transact-SQL Function Reference', url: 'https://learn.microsoft.com/en-us/sql/t-sql/functions/functions' }, { title: 'Microsoft Learn - Transact-SQL Language Reference', url: 'https://learn.microsoft.com/en-us/sql/t-sql/language-reference' } ], keywords: [ { name: 'SELECT', category: 'clause', description: 'Starts a query and lists the columns or expressions to return.', syntax: 'SELECT [TOP (n)] [DISTINCT] column_list FROM ...', example: 'SELECT SubscriberKey, EmailAddress FROM _Subscribers', docs: 'https://mateuszdabrowski.pl/docs/salesforce/marketing-cloud-engagement/sql/sql-select/', officialDocs: 'https://learn.microsoft.com/en-us/sql/t-sql/queries/select-transact-sql', sfmc: 'Only SELECT queries run inside a Query Activity; INSERT, UPDATE, DELETE, DECLARE, EXEC and control-flow statements are not supported. A Query Activity that combines SELECT * with any JOIN fails validation.' }, { name: 'TOP', category: 'clause', description: 'Limits the result set to the first n rows.', syntax: 'SELECT TOP (n) column_list ...', example: 'SELECT TOP 10 SubscriberKey FROM _Subscribers ORDER BY DateJoined DESC', docs: 'https://mateuszdabrowski.pl/docs/salesforce/marketing-cloud-engagement/sql/sql-select/', officialDocs: 'https://learn.microsoft.com/en-us/sql/t-sql/queries/top-transact-sql', sfmc: 'Always pair TOP with ORDER BY, otherwise SQL Server may return an arbitrary subset of rows.' }, { name: 'PERCENT', category: 'modifier', description: 'Modifies TOP to return a percentage of rows instead of a fixed count, rounded up.', syntax: 'SELECT TOP (n) PERCENT column_list ...', example: 'SELECT TOP 10 PERCENT SubscriberKey FROM _Subscribers ORDER BY DateJoined DESC', docs: 'https://mateuszdabrowski.pl/docs/salesforce/marketing-cloud-engagement/sql/sql-select/', officialDocs: 'https://learn.microsoft.com/en-us/sql/t-sql/queries/top-transact-sql', sfmc: 'Works inside an Automation Studio Query Activity but produces an error when the same query is run directly in Query Studio.' }, { name: 'WITH TIES', category: 'modifier', description: 'Extends TOP to also include rows that tie the ORDER BY value of the last returned row.', syntax: 'SELECT TOP (n) WITH TIES column_list ... ORDER BY ...', example: 'SELECT TOP 5 WITH TIES SubscriberKey, BounceCount FROM _Subscribers ORDER BY BounceCount DESC', docs: 'https://mateuszdabrowski.pl/docs/salesforce/marketing-cloud-engagement/sql/sql-select/', officialDocs: 'https://learn.microsoft.com/en-us/sql/t-sql/queries/top-transact-sql', sfmc: 'Requires ORDER BY. Works in a Query Activity but throws an error in interactive Query Studio.' }, { name: 'DISTINCT', category: 'clause', description: 'Removes duplicate rows from the result set, based on the combination of all selected columns.', syntax: 'SELECT DISTINCT column_list FROM ...', example: 'SELECT DISTINCT Domain FROM _Subscribers', docs: 'https://mateuszdabrowski.pl/docs/salesforce/marketing-cloud-engagement/sql/sql-select/', officialDocs: 'https://learn.microsoft.com/en-us/sql/t-sql/queries/select-clause-transact-sql', sfmc: 'Applies to the full combination of selected columns and runs before ORDER BY. Every ORDER BY column must also appear in the SELECT list when DISTINCT is used.' }, { name: 'FROM', category: 'clause', description: 'Specifies the Data Extension or Data View a query reads from.', syntax: 'SELECT column_list FROM table_name', example: 'SELECT SubscriberKey FROM _Subscribers', docs: 'https://mateuszdabrowski.pl/docs/salesforce/marketing-cloud-engagement/sql/sql-from/', officialDocs: 'https://learn.microsoft.com/en-us/sql/t-sql/queries/from-transact-sql', sfmc: 'System Data Views use a leading underscore (_Subscribers). Names with spaces or hyphens need [brackets]. The legacy MID-prefixed multi-part name (12345.DataExtensionOnChildBU) now errors; use a Shared Data Extension with the Ent. prefix instead.' }, { name: 'WHERE', category: 'clause', description: 'Filters rows before grouping, based on a boolean condition.', syntax: 'SELECT column_list FROM table WHERE condition', example: "SELECT SubscriberKey FROM _Subscribers WHERE Status = 'Active'", docs: 'https://mateuszdabrowski.pl/docs/salesforce/marketing-cloud-engagement/sql/sql-where/', officialDocs: 'https://learn.microsoft.com/en-us/sql/t-sql/queries/where-transact-sql', sfmc: "Text comparisons ignore letter case and trailing spaces and keep accents, as text compares with the collation SQL_Latin1_General_CP1_CI_AS: 'abc' = 'ABC' and 'abc ' = 'abc' are true, 'café' = 'cafe' is false (org check, 2026-10-14). So LOWER() or UPPER() around a comparison is not needed. Any comparison with NULL, = NULL included, is never true, as ANSI_NULLS is on. Use IS NULL instead." }, { name: 'AND', category: 'logical-operator', description: 'Combines two conditions; both must be true.', syntax: 'condition1 AND condition2', example: "WHERE Status = 'Active' AND BounceCount = 0", docs: 'https://mateuszdabrowski.pl/docs/salesforce/marketing-cloud-engagement/sql/sql-where/', officialDocs: 'https://learn.microsoft.com/en-us/sql/t-sql/language-elements/and-transact-sql' }, { name: 'OR', category: 'logical-operator', description: 'Combines two conditions; at least one must be true.', syntax: 'condition1 OR condition2', example: "WHERE Status = 'Active' OR Status = 'Held'", docs: 'https://mateuszdabrowski.pl/docs/salesforce/marketing-cloud-engagement/sql/sql-where/', officialDocs: 'https://learn.microsoft.com/en-us/sql/t-sql/language-elements/or-transact-sql', sfmc: 'OR predicates are non-SARGable and can force a full scan on large tracking data views; an IN list of the same values is usually faster.' }, { name: 'NOT', category: 'logical-operator', description: 'Negates the condition that follows it.', syntax: 'NOT condition', example: "WHERE NOT Status = 'Unsubscribed'", docs: 'https://mateuszdabrowski.pl/docs/salesforce/marketing-cloud-engagement/sql/sql-where/', officialDocs: 'https://learn.microsoft.com/en-us/sql/t-sql/language-elements/not-transact-sql', sfmc: 'NOT IN, NOT EXISTS and NOT LIKE are non-SARGable. NOT IN (SELECT ...) returns zero rows if the subquery yields any NULL; filter it out or use NOT EXISTS.' }, { name: 'IN', category: 'logical-operator', description: 'Tests whether a value matches any value in a list or subquery.', syntax: 'column IN (value1, value2, ...) | column IN (SELECT ...)', example: "WHERE Status IN ('Active', 'Held')", docs: 'https://mateuszdabrowski.pl/docs/salesforce/marketing-cloud-engagement/sql/sql-where/', officialDocs: 'https://learn.microsoft.com/en-us/sql/t-sql/language-elements/in-transact-sql', sfmc: 'Evaluated faster than an equivalent chain of OR conditions. Cannot be combined with LIKE in the same predicate. NULL values inside the list are ignored.' }, { name: 'EXISTS', category: 'logical-operator', description: 'Tests whether a subquery returns at least one row.', syntax: 'WHERE EXISTS (SELECT 1 FROM ... WHERE ...)', example: 'SELECT SubscriberKey FROM _Subscribers s WHERE EXISTS (SELECT 1 FROM _Open o WHERE o.SubscriberKey = s.SubscriberKey)', docs: 'https://learn.microsoft.com/en-us/sql/t-sql/language-elements/exists-transact-sql' }, { name: 'BETWEEN', category: 'logical-operator', description: 'Tests whether a value falls within an inclusive range.', syntax: 'column BETWEEN low AND high', example: "WHERE DateJoined BETWEEN '2024-01-01' AND '2024-01-31'", docs: 'https://mateuszdabrowski.pl/docs/salesforce/marketing-cloud-engagement/sql/sql-where/', officialDocs: 'https://learn.microsoft.com/en-us/sql/t-sql/language-elements/between-transact-sql', sfmc: 'Inclusive on both ends, so a datetime range like this example misses events that occur after midnight on the last day. Prefer col >= start AND col < next_day for date ranges.' }, { name: 'LIKE', category: 'logical-operator', description: 'Matches a string against a wildcard pattern.', syntax: "column LIKE pattern [ESCAPE 'escape_char']", example: "WHERE EmailAddress LIKE '%@gmail.com'", docs: 'https://mateuszdabrowski.pl/docs/salesforce/marketing-cloud-engagement/sql/sql-like/', officialDocs: 'https://learn.microsoft.com/en-us/sql/t-sql/language-elements/like-transact-sql', sfmc: "Matching ignores letter case, as = does, but keeps trailing spaces: MCE's text values are nvarchar at Run, so 'abc ' LIKE 'abc' is false while 'abc ' = 'abc' is true (org check, 2026-10-14). LIKE cannot be combined with IN. Use several LIKE conditions joined with OR instead." }, { name: 'ESCAPE', category: 'clause', description: 'Defines an escape character so a wildcard character in LIKE can be matched literally.', syntax: "column LIKE pattern ESCAPE 'escape_char'", example: "WHERE Code LIKE '50!%' ESCAPE '!'", docs: 'https://learn.microsoft.com/en-us/sql/t-sql/language-elements/like-transact-sql', sfmc: "Wrapping the literal wildcard in [brackets] (e.g. LIKE '[%]') is the pattern shown in MCE's own docs and is more common than ESCAPE in Query Studio examples." }, { name: 'IS NULL', category: 'logical-operator', description: 'Tests whether a value is NULL.', syntax: 'column IS NULL', example: 'WHERE EmailAddress IS NULL', docs: 'https://mateuszdabrowski.pl/docs/salesforce/marketing-cloud-engagement/sql/sql-null-functions/', officialDocs: 'https://learn.microsoft.com/en-us/sql/t-sql/queries/is-null-transact-sql', sfmc: 'The only reliable way to test for NULL; = NULL and != NULL never match anything.' }, { name: 'IS NOT NULL', category: 'logical-operator', description: 'Tests whether a value is not NULL.', syntax: 'column IS NOT NULL', example: 'WHERE EmailAddress IS NOT NULL', docs: 'https://mateuszdabrowski.pl/docs/salesforce/marketing-cloud-engagement/sql/sql-null-functions/', officialDocs: 'https://learn.microsoft.com/en-us/sql/t-sql/queries/is-null-transact-sql' }, { name: 'AS', category: 'clause', description: 'Assigns an alias to a column, expression or table.', syntax: 'expression AS alias', example: "SELECT FirstName + ' ' + LastName AS FullName FROM Contacts", docs: 'https://mateuszdabrowski.pl/docs/salesforce/marketing-cloud-engagement/sql/sql-select/', officialDocs: 'https://learn.microsoft.com/en-us/sql/t-sql/queries/select-clause-transact-sql', sfmc: 'A computed column without an alias has no name and cannot map to a target Data Extension field; Query Studio surfaces this as "Field name cannot be blank".' }, { name: 'INNER JOIN', category: 'join', description: 'Returns only rows that have a match in both tables.', syntax: 'FROM t1 INNER JOIN t2 ON t1.col = t2.col', example: 'SELECT s.SubscriberKey, o.EventDate FROM _Subscribers AS s INNER JOIN _Open AS o ON s.SubscriberID = o.SubscriberID', docs: 'https://mateuszdabrowski.pl/docs/salesforce/marketing-cloud-engagement/sql/sql-join/', officialDocs: 'https://learn.microsoft.com/en-us/sql/t-sql/queries/from-transact-sql', sfmc: 'Write INNER JOIN explicitly rather than the bare JOIN keyword, per the MCE SQL style guide. Only join columns of the same data type.' }, { name: 'LEFT JOIN', category: 'join', description: 'Returns all rows from the left table, with matching rows from the right table or NULLs when there is no match.', syntax: 'FROM t1 LEFT JOIN t2 ON t1.col = t2.col', example: 'SELECT s.SubscriberKey, o.EventDate FROM _Subscribers AS s LEFT JOIN _Open AS o ON s.SubscriberID = o.SubscriberID', docs: 'https://mateuszdabrowski.pl/docs/salesforce/marketing-cloud-engagement/sql/sql-join/', officialDocs: 'https://learn.microsoft.com/en-us/sql/t-sql/queries/from-transact-sql', sfmc: 'Also written LEFT OUTER JOIN. Useful as an anti-join pattern: LEFT JOIN then WHERE right.Key IS NULL finds rows with no match.' }, { name: 'RIGHT JOIN', category: 'join', description: 'Returns all rows from the right table, with matching rows from the left table or NULLs when there is no match.', syntax: 'FROM t1 RIGHT JOIN t2 ON t1.col = t2.col', example: 'SELECT s.SubscriberKey, j.EmailName FROM _Sent AS s RIGHT JOIN _Job AS j ON s.JobID = j.JobID', docs: 'https://mateuszdabrowski.pl/docs/salesforce/marketing-cloud-engagement/sql/sql-join/', officialDocs: 'https://learn.microsoft.com/en-us/sql/t-sql/queries/from-transact-sql', sfmc: 'Also written RIGHT OUTER JOIN. Rewriting as a LEFT JOIN with the tables swapped is more common in the MCE community style.' }, { name: 'FULL JOIN', category: 'join', description: 'Returns all rows from both tables, with NULLs where either side has no match.', syntax: 'FROM t1 FULL JOIN t2 ON t1.col = t2.col', example: 'SELECT s.SubscriberKey, u.EventDate FROM _Subscribers AS s FULL JOIN _Unsubscribe AS u ON s.SubscriberID = u.SubscriberID', docs: 'https://mateuszdabrowski.pl/docs/salesforce/marketing-cloud-engagement/sql/sql-join/', officialDocs: 'https://learn.microsoft.com/en-us/sql/t-sql/queries/from-transact-sql', sfmc: 'Also written FULL OUTER JOIN.' }, { name: 'CROSS JOIN', category: 'join', description: 'Returns the Cartesian product of two tables: every row of the first combined with every row of the second.', syntax: 'FROM t1 CROSS JOIN t2', example: 'SELECT s.SubscriberKey, d.DayLabel FROM _Subscribers AS s CROSS JOIN CalendarDays AS d', docs: 'https://learn.microsoft.com/en-us/sql/t-sql/queries/from-transact-sql', sfmc: 'Works in a Query Activity. The row counts multiply: two tables of 1,000 rows give 1,000,000, so filter both sides first, or cross join a small table.' }, { name: 'CROSS APPLY', category: 'join', description: 'Evaluates a subquery once per row of the left table, keeping only rows where the subquery returns at least one row.', syntax: 'FROM t1 CROSS APPLY (SELECT ... WHERE ... = t1.col) AS x', example: 'SELECT s.SubscriberKey, lastOpen.EventDate AS LastOpen FROM _Subscribers AS s CROSS APPLY (SELECT TOP 1 o.EventDate FROM _Open AS o WHERE o.SubscriberKey = s.SubscriberKey ORDER BY o.EventDate DESC) AS lastOpen', docs: 'https://learn.microsoft.com/en-us/sql/t-sql/queries/from-transact-sql', sfmc: 'Use it with a subquery, as a per-row lookup such as each subscriber\'s latest open. The query check refuses a table function with more than one argument after APPLY, such as STRING_SPLIT (org check, 2026-09-28).' }, { name: 'OUTER APPLY', category: 'join', description: 'Like CROSS APPLY, but keeps left-side rows even when the subquery returns no rows (NULLs fill the right side).', syntax: 'FROM t1 OUTER APPLY (SELECT ... WHERE ... = t1.col) AS x', example: 'SELECT s.SubscriberKey, lastOpen.EventDate AS LastOpen FROM _Subscribers AS s OUTER APPLY (SELECT TOP 1 o.EventDate FROM _Open AS o WHERE o.SubscriberKey = s.SubscriberKey ORDER BY o.EventDate DESC) AS lastOpen', docs: 'https://learn.microsoft.com/en-us/sql/t-sql/queries/from-transact-sql', sfmc: 'Behaves like a LEFT JOIN against a per-row subquery: rows with no match stay, with NULLs on the right.' }, { name: 'ON', category: 'clause', description: 'Defines the join condition between two tables.', syntax: 'JOIN table2 ON table1.col = table2.col', example: 'INNER JOIN _Job AS j ON s.JobID = j.JobID', docs: 'https://mateuszdabrowski.pl/docs/salesforce/marketing-cloud-engagement/sql/sql-join/', officialDocs: 'https://learn.microsoft.com/en-us/sql/t-sql/queries/from-transact-sql', sfmc: 'A function wrapped around either side of the ON condition (e.g. CONVERT, DATEDIFF) has a significant performance impact; compare raw columns instead.' }, { name: 'GROUP BY', category: 'clause', description: 'Groups rows that share the same values in the listed columns, for use with aggregate functions.', syntax: 'SELECT col, AGG(other_col) FROM table GROUP BY col', example: 'SELECT Domain, COUNT(*) AS SubscriberCount FROM _Subscribers GROUP BY Domain', docs: 'https://mateuszdabrowski.pl/docs/salesforce/marketing-cloud-engagement/sql/sql-aggregate-functions/', officialDocs: 'https://learn.microsoft.com/en-us/sql/t-sql/queries/select-group-by-transact-sql', sfmc: 'NULL values are grouped together as if equal. Every non-aggregated column in the SELECT list must also appear in GROUP BY.' }, { name: 'HAVING', category: 'clause', description: 'Filters grouped rows after aggregation, using a condition on an aggregate value.', syntax: 'GROUP BY col HAVING aggregate_condition', example: 'SELECT EmailAddress, COUNT(*) AS Cnt FROM _Subscribers GROUP BY EmailAddress HAVING COUNT(*) > 1', docs: 'https://mateuszdabrowski.pl/docs/salesforce/marketing-cloud-engagement/sql/sql-aggregate-functions/', officialDocs: 'https://learn.microsoft.com/en-us/sql/t-sql/queries/select-having-transact-sql', sfmc: 'WHERE filters rows before GROUP BY; HAVING filters groups after GROUP BY. Execution order is FROM, WHERE, GROUP BY, HAVING, DISTINCT, ORDER BY.' }, { name: 'ORDER BY', category: 'clause', description: 'Sorts the result set by one or more columns.', syntax: 'SELECT ... ORDER BY col [ASC|DESC]', example: 'SELECT TOP 10 SubscriberKey FROM _Subscribers ORDER BY DateJoined DESC', docs: 'https://mateuszdabrowski.pl/docs/salesforce/marketing-cloud-engagement/sql/sql-select/', officialDocs: 'https://learn.microsoft.com/en-us/sql/t-sql/queries/select-order-by-clause-transact-sql', sfmc: "Sorts Text-typed columns alphabetically, so '10' sorts before '2' and dates stored as text sort by characters, not chronologically; CONVERT to a numeric or date type first. Inside a subquery, derived table or CTE, ORDER BY is only allowed together with TOP." }, { name: 'ASC', category: 'modifier', description: 'Sorts in ascending order; the default direction for ORDER BY.', syntax: 'ORDER BY col ASC', example: 'ORDER BY DateJoined ASC', docs: 'https://mateuszdabrowski.pl/docs/salesforce/marketing-cloud-engagement/sql/sql-select/', officialDocs: 'https://learn.microsoft.com/en-us/sql/t-sql/queries/select-order-by-clause-transact-sql' }, { name: 'DESC', category: 'modifier', description: 'Sorts in descending order.', syntax: 'ORDER BY col DESC', example: 'ORDER BY DateJoined DESC', docs: 'https://mateuszdabrowski.pl/docs/salesforce/marketing-cloud-engagement/sql/sql-select/', officialDocs: 'https://learn.microsoft.com/en-us/sql/t-sql/queries/select-order-by-clause-transact-sql' }, { name: 'UNION', category: 'set', description: 'Combines the results of two queries into one result set, removing duplicate rows.', syntax: 'SELECT ... UNION SELECT ...', example: 'SELECT SubscriberKey FROM ListA UNION SELECT SubscriberKey FROM ListB', docs: 'https://mateuszdabrowski.pl/docs/salesforce/marketing-cloud-engagement/sql/sql-join/', officialDocs: 'https://learn.microsoft.com/en-us/sql/t-sql/language-elements/set-operators-union-transact-sql', sfmc: 'Both sides must return the same number of columns, in the same order, with compatible types. Removing duplicates costs a sort; use UNION ALL when duplicates are impossible or wanted.' }, { name: 'UNION ALL', category: 'set', description: 'Combines the results of two queries into one result set, keeping duplicate rows.', syntax: 'SELECT ... UNION ALL SELECT ...', example: "SELECT SubscriberKey, 'A' AS Src FROM ListA UNION ALL SELECT SubscriberKey, 'B' FROM ListB", docs: 'https://mateuszdabrowski.pl/docs/salesforce/marketing-cloud-engagement/sql/sql-join/', officialDocs: 'https://learn.microsoft.com/en-us/sql/t-sql/language-elements/set-operators-union-transact-sql', sfmc: 'Faster than UNION because it skips the duplicate-removal sort; duplicates only surface in the target Data Extension if it has no primary key on that value.' }, { name: 'EXCEPT', category: 'set', description: "Returns rows from the first query that do not appear in the second query's results.", syntax: 'SELECT ... EXCEPT SELECT ...', example: 'SELECT SubscriberKey FROM _ListSubscribers WHERE ListID = 123 EXCEPT SELECT SubscriberKey FROM _Unsubscribe', docs: 'https://mateuszdabrowski.pl/docs/salesforce/marketing-cloud-engagement/sql/sql-join/', officialDocs: 'https://learn.microsoft.com/en-us/sql/t-sql/language-elements/set-operators-except-and-intersect-transact-sql', sfmc: 'Both sides need the same number of columns, in the same order, with compatible types, as with UNION.' }, { name: 'INTERSECT', category: 'set', description: "Returns only the rows that appear in both queries' results.", syntax: 'SELECT ... INTERSECT SELECT ...', example: 'SELECT SubscriberKey FROM ListA INTERSECT SELECT SubscriberKey FROM ListB', docs: 'https://mateuszdabrowski.pl/docs/salesforce/marketing-cloud-engagement/sql/sql-join/', officialDocs: 'https://learn.microsoft.com/en-us/sql/t-sql/language-elements/set-operators-except-and-intersect-transact-sql', sfmc: 'Both sides need the same number of columns, in the same order, with compatible types, as with UNION.' }, { name: 'CASE', category: 'case', description: 'Evaluates a list of conditions and returns a value for the first one that matches.', syntax: 'CASE WHEN cond THEN val [WHEN ... THEN ...] [ELSE default] END', example: "SELECT SubscriberKey, CASE WHEN BounceCount = 0 THEN 'Clean' WHEN BounceCount < 5 THEN 'Watch' ELSE 'Risk' END AS Health FROM _Subscribers", docs: 'https://mateuszdabrowski.pl/docs/salesforce/marketing-cloud-engagement/sql/sql-case/', officialDocs: 'https://learn.microsoft.com/en-us/sql/t-sql/language-elements/case-transact-sql', sfmc: 'Returns the THEN value of the first WHEN condition that evaluates to TRUE, in top-to-bottom order; a CASE with no ELSE returns NULL for unmatched rows.' }, { name: 'WHEN', category: 'case', description: 'Introduces one condition (searched CASE) or one value to match (simple CASE) inside a CASE expression.', syntax: 'CASE WHEN condition THEN result', example: "CASE WHEN Status = 'bounced' THEN 1 ELSE 0 END", docs: 'https://mateuszdabrowski.pl/docs/salesforce/marketing-cloud-engagement/sql/sql-case/', officialDocs: 'https://learn.microsoft.com/en-us/sql/t-sql/language-elements/case-transact-sql' }, { name: 'THEN', category: 'case', description: 'Specifies the value to return when the preceding WHEN condition is true.', syntax: 'WHEN condition THEN result', example: "WHEN Status = 'bounced' THEN 1", docs: 'https://mateuszdabrowski.pl/docs/salesforce/marketing-cloud-engagement/sql/sql-case/', officialDocs: 'https://learn.microsoft.com/en-us/sql/t-sql/language-elements/case-transact-sql' }, { name: 'ELSE', category: 'case', description: 'Specifies the default value a CASE expression returns when no WHEN condition matched.', syntax: 'CASE WHEN ... THEN ... ELSE default END', example: "CASE WHEN Status = 'bounced' THEN 1 ELSE 0 END", docs: 'https://mateuszdabrowski.pl/docs/salesforce/marketing-cloud-engagement/sql/sql-case/', officialDocs: 'https://learn.microsoft.com/en-us/sql/t-sql/language-elements/case-transact-sql', sfmc: 'Omitting ELSE means unmatched rows get NULL instead of a default value; add ELSE to control what gets written to the target Data Extension.' }, { name: 'END', category: 'case', description: 'Closes a CASE expression.', syntax: 'CASE WHEN ... THEN ... END', example: "CASE WHEN Status = 'bounced' THEN 1 ELSE 0 END", docs: 'https://mateuszdabrowski.pl/docs/salesforce/marketing-cloud-engagement/sql/sql-case/', officialDocs: 'https://learn.microsoft.com/en-us/sql/t-sql/language-elements/case-transact-sql' }, { name: 'OVER', category: 'window', description: 'Defines the window (partition, order and frame) that a window or ranking function operates over.', syntax: 'function(...) OVER ([PARTITION BY col] [ORDER BY col] [frame])', example: 'SELECT SubscriberKey, ROW_NUMBER() OVER (PARTITION BY EmailAddress ORDER BY DateJoined DESC) AS rn FROM _Subscribers', docs: 'https://mateuszdabrowski.pl/docs/salesforce/marketing-cloud-engagement/sql/sql-window-functions/', officialDocs: 'https://learn.microsoft.com/en-us/sql/t-sql/queries/select-over-clause-transact-sql', sfmc: 'Common MCE dedup pattern: wrap the query in a subquery or CTE, then filter WHERE rn = 1 in the outer query to keep only the newest row per SubscriberKey.' }, { name: 'PARTITION BY', category: 'window', description: 'Divides the rows passed to a window function into groups; the function restarts for each group.', syntax: 'OVER (PARTITION BY col ORDER BY col)', example: 'SUM(BounceCount) OVER (PARTITION BY Domain)', docs: 'https://mateuszdabrowski.pl/docs/salesforce/marketing-cloud-engagement/sql/sql-window-functions/', officialDocs: 'https://learn.microsoft.com/en-us/sql/t-sql/queries/select-over-clause-transact-sql' }, { name: 'ROWS BETWEEN', category: 'window', description: 'Defines an explicit window frame (a range of rows relative to the current row) for a window function.', syntax: 'OVER (ORDER BY col ROWS BETWEEN start AND end)', example: 'SELECT EventDate, SUM(Amount) OVER (ORDER BY EventDate ROWS BETWEEN UNBOUNDED PRECEDING AND CURRENT ROW) AS RunningTotal FROM Orders_DE', docs: 'https://mateuszdabrowski.pl/docs/salesforce/marketing-cloud-engagement/sql/sql-window-functions/', officialDocs: 'https://learn.microsoft.com/en-us/sql/t-sql/queries/select-over-clause-transact-sql', sfmc: 'Useful for running totals inside a Query Activity.' }, { name: 'PIVOT', category: 'clause', description: 'Rotates unique values from one column into multiple output columns, aggregating as it goes.', syntax: 'SELECT ... FROM (source_query) AS src PIVOT (AGG(value_col) FOR pivot_col IN ([v1], [v2])) AS p', example: 'SELECT * FROM (SELECT Domain, DATENAME(MONTH, DateJoined) AS JoinMonth FROM _Subscribers) AS src PIVOT (COUNT(Domain) FOR JoinMonth IN ([January],[February])) AS p', docs: 'https://learn.microsoft.com/en-us/sql/t-sql/queries/from-using-pivot-and-unpivot', sfmc: 'The pivoted column list must be known and written out when you write the query.' }, { name: 'UNPIVOT', category: 'clause', description: 'Rotates multiple columns into rows, the reverse of PIVOT.', syntax: 'SELECT ... FROM source UNPIVOT (value_col FOR name_col IN (col1, col2)) AS u', example: 'SELECT SubscriberKey, ChannelName, IsOptedIn FROM Prefs_DE UNPIVOT (IsOptedIn FOR ChannelName IN (EmailOptIn, SMSOptIn)) AS u', docs: 'https://learn.microsoft.com/en-us/sql/t-sql/queries/from-using-pivot-and-unpivot' }, { name: 'OFFSET FETCH', category: 'clause', description: 'Pages a sorted result set by skipping a number of rows and returning a fixed number after that.', syntax: 'ORDER BY col OFFSET n ROWS FETCH NEXT m ROWS ONLY', example: 'SELECT SubscriberKey FROM _Subscribers ORDER BY DateJoined OFFSET 20 ROWS FETCH NEXT 10 ROWS ONLY', docs: 'https://learn.microsoft.com/en-us/sql/t-sql/queries/select-order-by-clause-transact-sql', sfmc: 'Requires ORDER BY. TOP is more common in Query Activities than OFFSET and FETCH.' }, { name: 'WITHIN GROUP', category: 'clause', description: 'Specifies the sort order used by an ordered-set aggregate, such as STRING_AGG, PERCENTILE_CONT or PERCENTILE_DISC.', syntax: 'STRING_AGG(expr, sep) WITHIN GROUP (ORDER BY col)', example: "SELECT Domain, STRING_AGG(CAST(SubscriberKey AS NVARCHAR(MAX)), ', ') WITHIN GROUP (ORDER BY DateJoined) AS Keys FROM _Subscribers GROUP BY Domain", docs: 'https://mateuszdabrowski.pl/docs/salesforce/marketing-cloud-engagement/sql/sql-window-functions/' }, { name: 'ALL', category: 'modifier', description: 'In a subquery comparison, requires the condition to be true for every value the subquery returns; also the default (non-DISTINCT) mode for COUNT/UNION.', syntax: 'expr > ALL (subquery)', example: 'WHERE BounceCount > ALL (SELECT AVG(BounceCount) FROM _Subscribers)', docs: 'https://learn.microsoft.com/en-us/sql/t-sql/language-elements/all-transact-sql' }, { name: 'ANY', category: 'modifier', description: 'In a subquery comparison, requires the condition to be true for at least one value the subquery returns.', syntax: 'expr = ANY (subquery)', example: 'WHERE SubscriberKey = ANY (SELECT SubscriberKey FROM _Open)', docs: 'https://learn.microsoft.com/en-us/sql/t-sql/language-elements/any-transact-sql' }, { name: 'SOME', category: 'modifier', description: 'Synonym for ANY in a subquery comparison.', syntax: 'expr = SOME (subquery)', example: 'WHERE SubscriberKey = SOME (SELECT SubscriberKey FROM _Open)', docs: 'https://learn.microsoft.com/en-us/sql/t-sql/language-elements/any-transact-sql' }, { name: 'COLLATE', category: 'modifier', description: 'Overrides the collation used to compare or sort a string expression.', syntax: 'expression COLLATE collation_name', example: "SELECT SubscriberKey FROM _Subscribers WHERE EmailAddress COLLATE Latin1_General_CS_AS LIKE '%@Example.com'", docs: 'https://mateuszdabrowski.pl/docs/salesforce/marketing-cloud-engagement/sql/sql-string-functions/', officialDocs: 'https://learn.microsoft.com/en-us/sql/t-sql/statements/collations', sfmc: "Text compares with the collation SQL_Latin1_General_CP1_CI_AS in MCE, so letter case is ignored and accents count, CHARINDEX included (org check, 2026-10-14). COLLATE works: 'abc' COLLATE Latin1_General_CS_AS = 'ABC' is false, and 'café' COLLATE Latin1_General_CI_AI = 'cafe' is true." }, { name: 'AT TIME ZONE', category: 'modifier', description: 'Attaches or converts a time zone offset on a datetime value, returning a datetimeoffset.', syntax: "datetime_expr AT TIME ZONE 'time_zone_name'", example: "SELECT EventDate AT TIME ZONE 'Central America Standard Time' AS EventDateOffset FROM _Open", docs: 'https://mateuszdabrowski.pl/docs/salesforce/marketing-cloud-engagement/sql/sql-date-functions/', officialDocs: 'https://learn.microsoft.com/en-us/sql/t-sql/queries/at-time-zone-transact-sql', sfmc: "MCE server time, GETDATE(), is UTC-6 all year, also during US daylight saving, though the server's Windows zone is Central Time (org check, 2026-10-14). AT TIME ZONE 'Central Standard Time' follows daylight saving, so it tags server time an hour off in summer: on 14 October 2026 it ended -05:00. 'Central America Standard Time' keeps -06:00 all year, as TODATETIMEOFFSET(x, '-06:00') does. The result is a datetimeoffset. CONVERT it before writing it to a Date field." }, { name: 'Ent.', category: 'reference', description: 'Prefix that reads a shared or parent Business Unit object from a child Business Unit context.', syntax: 'FROM Ent.TableName', example: 'SELECT SubscriberKey FROM Ent._Subscribers', docs: 'https://mateuszdabrowski.pl/docs/salesforce/marketing-cloud-engagement/sql/sql-from/', sfmc: "Required to read a shared/parent Data Extension, a Synchronized Data Extension, or a parent Business Unit's System Data View from a child Business Unit. Without it, _Subscribers and similar views return only the child Business Unit's own rows." }, { name: '*', category: 'reference', description: 'Selects every column of a table, or (inside COUNT) counts every row.', syntax: 'SELECT * FROM table | SELECT alias.* FROM table AS alias | COUNT(*)', example: 'SELECT COUNT(*) AS TotalRows FROM _Subscribers', docs: 'https://mateuszdabrowski.pl/docs/salesforce/marketing-cloud-engagement/sql/sql-select/', officialDocs: 'https://learn.microsoft.com/en-us/sql/t-sql/queries/select-clause-transact-sql', sfmc: 'Query Studio does not support SELECT * for column output, and a Query Activity that combines SELECT * with any JOIN fails validation. SELECT * also freezes its column list at save time, so new Data Extension columns are silently ignored later. COUNT(*) itself is unaffected and always allowed.' }, { name: '/* Comment */', category: 'comment', description: 'Block comment; text between /* and */ is ignored by the SQL engine.', syntax: '/* comment text */', example: 'SELECT SubscriberKey /* primary identifier */ FROM _Subscribers', docs: 'https://help.salesforce.com/s/articleView?id=mktg.mc_as_sql_reference.htm', officialDocs: 'https://learn.microsoft.com/en-us/sql/t-sql/language-elements/slash-star-comment-transact-sql', sfmc: 'Block comments work anywhere, before SELECT too, and words such as delete in them are fine (org check, 2026-09-28). A -- line comment passes Validate, but the save of a Query Activity loses everything after it: the rest of the query, or the whole query where the comment comes before SELECT, which answers Cannot find table 0 (the author\'s org, 2026-10-15). Write comments as /* */, which lint\'s MCE100 does for you. An unpaired apostrophe in a comment is not fine either: Validate passes it, but the run then fails with Error while processing square brackets, or, with one in another comment too, runs with its text values changed. A pair in one comment is fine.' }, ], functions: [ { name: 'LEN', category: 'string', signature: 'LEN(string_expression)', returns: 'int (bigint if the input is varchar(max)/nvarchar(max))', description: 'Returns the number of characters in a string, excluding trailing blanks.', example: 'SELECT SubscriberKey FROM _Subscribers WHERE LEN(SubscriberKey) != 18', docs: 'https://mateuszdabrowski.pl/docs/salesforce/marketing-cloud-engagement/sql/sql-string-functions/', officialDocs: 'https://learn.microsoft.com/en-us/sql/t-sql/functions/len-transact-sql' }, { name: 'LEFT', category: 'string', signature: 'LEFT(character_expression, integer_expression)', returns: 'varchar or nvarchar', description: 'Returns the leftmost specified number of characters of a string.', example: 'SELECT LEFT(EmailAddress, 1) AS FirstLetter FROM _Subscribers', docs: 'https://mateuszdabrowski.pl/docs/salesforce/marketing-cloud-engagement/sql/sql-string-functions/', officialDocs: 'https://learn.microsoft.com/en-us/sql/t-sql/functions/left-transact-sql' }, { name: 'RIGHT', category: 'string', signature: 'RIGHT(character_expression, integer_expression)', returns: 'varchar or nvarchar', description: 'Returns the rightmost specified number of characters of a string.', example: 'SELECT RIGHT(EmailAddress, 4) FROM _Subscribers', docs: 'https://mateuszdabrowski.pl/docs/salesforce/marketing-cloud-engagement/sql/sql-string-functions/', officialDocs: 'https://learn.microsoft.com/en-us/sql/t-sql/functions/right-transact-sql' }, { name: 'SUBSTRING', category: 'string', signature: 'SUBSTRING(expression, start, length)', returns: 'varchar or nvarchar', description: 'Returns part of a string, starting at a 1-based position, for a given length.', example: "SELECT SUBSTRING(EmailAddress, 1, CHARINDEX('@', EmailAddress) - 1) AS LocalPart FROM _Subscribers", docs: 'https://learn.microsoft.com/en-us/sql/t-sql/functions/substring-transact-sql', sfmc: 'String functions in MCE SQL are 1-based, like standard T-SQL.' }, { name: 'CHARINDEX', category: 'string', signature: 'CHARINDEX(expressionToFind, expressionToSearch [, start_location])', returns: 'int (bigint for very large inputs); 0 if not found, NULL if either input is NULL', description: 'Returns the starting position of the first occurrence of a substring inside a string.', example: "SELECT SubscriberKey FROM _Subscribers WHERE CHARINDEX('@gmail.com', EmailAddress) > 0", docs: 'https://mateuszdabrowski.pl/docs/salesforce/marketing-cloud-engagement/sql/sql-string-functions/', officialDocs: 'https://learn.microsoft.com/en-us/sql/t-sql/functions/charindex-transact-sql', sfmc: 'Case-insensitive by default; add COLLATE Latin1_General_CS_AS to the searched expression for a case-sensitive match.' }, { name: 'PATINDEX', category: 'string', signature: "PATINDEX('%pattern%', expression)", returns: 'int', description: 'Returns the starting position of the first match of a LIKE-style wildcard pattern inside a string.', example: "SELECT PATINDEX('%[0-9]%', SubscriberKey) FROM _Subscribers", docs: 'https://mateuszdabrowski.pl/docs/salesforce/marketing-cloud-engagement/sql/sql-string-functions/', officialDocs: 'https://learn.microsoft.com/en-us/sql/t-sql/functions/patindex-transact-sql', sfmc: 'Like CHARINDEX but supports the same wildcard patterns as LIKE (%, _, [], [^]).' }, { name: 'REPLACE', category: 'string', signature: 'REPLACE(string_expression, string_pattern, string_replacement)', returns: 'varchar or nvarchar', description: 'Replaces every occurrence of a substring with another substring.', example: "SELECT REPLACE(MobileNumber, '-', '') AS CleanNumber FROM _MobileAddress", docs: 'https://learn.microsoft.com/en-us/sql/t-sql/functions/replace-transact-sql' }, { name: 'STUFF', category: 'string', signature: 'STUFF(character_expression, start, length, replaceWith_expression)', returns: 'varchar or nvarchar', description: 'Deletes a specified length of characters starting at a position, then inserts a replacement string at that position.', example: "SELECT STUFF(MobileNumber, 1, 2, '+48') FROM _MobileAddress", docs: 'https://learn.microsoft.com/en-us/sql/t-sql/functions/stuff-transact-sql' }, { name: 'CONCAT', category: 'string', signature: 'CONCAT(string1, string2 [, string3, ...])', returns: 'varchar or nvarchar', description: 'Joins two or more strings together, implicitly converting non-string arguments and treating NULL as an empty string.', example: "SELECT CONCAT(FirstName, ' ', LastName) AS FullName FROM Contacts_DE", docs: 'https://mateuszdabrowski.pl/docs/salesforce/marketing-cloud-engagement/sql/sql-string-functions/', officialDocs: 'https://learn.microsoft.com/en-us/sql/t-sql/functions/concat-transact-sql', sfmc: "Unlike the + operator, CONCAT treats NULL as an empty text. With +, 'abc' + NULL is NULL, as CONCAT_NULL_YIELDS_NULL is on in MCE (org check, 2026-10-14)." }, { name: 'CONCAT_WS', category: 'string', signature: 'CONCAT_WS(separator, string1, string2 [, ...])', returns: 'varchar or nvarchar', description: 'Joins two or more strings with a separator between each, skipping NULL arguments entirely.', example: "SELECT CONCAT_WS(', ', City, State, ZipCode) AS Address FROM Contacts_DE", docs: 'https://learn.microsoft.com/en-us/sql/t-sql/functions/concat-ws-transact-sql', sfmc: 'Added in SQL Server 2017. MCE runs SQL Server 2022 (org check, 2026-09-28).' }, { name: 'LTRIM', category: 'string', signature: 'LTRIM(character_expression [, characters])', returns: 'varchar or nvarchar', description: 'Removes leading spaces from a string, or the characters given as its second argument.', example: 'SELECT LTRIM(FormInput) FROM Webform_DE', docs: 'https://mateuszdabrowski.pl/docs/salesforce/marketing-cloud-engagement/sql/sql-string-functions/', officialDocs: 'https://learn.microsoft.com/en-us/sql/t-sql/functions/ltrim-transact-sql', sfmc: "The characters argument, LTRIM('xxabc', 'x') for abc, needs database compatibility level 160, which MCE runs: it validated and ran (org check, 2026-10-14)." }, { name: 'RTRIM', category: 'string', signature: 'RTRIM(character_expression [, characters])', returns: 'varchar or nvarchar', description: 'Removes trailing spaces from a string, or the characters given as its second argument.', example: 'SELECT RTRIM(FormInput) FROM Webform_DE', docs: 'https://mateuszdabrowski.pl/docs/salesforce/marketing-cloud-engagement/sql/sql-string-functions/', officialDocs: 'https://learn.microsoft.com/en-us/sql/t-sql/functions/rtrim-transact-sql', sfmc: "The characters argument, RTRIM('abcxx', 'x') for abc, needs database compatibility level 160, which MCE runs: it validated and ran (org check, 2026-10-14)." }, { name: 'TRIM', category: 'string', signature: 'TRIM(string)', returns: 'varchar or nvarchar', description: 'Removes leading and trailing spaces (or a specified set of characters) from a string.', example: 'SELECT TRIM(FormInput) FROM Webform_DE', docs: 'https://mateuszdabrowski.pl/docs/salesforce/marketing-cloud-engagement/sql/sql-string-functions/', officialDocs: 'https://learn.microsoft.com/en-us/sql/t-sql/functions/trim-transact-sql', sfmc: 'TRIM(text) strips spaces and works. Its FROM forms, TRIM(\'x\' FROM text) and TRIM(LEADING \'0\' FROM text), fail the query check, which reads that FROM as a table (org check, 2026-09-28). Use LTRIM and RTRIM instead, with the characters to cut as their second argument, LTRIM(x, \'0\'), which MCE runs at its database level 160 (org check, 2026-10-14).' }, { name: 'LOWER', category: 'string', signature: 'LOWER(character_expression)', returns: 'varchar or nvarchar', description: 'Converts a string to lowercase.', example: 'SELECT LOWER(EmailAddress) FROM _Subscribers', docs: 'https://mateuszdabrowski.pl/docs/salesforce/marketing-cloud-engagement/sql/sql-string-functions/', officialDocs: 'https://learn.microsoft.com/en-us/sql/t-sql/functions/lower-transact-sql', sfmc: 'Comparisons in MCE are already case-insensitive, so wrapping a WHERE column in LOWER/UPPER is usually unnecessary and can prevent index use.' }, { name: 'UPPER', category: 'string', signature: 'UPPER(character_expression)', returns: 'varchar or nvarchar', description: 'Converts a string to uppercase.', example: 'SELECT UPPER(SubscriberType) FROM _Subscribers', docs: 'https://mateuszdabrowski.pl/docs/salesforce/marketing-cloud-engagement/sql/sql-string-functions/', officialDocs: 'https://learn.microsoft.com/en-us/sql/t-sql/functions/upper-transact-sql' }, { name: 'REVERSE', category: 'string', signature: 'REVERSE(string_expression)', returns: 'varchar or nvarchar', description: 'Reverses the order of characters in a string.', example: 'SELECT REVERSE(SubscriberKey) FROM _Subscribers', docs: 'https://learn.microsoft.com/en-us/sql/t-sql/functions/reverse-transact-sql' }, { name: 'SPACE', category: 'string', signature: 'SPACE(integer_expression)', returns: 'varchar', description: 'Returns a string made of the specified number of spaces.', example: 'SELECT FirstName + SPACE(1) + LastName AS FullName FROM Contacts_DE', docs: 'https://learn.microsoft.com/en-us/sql/t-sql/functions/space-transact-sql' }, { name: 'REPLICATE', category: 'string', signature: 'REPLICATE(string_expression, integer_expression)', returns: 'varchar or nvarchar', description: 'Repeats a string a specified number of times.', example: "SELECT REPLICATE('0', 5 - LEN(ZipCode)) + ZipCode AS PaddedZip FROM Contacts_DE", docs: 'https://learn.microsoft.com/en-us/sql/t-sql/functions/replicate-transact-sql' }, { name: 'TRANSLATE', category: 'string', signature: 'TRANSLATE(inputString, characters, translations)', returns: 'varchar or nvarchar', description: 'Replaces each character found in the characters argument with the character at the same position in translations.', example: "SELECT TRANSLATE(Phone, '() -', ' ') FROM _MobileAddress", docs: 'https://learn.microsoft.com/en-us/sql/t-sql/functions/translate-transact-sql' }, { name: 'QUOTENAME', category: 'string', signature: 'QUOTENAME(character_expression [, quote_character])', returns: 'nvarchar(258)', description: 'Wraps a string in delimiters (brackets by default) to make it a valid SQL Server identifier.', example: 'SELECT QUOTENAME(ColumnName) FROM MetadataList_DE', docs: 'https://learn.microsoft.com/en-us/sql/t-sql/functions/quotename-transact-sql' }, { name: 'STR', category: 'string', signature: 'STR(float_expression [, length [, decimal]])', returns: 'char', description: 'Converts a numeric value to a right-justified character string.', example: 'SELECT STR(BounceCount, 5) FROM _Subscribers', docs: 'https://learn.microsoft.com/en-us/sql/t-sql/functions/str-transact-sql' }, { name: 'UNICODE', category: 'string', signature: "UNICODE('character_expression')", returns: 'int', description: 'Returns the integer Unicode code point of the first character of the input string.', example: 'SELECT UNICODE(LEFT(EmailAddress, 1)) FROM _Subscribers', docs: 'https://learn.microsoft.com/en-us/sql/t-sql/functions/unicode-transact-sql' }, { name: 'ASCII', category: 'string', signature: 'ASCII(character_expression)', returns: 'int', description: 'Returns the ASCII code value of the leftmost character of a string.', example: 'SELECT ASCII(LEFT(SubscriberKey, 1)) FROM _Subscribers', docs: 'https://learn.microsoft.com/en-us/sql/t-sql/functions/ascii-transact-sql' }, { name: 'CHAR', category: 'string', signature: 'CHAR(integer_code)', returns: 'char(1)', description: 'Converts an ASCII integer code into its character.', example: 'SELECT FirstName + CHAR(10) + LastName FROM Contacts_DE', docs: 'https://learn.microsoft.com/en-us/sql/t-sql/functions/char-transact-sql' }, { name: 'NCHAR', category: 'string', signature: 'NCHAR(integer_code)', returns: 'nchar(1)', description: 'Converts a Unicode integer code point into its character.', example: 'SELECT NCHAR(233) AS AccentedE', docs: 'https://learn.microsoft.com/en-us/sql/t-sql/functions/nchar-transact-sql' }, { name: 'STRING_ESCAPE', category: 'string', signature: 'STRING_ESCAPE(text, type)', returns: 'nvarchar', description: 'Escapes special characters in a string for a given output format.', example: "SELECT STRING_ESCAPE(Notes, 'json') FROM Contacts_DE", docs: 'https://learn.microsoft.com/en-us/sql/t-sql/functions/string-escape-transact-sql', sfmc: "The type argument currently only supports 'json'." }, { name: 'SOUNDEX', category: 'string', signature: 'SOUNDEX(character_expression)', returns: 'char(4)', description: 'Returns a four-character code representing how a string sounds when spoken, for fuzzy name matching.', example: "SELECT * FROM Contacts_DE WHERE SOUNDEX(LastName) = SOUNDEX('Smith')", docs: 'https://learn.microsoft.com/en-us/sql/t-sql/functions/soundex-transact-sql' }, { name: 'DIFFERENCE', category: 'string', signature: 'DIFFERENCE(character_expression, character_expression)', returns: 'int (0-4)', description: 'Returns how similar the SOUNDEX values of two strings are, from 0 (no similarity) to 4 (identical).', example: "SELECT DIFFERENCE(LastName, 'Smith') FROM Contacts_DE", docs: 'https://learn.microsoft.com/en-us/sql/t-sql/functions/difference-transact-sql' }, { name: 'GETDATE', category: 'date', signature: 'GETDATE()', returns: 'datetime', description: 'Returns the current date and time.', example: 'SELECT GETDATE() AS Now', docs: 'https://mateuszdabrowski.pl/docs/salesforce/marketing-cloud-engagement/sql/sql-date-functions/', officialDocs: 'https://learn.microsoft.com/en-us/sql/t-sql/functions/getdate-transact-sql', sfmc: 'Returns MCE server time, UTC-6 all year, never moved for daylight saving: on 14 October 2026, in US daylight saving, it was 360 minutes behind GETUTCDATE() (org check, 2026-10-14).' }, { name: 'GETUTCDATE', category: 'date', signature: 'GETUTCDATE()', returns: 'datetime', description: 'Returns the current date and time in UTC.', example: 'SELECT GETUTCDATE() AS NowUtc', docs: 'https://mateuszdabrowski.pl/docs/salesforce/marketing-cloud-engagement/sql/sql-date-functions/', officialDocs: 'https://learn.microsoft.com/en-us/sql/t-sql/functions/getutcdate-transact-sql', sfmc: 'Use this instead of GETDATE() for a timezone-independent timestamp; it still carries no DST logic since MCE server time never observes DST anyway.' }, { name: 'SYSDATETIME', category: 'date', signature: 'SYSDATETIME()', returns: 'datetime2(7)', description: 'Returns the current date and time with fractional-second precision.', example: 'SELECT SYSDATETIME() AS PreciseNow', docs: 'https://learn.microsoft.com/en-us/sql/t-sql/functions/sysdatetime-transact-sql' }, { name: 'SYSUTCDATETIME', category: 'date', signature: 'SYSUTCDATETIME()', returns: 'datetime2(7)', description: 'Returns the current UTC date and time with fractional-second precision.', example: 'SELECT SYSUTCDATETIME() AS PreciseNowUtc', docs: 'https://learn.microsoft.com/en-us/sql/t-sql/functions/sysutcdatetime-transact-sql' }, { name: 'SYSDATETIMEOFFSET', category: 'date', signature: 'SYSDATETIMEOFFSET()', returns: 'datetimeoffset(7)', description: "Returns the current date and time along with the server's time zone offset.", example: 'SELECT SYSDATETIMEOFFSET() AS NowWithOffset', docs: 'https://mateuszdabrowski.pl/docs/salesforce/marketing-cloud-engagement/sql/sql-date-functions/', officialDocs: 'https://learn.microsoft.com/en-us/sql/t-sql/functions/sysdatetimeoffset-transact-sql', sfmc: 'Ends with -06:00 all year in MCE, as the server clock keeps UTC-6, also during US daylight saving (org check, 2026-10-14).' }, { name: 'DATEADD', category: 'date', signature: 'DATEADD(datepart, number, date)', returns: 'same type as date', description: 'Adds (or subtracts, with a negative number) an interval to a date.', example: 'SELECT DATEADD(DAY, -7, GETDATE()) AS OneWeekAgo', docs: 'https://mateuszdabrowski.pl/docs/salesforce/marketing-cloud-engagement/sql/sql-date-functions/', officialDocs: 'https://learn.microsoft.com/en-us/sql/t-sql/functions/dateadd-transact-sql', sfmc: 'Does not account for time zone offsets; it operates on the raw date value.' }, { name: 'DATEDIFF', category: 'date', signature: 'DATEDIFF(datepart, startdate, enddate)', returns: 'int', description: 'Returns the number of datepart boundaries crossed between two dates.', example: 'SELECT SubscriberKey, DATEDIFF(DAY, DateJoined, GETDATE()) AS DaysSinceJoin FROM _Subscribers', docs: 'https://mateuszdabrowski.pl/docs/salesforce/marketing-cloud-engagement/sql/sql-date-functions/', officialDocs: 'https://learn.microsoft.com/en-us/sql/t-sql/functions/datediff-transact-sql', sfmc: 'Wrapping a data-view column in DATEDIFF inside WHERE/ON is non-SARGable and forces a full scan; compare against DATEADD(...) on the raw column instead. With SECOND or MILLISECOND, the result overflows int after about 68 years; use DATEDIFF_BIG for very large spans.' }, { name: 'DATEDIFF_BIG', category: 'date', signature: 'DATEDIFF_BIG(datepart, startdate, enddate)', returns: 'bigint', description: 'Same as DATEDIFF but returns a bigint, avoiding int overflow for very large second/millisecond spans.', example: "SELECT DATEDIFF_BIG(MILLISECOND, '2000-01-01', GETDATE())", docs: 'https://learn.microsoft.com/en-us/sql/t-sql/functions/datediff-big-transact-sql' }, { name: 'DATEPART', category: 'date', signature: 'DATEPART(datepart, date)', returns: 'int', description: 'Returns a single part of a date (year, month, day, etc.) as an integer.', example: 'SELECT DATEPART(YEAR, DateJoined) AS JoinYear FROM _Subscribers', docs: 'https://mateuszdabrowski.pl/docs/salesforce/marketing-cloud-engagement/sql/sql-date-functions/', officialDocs: 'https://learn.microsoft.com/en-us/sql/t-sql/functions/datepart-transact-sql', sfmc: "The session's DATEFIRST is 7, so DATEPART(WEEKDAY, x) gives 1 for Sunday, and DATEPART(WEEK, '2026-01-04') gives 2 where ISO_WEEK gives 1 (org check, 2026-10-14). Use full date part names, YEAR rather than yy, as the MCE SQL style guide does." }, { name: 'DATENAME', category: 'date', signature: 'DATENAME(datepart, date)', returns: 'nvarchar', description: 'Returns a single part of a date as its string name.', example: 'SELECT DATENAME(WEEKDAY, DateJoined) AS JoinWeekday FROM _Subscribers', docs: 'https://mateuszdabrowski.pl/docs/salesforce/marketing-cloud-engagement/sql/sql-date-functions/', officialDocs: 'https://learn.microsoft.com/en-us/sql/t-sql/functions/datename-transact-sql', sfmc: 'The session language is us_english, so the names are English: Sunday, October (org check, 2026-10-14). The result is text, so use DATEPART for comparisons and arithmetic.' }, { name: 'DAY', category: 'date', signature: 'DAY(date)', returns: 'int', description: 'Shorthand for DATEPART(DAY, date).', example: 'SELECT DAY(DateJoined) FROM _Subscribers', docs: 'https://mateuszdabrowski.pl/docs/salesforce/marketing-cloud-engagement/sql/sql-date-functions/', officialDocs: 'https://learn.microsoft.com/en-us/sql/t-sql/functions/day-transact-sql' }, { name: 'MONTH', category: 'date', signature: 'MONTH(date)', returns: 'int', description: 'Shorthand for DATEPART(MONTH, date).', example: 'SELECT MONTH(DateJoined) FROM _Subscribers', docs: 'https://mateuszdabrowski.pl/docs/salesforce/marketing-cloud-engagement/sql/sql-date-functions/', officialDocs: 'https://learn.microsoft.com/en-us/sql/t-sql/functions/month-transact-sql' }, { name: 'YEAR', category: 'date', signature: 'YEAR(date)', returns: 'int', description: 'Shorthand for DATEPART(YEAR, date).', example: 'SELECT YEAR(DateJoined) FROM _Subscribers', docs: 'https://mateuszdabrowski.pl/docs/salesforce/marketing-cloud-engagement/sql/sql-date-functions/', officialDocs: 'https://learn.microsoft.com/en-us/sql/t-sql/functions/year-transact-sql' }, { name: 'EOMONTH', category: 'date', signature: 'EOMONTH(start_date [, month_to_add])', returns: 'date', description: 'Returns the last day of the month for a given date, optionally after adding a number of months.', example: 'SELECT EOMONTH(GETDATE()) AS LastDayOfMonth', docs: 'https://learn.microsoft.com/en-us/sql/t-sql/functions/eomonth-transact-sql' }, { name: 'DATEFROMPARTS', category: 'date', signature: 'DATEFROMPARTS(year, month, day)', returns: 'date', description: 'Builds a date value from separate year, month and day integers.', example: 'SELECT DATEFROMPARTS(2026, 1, 31) AS CampaignDate', docs: 'https://mateuszdabrowski.pl/docs/salesforce/marketing-cloud-engagement/sql/sql-date-functions/', officialDocs: 'https://learn.microsoft.com/en-us/sql/t-sql/functions/datefromparts-transact-sql', sfmc: 'Returns NULL if any part is NULL, rather than an error.' }, { name: 'DATETIMEFROMPARTS', category: 'date', signature: 'DATETIMEFROMPARTS(year, month, day, hour, minute, seconds, milliseconds)', returns: 'datetime', description: 'Builds a datetime value from separate integer components.', example: 'SELECT DATETIMEFROMPARTS(2026, 1, 31, 23, 59, 59, 0) AS CampaignEnd', docs: 'https://mateuszdabrowski.pl/docs/salesforce/marketing-cloud-engagement/sql/sql-date-functions/', officialDocs: 'https://learn.microsoft.com/en-us/sql/t-sql/functions/datetimefromparts-transact-sql' }, { name: 'DATETIME2FROMPARTS', category: 'date', signature: 'DATETIME2FROMPARTS(year, month, day, hour, minute, seconds, fractions, precision)', returns: 'datetime2', description: 'Builds a datetime2 value (higher precision than DATETIMEFROMPARTS) from separate integer components.', example: 'SELECT DATETIME2FROMPARTS(2026, 1, 31, 23, 59, 59, 0, 0) AS CampaignEnd', docs: 'https://learn.microsoft.com/en-us/sql/t-sql/functions/datetime2fromparts-transact-sql' }, { name: 'TIMEFROMPARTS', category: 'date', signature: 'TIMEFROMPARTS(hour, minute, seconds, fractions, precision)', returns: 'time', description: 'Builds a time-of-day value from separate integer components.', example: 'SELECT TIMEFROMPARTS(9, 30, 0, 0, 0) AS SendTime', docs: 'https://mateuszdabrowski.pl/docs/salesforce/marketing-cloud-engagement/sql/sql-date-functions/', officialDocs: 'https://learn.microsoft.com/en-us/sql/t-sql/functions/timefromparts-transact-sql', sfmc: 'Of limited use in MCE since most Data Extension date fields store a full datetime, not a bare time value.' }, { name: 'ISDATE', category: 'date', signature: 'ISDATE(expression)', returns: 'int (1 or 0)', description: 'Returns 1 if a string is a valid date, time or datetime value, otherwise 0.', example: 'SELECT * FROM ImportedRows_DE WHERE ISDATE(RawDate) = 1', docs: 'https://learn.microsoft.com/en-us/sql/t-sql/functions/isdate-transact-sql' }, { name: 'TODATETIMEOFFSET', category: 'date', signature: 'TODATETIMEOFFSET(datetime_expr, time_zone_offset)', returns: 'datetimeoffset', description: 'Attaches a fixed time zone offset to a date/time value, without applying any Daylight Saving Time logic.', example: "SELECT TODATETIMEOFFSET(GETDATE(), '-06:00') AS ServerTimeWithOffset", docs: 'https://mateuszdabrowski.pl/docs/salesforce/marketing-cloud-engagement/sql/sql-date-functions/', officialDocs: 'https://learn.microsoft.com/en-us/sql/t-sql/functions/todatetimeoffset-transact-sql', sfmc: "The preferred way to attach MCE's fixed Central Standard Time offset, since AT TIME ZONE 'Central Standard Time' would incorrectly apply real DST rules." }, { name: 'SWITCHOFFSET', category: 'date', signature: 'SWITCHOFFSET(datetimeoffset_expr, time_zone_offset)', returns: 'datetimeoffset', description: 'Converts a datetimeoffset value to a different time zone offset, keeping the same point in time.', example: "SELECT SWITCHOFFSET(SYSDATETIMEOFFSET(), '+00:00') AS AsUtcOffset", docs: 'https://learn.microsoft.com/en-us/sql/t-sql/functions/switchoffset-transact-sql' }, { name: 'CURRENT_TIMEZONE', category: 'date', signature: 'CURRENT_TIMEZONE()', returns: 'varchar', description: "Returns the name of the server's time zone.", example: 'SELECT CURRENT_TIMEZONE() AS ServerTimeZone', docs: 'https://learn.microsoft.com/en-us/sql/t-sql/functions/current-timezone-transact-sql', sfmc: 'Returns "(UTC-06:00) Central Time (US & Canada)" in MCE, while the server clock keeps UTC-6 all year and never moves for daylight saving (org check, 2026-10-14).' }, { name: 'DATETRUNC', category: 'date', signature: 'DATETRUNC(datepart, date)', returns: 'the type of date', description: 'Returns the date cut down to the start of its date part, such as the first day of its month.', example: 'SELECT SubscriberKey, DATETRUNC(MONTH, DateJoined) AS JoinMonth FROM _Subscribers', docs: 'https://learn.microsoft.com/en-us/sql/t-sql/functions/datetrunc-transact-sql', sfmc: 'Added in SQL Server 2022. Validate accepted it with MONTH and WEEK in MCE (org check, 2026-10-14). A WEEK starts on Sunday, as DATEFIRST is 7.' }, { name: 'DATE_BUCKET', category: 'date', signature: 'DATE_BUCKET(datepart, number, date [, origin])', returns: 'the type of date', description: 'Returns the start of the bucket of number date parts that holds the date, counted from the origin, 1900-01-01 by default.', example: 'SELECT SubscriberKey, DATE_BUCKET(WEEK, 1, DateJoined) AS JoinWeek FROM _Subscribers', docs: 'https://learn.microsoft.com/en-us/sql/t-sql/functions/date-bucket-transact-sql', sfmc: 'Added in SQL Server 2022. Validate accepted it in MCE (org check, 2026-10-14).' }, { name: 'CAST', category: 'conversion', signature: 'CAST(expression AS data_type)', returns: 'the target data_type', description: 'Converts an expression from one data type to another, using ANSI standard syntax.', example: 'SELECT CAST(BounceCount AS VARCHAR(10)) FROM _Subscribers', docs: 'https://mateuszdabrowski.pl/docs/salesforce/marketing-cloud-engagement/sql/sql-conversion-functions/', officialDocs: 'https://learn.microsoft.com/en-us/sql/t-sql/functions/cast-and-convert-transact-sql', sfmc: 'CONVERT is generally preferred in MCE: Query Studio has been reported to reject CAST in some SELECT list positions, and CONVERT additionally accepts style codes for date formatting.' }, { name: 'CONVERT', category: 'conversion', signature: 'CONVERT(data_type, expression [, style])', returns: 'the target data_type', description: 'Converts an expression from one data type to another, with an optional style code that controls date/number formatting.', example: 'SELECT CONVERT(VARCHAR(10), GETDATE(), 101) AS TodayUS', docs: 'https://mateuszdabrowski.pl/docs/salesforce/marketing-cloud-engagement/sql/sql-conversion-functions/', officialDocs: 'https://learn.microsoft.com/en-us/sql/t-sql/functions/cast-and-convert-transact-sql', sfmc: "The session reads date text as month, day, year, so CONVERT(DATETIME, '01/02/2026') and CAST('01/02/2026' AS DATETIME) are 2 January, and style 103 reads it day first, 1 February (org check, 2026-10-14). Write a date as 'yyyymmdd' to be safe. A datetime as text with no style reads 'Oct 14 2026 1:45PM'. Converting to INT cuts the decimals, 6.9 to 6, and DECIMAL with no precision and scale rounds, 6.9 to 7, so write DECIMAL(p, s). FLOAT and REAL are approximate and misbehave in WHERE comparisons." }, { name: 'TRY_CAST', category: 'conversion', signature: 'TRY_CAST(expression AS data_type)', returns: 'the target data_type, or NULL on failure', description: 'Same as CAST, but returns NULL instead of raising an error when the conversion fails.', example: 'SELECT TRY_CAST(RawValue AS INT) FROM ImportedRows_DE', docs: 'https://learn.microsoft.com/en-us/sql/t-sql/functions/try-cast-transact-sql' }, { name: 'TRY_CONVERT', category: 'conversion', signature: 'TRY_CONVERT(data_type, expression [, style])', returns: 'the target data_type, or NULL on failure', description: 'Same as CONVERT, but returns NULL instead of raising an error when the conversion fails.', example: 'SELECT TRY_CONVERT(DATE, RawDate, 103) FROM ImportedRows_DE', docs: 'https://learn.microsoft.com/en-us/sql/t-sql/functions/try-convert-transact-sql' }, { name: 'PARSE', category: 'conversion', signature: 'PARSE(string_value AS data_type [USING culture])', returns: 'the target data_type', description: 'Converts a culture-formatted string (e.g. a localized date) into a date, time or numeric type.', example: "SELECT PARSE('31 January 2026' AS DATE USING 'en-US') AS ParsedDate", docs: 'https://learn.microsoft.com/en-us/sql/t-sql/functions/parse-transact-sql', sfmc: "Runs in MCE, as the CLR is there: PARSE('October 14, 2026' AS DATE USING 'en-US') gave 2026-10-14 (org check, 2026-10-14). The culture is en-US when USING is left out. It is slower than CONVERT on large tables, so prefer CONVERT with a style when the format is fixed." }, { name: 'TRY_PARSE', category: 'conversion', signature: 'TRY_PARSE(string_value AS data_type [USING culture])', returns: 'the target data_type, or NULL on failure', description: 'Same as PARSE, but returns NULL instead of raising an error when the conversion fails.', example: "SELECT TRY_PARSE(RawDate AS DATE USING 'en-GB') FROM ImportedRows_DE", docs: 'https://learn.microsoft.com/en-us/sql/t-sql/functions/try-parse-transact-sql', sfmc: "Runs in MCE: TRY_PARSE('not a date' AS DATE) gave NULL (org check, 2026-10-14). The culture is en-US when USING is left out. It is as slow as PARSE on large tables." }, { name: 'FORMAT', category: 'conversion', signature: 'FORMAT(value, format_specifier [, culture])', returns: 'nvarchar', description: 'Formats a date or numeric value into a string using .NET-style format strings.', example: "SELECT FORMAT(GETUTCDATE(), 'dd/MM/yyyy') AS EuroDate", docs: 'https://mateuszdabrowski.pl/docs/salesforce/marketing-cloud-engagement/sql/sql-date-functions/', officialDocs: 'https://learn.microsoft.com/en-us/sql/t-sql/functions/format-transact-sql', sfmc: "Runs in MCE, as the CLR is there: FORMAT(1234567.891, 'N2') gave 1,234,567.89, as the session culture is en-US, and with 'pl-PL' it gave 1 234 567,89 (org check, 2026-10-14). It is slow on large tables, and it takes dates and numbers, so CONVERT a text column first." }, { name: 'IIF', category: 'logical', signature: 'IIF(boolean_expression, true_value, false_value)', returns: 'the common type of true_value/false_value', description: 'Shorthand for a two-branch CASE expression.', example: "SELECT SubscriberKey, IIF(BounceCount > 0, 'Has Bounces', 'Clean') AS BounceFlag FROM _Subscribers", docs: 'https://mateuszdabrowski.pl/docs/salesforce/marketing-cloud-engagement/sql/sql-case/', officialDocs: 'https://learn.microsoft.com/en-us/sql/t-sql/functions/logical-functions-iif-transact-sql' }, { name: 'CHOOSE', category: 'logical', signature: 'CHOOSE(index, val1, val2 [, val3, ...])', returns: 'the common type of the value list', description: 'Returns the value at the given 1-based position in a list.', example: "SELECT CHOOSE(DATEPART(MONTH, GETDATE()), 'Jan','Feb','Mar','Apr','May','Jun','Jul','Aug','Sep','Oct','Nov','Dec') AS MonthName", docs: 'https://learn.microsoft.com/en-us/sql/t-sql/functions/logical-functions-choose-transact-sql' }, { name: 'ISNUMERIC', category: 'logical', signature: 'ISNUMERIC(expression)', returns: 'int (1 or 0)', description: 'Returns 1 if an expression can be evaluated as a numeric type, otherwise 0.', example: 'SELECT * FROM ImportedRows_DE WHERE ISNUMERIC(RawAmount) = 1', docs: 'https://learn.microsoft.com/en-us/sql/t-sql/functions/isnumeric-transact-sql', sfmc: "Known quirk: ISNUMERIC also returns 1 for standalone '+', '-', '.', and ',' characters, which are not actually convertible numbers; validate with TRY_CAST/TRY_CONVERT if that matters." }, { name: 'GREATEST', category: 'logical', signature: 'GREATEST(expression1 [, ...expressionN])', returns: 'the type of highest precedence of the arguments', description: 'Returns the largest value of a list of expressions, leaving NULL out.', example: 'SELECT SubscriberKey, GREATEST(DateJoined, DateUnsubscribed) AS LastChange FROM _Subscribers', docs: 'https://learn.microsoft.com/en-us/sql/t-sql/functions/logical-functions-greatest-transact-sql', sfmc: 'Added in SQL Server 2022. Validate accepted it in MCE (org check, 2026-10-14).' }, { name: 'LEAST', category: 'logical', signature: 'LEAST(expression1 [, ...expressionN])', returns: 'the type of highest precedence of the arguments', description: 'Returns the smallest value of a list of expressions, leaving NULL out.', example: 'SELECT SubscriberKey, LEAST(DateJoined, DateUnsubscribed) AS FirstChange FROM _Subscribers', docs: 'https://learn.microsoft.com/en-us/sql/t-sql/functions/logical-functions-least-transact-sql', sfmc: 'Added in SQL Server 2022. Validate accepted it in MCE (org check, 2026-10-14).' }, { name: 'ISNULL', category: 'null', signature: 'ISNULL(check_expression, replacement_value)', returns: 'the type of check_expression', description: 'Returns check_expression if it is not NULL, otherwise returns replacement_value.', example: 'SELECT SubscriberKey FROM Orders_DE WHERE ISNULL(PurchaseCount, 0) < 5', docs: 'https://mateuszdabrowski.pl/docs/salesforce/marketing-cloud-engagement/sql/sql-null-functions/', officialDocs: 'https://learn.microsoft.com/en-us/sql/t-sql/functions/isnull-transact-sql', sfmc: 'Takes exactly two arguments and returns the data type of the first one; use COALESCE when you need more than one fallback or type-precedence behavior.' }, { name: 'COALESCE', category: 'null', signature: 'COALESCE(expression1, expression2 [, expression3, ...])', returns: 'the highest-precedence type among the arguments', description: 'Returns the first non-NULL expression from a list.', example: "SELECT CONCAT(FirstName, ' ', COALESCE(MiddleName + ' ', ''), LastName) AS FullName FROM Contacts_DE", docs: 'https://mateuszdabrowski.pl/docs/salesforce/marketing-cloud-engagement/sql/sql-null-functions/', officialDocs: 'https://learn.microsoft.com/en-us/sql/t-sql/language-elements/coalesce-transact-sql', sfmc: 'AVG and SUM silently ignore NULL rows, which can skew results; wrap the column in COALESCE(col, 0) first if a NULL should count as zero.' }, { name: 'NULLIF', category: 'null', signature: 'NULLIF(expression1, expression2)', returns: 'the type of expression1', description: 'Returns NULL if the two expressions are equal, otherwise returns expression1.', example: 'SELECT SubscriberKey, NULLIF(WorkEmailAddress, EmailAddress) AS WorkEmailIfDifferent FROM Contacts_DE', docs: 'https://mateuszdabrowski.pl/docs/salesforce/marketing-cloud-engagement/sql/sql-null-functions/', officialDocs: 'https://learn.microsoft.com/en-us/sql/t-sql/language-elements/nullif-transact-sql', sfmc: 'Divide by NULLIF(x, 0) when x can be 0: MCE runs with ANSI_WARNINGS on, so one division by zero fails the whole run, with no reason given (org check, 2026-10-14). NULLIF gives NULL for that row instead.' }, { name: 'COUNT', category: 'aggregate', signature: 'COUNT(* | [ALL] expression | DISTINCT expression)', returns: 'int', description: 'Counts rows. COUNT(*) counts every row including NULLs and duplicates; COUNT(column) skips NULLs; COUNT(DISTINCT column) skips NULLs and duplicates.', example: 'SELECT COUNT(*) AS TotalRows, COUNT(DISTINCT EmailAddress) AS UniqueEmails FROM _Subscribers', docs: 'https://mateuszdabrowski.pl/docs/salesforce/marketing-cloud-engagement/sql/sql-aggregate-functions/', officialDocs: 'https://learn.microsoft.com/en-us/sql/t-sql/functions/count-transact-sql' }, { name: 'COUNT_BIG', category: 'aggregate', signature: 'COUNT_BIG(* | [ALL] expression | DISTINCT expression)', returns: 'bigint', description: 'Same as COUNT but returns bigint, for row counts too large for int.', example: 'SELECT COUNT_BIG(*) FROM _Sent', docs: 'https://learn.microsoft.com/en-us/sql/t-sql/functions/count-big-transact-sql' }, { name: 'SUM', category: 'aggregate', signature: 'SUM([ALL | DISTINCT] expression)', returns: "the input's numeric type (widened where needed)", description: 'Adds up all the values in a numeric column, ignoring NULLs.', example: 'SELECT SUM(BounceCount) AS AllBounces FROM _Subscribers', docs: 'https://mateuszdabrowski.pl/docs/salesforce/marketing-cloud-engagement/sql/sql-aggregate-functions/', officialDocs: 'https://learn.microsoft.com/en-us/sql/t-sql/functions/sum-transact-sql', sfmc: 'Ignores NULL rows rather than treating them as zero; COALESCE the column first if that matters.' }, { name: 'AVG', category: 'aggregate', signature: 'AVG([ALL | DISTINCT] expression)', returns: "the input's numeric type (widened where needed)", description: 'Returns the average of the non-NULL values in a numeric column.', example: 'SELECT AVG(BounceCount) AS AverageBounceCount FROM _Subscribers', docs: 'https://mateuszdabrowski.pl/docs/salesforce/marketing-cloud-engagement/sql/sql-aggregate-functions/', officialDocs: 'https://learn.microsoft.com/en-us/sql/t-sql/functions/avg-transact-sql', sfmc: 'Ignores NULL rows when computing the average, which can skew the result upward or downward versus treating them as zero; COALESCE the column first if needed.' }, { name: 'MIN', category: 'aggregate', signature: 'MIN(expression)', returns: "the input's type", description: 'Returns the smallest value in a column.', example: 'SELECT MIN(DateJoined) AS EarliestJoin FROM _Subscribers', docs: 'https://mateuszdabrowski.pl/docs/salesforce/marketing-cloud-engagement/sql/sql-aggregate-functions/', officialDocs: 'https://learn.microsoft.com/en-us/sql/t-sql/functions/min-transact-sql' }, { name: 'MAX', category: 'aggregate', signature: 'MAX(expression)', returns: "the input's type", description: 'Returns the largest value in a column.', example: 'SELECT MAX(LEN(SubscriberKey)) AS LongestKey FROM _Subscribers', docs: 'https://mateuszdabrowski.pl/docs/salesforce/marketing-cloud-engagement/sql/sql-aggregate-functions/', officialDocs: 'https://learn.microsoft.com/en-us/sql/t-sql/functions/max-transact-sql' }, { name: 'STDEV', category: 'aggregate', signature: 'STDEV(expression)', returns: 'float', description: 'Returns the statistical standard deviation of all values in a column (sample standard deviation).', example: 'SELECT STDEV(BounceCount) FROM _Subscribers', docs: 'https://learn.microsoft.com/en-us/sql/t-sql/functions/stdev-transact-sql' }, { name: 'STDEVP', category: 'aggregate', signature: 'STDEVP(expression)', returns: 'float', description: 'Returns the standard deviation for the full population of values in a column.', example: 'SELECT STDEVP(BounceCount) FROM _Subscribers', docs: 'https://learn.microsoft.com/en-us/sql/t-sql/functions/stdevp-transact-sql' }, { name: 'VAR', category: 'aggregate', signature: 'VAR(expression)', returns: 'float', description: 'Returns the statistical variance of all values in a column (sample variance).', example: 'SELECT VAR(BounceCount) FROM _Subscribers', docs: 'https://learn.microsoft.com/en-us/sql/t-sql/functions/var-transact-sql' }, { name: 'VARP', category: 'aggregate', signature: 'VARP(expression)', returns: 'float', description: 'Returns the variance for the full population of values in a column.', example: 'SELECT VARP(BounceCount) FROM _Subscribers', docs: 'https://learn.microsoft.com/en-us/sql/t-sql/functions/varp-transact-sql' }, { name: 'GROUPING', category: 'aggregate', signature: 'GROUPING(column_expression)', returns: 'tinyint (1 or 0)', description: 'Used with ROLLUP or CUBE; returns 1 when the row is a subtotal/grand-total row for that column, 0 for a normal detail row.', example: 'SELECT Domain, GROUPING(Domain) AS IsSubtotal, COUNT(*) FROM _Subscribers GROUP BY ROLLUP (Domain)', docs: 'https://learn.microsoft.com/en-us/sql/t-sql/functions/grouping-transact-sql' }, { name: 'GROUPING_ID', category: 'aggregate', signature: 'GROUPING_ID(column1 [, column2, ...])', returns: 'int', description: 'Like GROUPING, but for multiple columns at once; returns a bitmask identifying which columns are subtotaled in the current row.', example: 'SELECT Domain, YEAR(DateJoined) AS JoinYear, GROUPING_ID(Domain, YEAR(DateJoined)) FROM _Subscribers GROUP BY ROLLUP (Domain, YEAR(DateJoined))', docs: 'https://learn.microsoft.com/en-us/sql/t-sql/functions/grouping-id-transact-sql' }, { name: 'CHECKSUM_AGG', category: 'aggregate', signature: 'CHECKSUM_AGG(expression)', returns: 'int', description: 'Returns a checksum computed across all the values in a group, useful for cheaply detecting whether any value in a column changed.', example: 'SELECT Domain, CHECKSUM_AGG(BounceCount) AS BounceChecksum FROM _Subscribers GROUP BY Domain', docs: 'https://learn.microsoft.com/en-us/sql/t-sql/functions/checksum-agg-transact-sql', sfmc: 'Can produce the same checksum for different data (collisions); use only as a cheap change-detection heuristic, not as a unique identifier.' }, { name: 'STRING_AGG', category: 'aggregate', signature: 'STRING_AGG(expression, separator) [WITHIN GROUP (ORDER BY sort_expression)]', returns: 'varchar or nvarchar', description: 'Concatenates the non-NULL values of a column across a group into a single delimited string.', example: "SELECT Domain, STRING_AGG(CAST(SubscriberKey AS NVARCHAR(MAX)), ', ') WITHIN GROUP (ORDER BY DateJoined) AS Keys FROM _Subscribers GROUP BY Domain", docs: 'https://learn.microsoft.com/en-us/sql/t-sql/functions/string-agg-transact-sql', sfmc: 'Added in SQL Server 2017. MCE runs SQL Server 2022 (org check, 2026-09-28). Cast the value to NVARCHAR(MAX), as the example does: STRING_AGG of a field gives NVARCHAR(4000), and a longer list fails the run.' }, { name: 'APPROX_COUNT_DISTINCT', category: 'aggregate', signature: 'APPROX_COUNT_DISTINCT(expression)', returns: 'bigint', description: 'Returns the approximate number of distinct values of a group, NULL left out, within 2% of the true count, with less memory than COUNT(DISTINCT ...).', example: 'SELECT Domain, APPROX_COUNT_DISTINCT(SubscriberKey) AS Subscribers FROM _Subscribers GROUP BY Domain', docs: 'https://learn.microsoft.com/en-us/sql/t-sql/functions/approx-count-distinct-transact-sql', sfmc: 'Validate accepted it in MCE (org check, 2026-10-14).' }, { name: 'APPROX_PERCENTILE_CONT', category: 'aggregate', signature: 'APPROX_PERCENTILE_CONT(numeric_literal) WITHIN GROUP (ORDER BY order_by_expression)', returns: 'the type of the ORDER BY expression', description: 'Returns an approximate percentile of a group, interpolated between its values, faster than PERCENTILE_CONT on large data.', example: 'SELECT Domain, APPROX_PERCENTILE_CONT(0.5) WITHIN GROUP (ORDER BY BounceCount) AS MedianBounces FROM _Subscribers GROUP BY Domain', docs: 'https://learn.microsoft.com/en-us/sql/t-sql/functions/approx-percentile-cont-transact-sql', sfmc: 'Added in SQL Server 2022. Validate accepted it in MCE (org check, 2026-10-14). It is an aggregate with WITHIN GROUP and no OVER, where PERCENTILE_CONT is a window function.' }, { name: 'APPROX_PERCENTILE_DISC', category: 'aggregate', signature: 'APPROX_PERCENTILE_DISC(numeric_literal) WITHIN GROUP (ORDER BY order_by_expression)', returns: 'the type of the ORDER BY expression', description: 'Returns an approximate percentile of a group as one of its values, faster than PERCENTILE_DISC on large data.', example: 'SELECT Domain, APPROX_PERCENTILE_DISC(0.5) WITHIN GROUP (ORDER BY BounceCount) AS MedianBounces FROM _Subscribers GROUP BY Domain', docs: 'https://learn.microsoft.com/en-us/sql/t-sql/functions/approx-percentile-disc-transact-sql', sfmc: 'Added in SQL Server 2022. Validate accepted it in MCE (org check, 2026-10-14). It is an aggregate with WITHIN GROUP and no OVER, where PERCENTILE_DISC is a window function.' }, { name: 'ROW_NUMBER', category: 'window', signature: 'ROW_NUMBER() OVER ([PARTITION BY col] ORDER BY col)', returns: 'bigint', description: 'Assigns a unique, sequential integer to each row within its partition, in the order defined by ORDER BY.', example: 'SELECT * FROM (SELECT SubscriberKey, ROW_NUMBER() OVER (PARTITION BY EmailAddress ORDER BY DateJoined DESC) AS rn FROM _Subscribers) AS x WHERE rn = 1', docs: 'https://mateuszdabrowski.pl/docs/salesforce/marketing-cloud-engagement/sql/sql-window-functions/', officialDocs: 'https://learn.microsoft.com/en-us/sql/t-sql/functions/row-number-transact-sql', sfmc: 'The standard MCE pattern for deduplicating a Data Extension: number the rows per key, then keep only rn = 1 in an outer query.' }, { name: 'RANK', category: 'window', signature: 'RANK() OVER ([PARTITION BY col] ORDER BY col)', returns: 'bigint', description: 'Ranks each row within its partition, giving tied rows the same rank and leaving gaps in the sequence after ties.', example: 'SELECT SubscriberKey, RANK() OVER (ORDER BY BounceCount DESC) AS BounceRank FROM _Subscribers', docs: 'https://mateuszdabrowski.pl/docs/salesforce/marketing-cloud-engagement/sql/sql-window-functions/', officialDocs: 'https://learn.microsoft.com/en-us/sql/t-sql/functions/rank-transact-sql' }, { name: 'DENSE_RANK', category: 'window', signature: 'DENSE_RANK() OVER ([PARTITION BY col] ORDER BY col)', returns: 'bigint', description: 'Like RANK, but leaves no gaps in the ranking sequence after ties.', example: 'SELECT SubscriberKey, DENSE_RANK() OVER (ORDER BY BounceCount DESC) AS BounceRank FROM _Subscribers', docs: 'https://mateuszdabrowski.pl/docs/salesforce/marketing-cloud-engagement/sql/sql-window-functions/', officialDocs: 'https://learn.microsoft.com/en-us/sql/t-sql/functions/dense-rank-transact-sql' }, { name: 'NTILE', category: 'window', signature: 'NTILE(integer_expression) OVER ([PARTITION BY col] ORDER BY col)', returns: 'int', description: 'Distributes rows within a partition as evenly as possible into the specified number of groups.', example: 'SELECT SubscriberKey, NTILE(4) OVER (ORDER BY BounceCount) AS Quartile FROM _Subscribers', docs: 'https://mateuszdabrowski.pl/docs/salesforce/marketing-cloud-engagement/sql/sql-window-functions/', officialDocs: 'https://learn.microsoft.com/en-us/sql/t-sql/functions/ntile-transact-sql' }, { name: 'LAG', category: 'window', signature: 'LAG(scalar_expression [, offset [, default]]) OVER ([PARTITION BY col] ORDER BY col)', returns: 'the type of scalar_expression', description: 'Returns the value of an expression from a row a fixed number of positions before the current row in the same partition.', example: 'SELECT SubscriberKey, EventDate, LAG(EventDate) OVER (PARTITION BY SubscriberKey ORDER BY EventDate) AS PreviousOpenDate FROM _Open', docs: 'https://mateuszdabrowski.pl/docs/salesforce/marketing-cloud-engagement/sql/sql-window-functions/', officialDocs: 'https://learn.microsoft.com/en-us/sql/t-sql/functions/lag-transact-sql' }, { name: 'LEAD', category: 'window', signature: 'LEAD(scalar_expression [, offset [, default]]) OVER ([PARTITION BY col] ORDER BY col)', returns: 'the type of scalar_expression', description: 'Returns the value of an expression from a row a fixed number of positions after the current row in the same partition.', example: 'SELECT SubscriberKey, EventDate, LEAD(EventDate) OVER (PARTITION BY SubscriberKey ORDER BY EventDate) AS NextOpenDate FROM _Open', docs: 'https://mateuszdabrowski.pl/docs/salesforce/marketing-cloud-engagement/sql/sql-window-functions/', officialDocs: 'https://learn.microsoft.com/en-us/sql/t-sql/functions/lead-transact-sql' }, { name: 'FIRST_VALUE', category: 'window', signature: 'FIRST_VALUE(scalar_expression) OVER ([PARTITION BY col] ORDER BY col [frame])', returns: 'the type of scalar_expression', description: 'Returns the value of an expression from the first row of the window frame.', example: 'SELECT SubscriberKey, EventDate, FIRST_VALUE(EventDate) OVER (PARTITION BY SubscriberKey ORDER BY EventDate) AS FirstOpenDate FROM _Open', docs: 'https://mateuszdabrowski.pl/docs/salesforce/marketing-cloud-engagement/sql/sql-window-functions/', officialDocs: 'https://learn.microsoft.com/en-us/sql/t-sql/functions/first-value-transact-sql' }, { name: 'LAST_VALUE', category: 'window', signature: 'LAST_VALUE(scalar_expression) OVER ([PARTITION BY col] ORDER BY col [frame])', returns: 'the type of scalar_expression', description: 'Returns the value of an expression from the last row of the window frame.', example: 'SELECT SubscriberKey, EventDate, LAST_VALUE(EventDate) OVER (PARTITION BY SubscriberKey ORDER BY EventDate ROWS BETWEEN UNBOUNDED PRECEDING AND UNBOUNDED FOLLOWING) AS LastOpenDate FROM _Open', docs: 'https://mateuszdabrowski.pl/docs/salesforce/marketing-cloud-engagement/sql/sql-window-functions/', officialDocs: 'https://learn.microsoft.com/en-us/sql/t-sql/functions/last-value-transact-sql', sfmc: 'Needs an explicit ROWS BETWEEN ... AND UNBOUNDED FOLLOWING frame to see the true last row; the default frame only looks back to the current row.' }, { name: 'PERCENT_RANK', category: 'window', signature: 'PERCENT_RANK() OVER ([PARTITION BY col] ORDER BY col)', returns: 'float', description: 'Returns the relative rank of the current row, as a value between 0 and 1, within its partition.', example: 'SELECT SubscriberKey, PERCENT_RANK() OVER (ORDER BY BounceCount) AS BouncePercentile FROM _Subscribers', docs: 'https://mateuszdabrowski.pl/docs/salesforce/marketing-cloud-engagement/sql/sql-window-functions/', officialDocs: 'https://learn.microsoft.com/en-us/sql/t-sql/functions/percent-rank-transact-sql' }, { name: 'CUME_DIST', category: 'window', signature: 'CUME_DIST() OVER ([PARTITION BY col] ORDER BY col)', returns: 'float', description: 'Returns the cumulative distribution of a value within a partition: the fraction of rows with a value less than or equal to the current row.', example: 'SELECT SubscriberKey, CUME_DIST() OVER (ORDER BY BounceCount) AS BounceCumeDist FROM _Subscribers', docs: 'https://mateuszdabrowski.pl/docs/salesforce/marketing-cloud-engagement/sql/sql-window-functions/', officialDocs: 'https://learn.microsoft.com/en-us/sql/t-sql/functions/cume-dist-transact-sql' }, { name: 'PERCENTILE_CONT', category: 'window', signature: 'PERCENTILE_CONT(numeric_literal) WITHIN GROUP (ORDER BY col) OVER ([PARTITION BY col])', returns: "float or the ORDER BY column's type", description: 'Returns a percentile value, interpolating between the two closest data points if the exact percentile falls between them.', example: 'SELECT Domain, PERCENTILE_CONT(0.5) WITHIN GROUP (ORDER BY BounceCount) OVER (PARTITION BY Domain) AS MedianBounces FROM _Subscribers', docs: 'https://mateuszdabrowski.pl/docs/salesforce/marketing-cloud-engagement/sql/sql-window-functions/', officialDocs: 'https://learn.microsoft.com/en-us/sql/t-sql/functions/percentile-cont-transact-sql', sfmc: 'Works in a Query Activity (org check, 2026-09-28).' }, { name: 'PERCENTILE_DISC', category: 'window', signature: 'PERCENTILE_DISC(numeric_literal) WITHIN GROUP (ORDER BY col) OVER ([PARTITION BY col])', returns: "the ORDER BY column's type", description: 'Returns a percentile value, always picking an actual value from the data set rather than interpolating.', example: 'SELECT Domain, PERCENTILE_DISC(0.5) WITHIN GROUP (ORDER BY BounceCount) OVER (PARTITION BY Domain) AS MedianBounces FROM _Subscribers', docs: 'https://mateuszdabrowski.pl/docs/salesforce/marketing-cloud-engagement/sql/sql-window-functions/', officialDocs: 'https://learn.microsoft.com/en-us/sql/t-sql/functions/percentile-disc-transact-sql', sfmc: 'Works in a Query Activity (org check, 2026-09-28).' }, { name: 'ABS', category: 'math', signature: 'ABS(numeric_expression)', returns: "the input's numeric type", description: 'Returns the absolute (non-negative) value of a number.', example: 'SELECT ABS(BalanceChange) FROM Transactions_DE', docs: 'https://learn.microsoft.com/en-us/sql/t-sql/functions/abs-transact-sql' }, { name: 'CEILING', category: 'math', signature: 'CEILING(numeric_expression)', returns: "the input's numeric type", description: 'Rounds a number up to the smallest following integer.', example: 'SELECT CEILING(4.2) AS RoundedUp', docs: 'https://learn.microsoft.com/en-us/sql/t-sql/functions/ceiling-transact-sql' }, { name: 'FLOOR', category: 'math', signature: 'FLOOR(numeric_expression)', returns: "the input's numeric type", description: 'Rounds a number down to the largest preceding integer.', example: 'SELECT FLOOR(4.8) AS RoundedDown', docs: 'https://learn.microsoft.com/en-us/sql/t-sql/functions/floor-transact-sql' }, { name: 'ROUND', category: 'math', signature: 'ROUND(numeric_expression, length [, function])', returns: "the input's numeric type", description: 'Rounds a number to a specified number of decimal places.', example: 'SELECT ROUND(AverageOrderValue, 2) FROM Orders_DE', docs: 'https://learn.microsoft.com/en-us/sql/t-sql/functions/round-transact-sql' }, { name: 'POWER', category: 'math', signature: 'POWER(numeric_expression, power)', returns: "the input's numeric type", description: 'Raises a number to a specified power.', example: 'SELECT POWER(2, 10) AS TwoToTen', docs: 'https://learn.microsoft.com/en-us/sql/t-sql/functions/power-transact-sql' }, { name: 'SQRT', category: 'math', signature: 'SQRT(float_expression)', returns: 'float', description: 'Returns the square root of a number.', example: 'SELECT SQRT(144) AS Root', docs: 'https://learn.microsoft.com/en-us/sql/t-sql/functions/sqrt-transact-sql' }, { name: 'SQUARE', category: 'math', signature: 'SQUARE(float_expression)', returns: 'float', description: 'Returns the square of a number.', example: 'SELECT SQUARE(12) AS Squared', docs: 'https://learn.microsoft.com/en-us/sql/t-sql/functions/square-transact-sql' }, { name: 'SIGN', category: 'math', signature: 'SIGN(numeric_expression)', returns: "the input's numeric type", description: 'Returns the sign of a number: 1 for positive, -1 for negative, 0 for zero.', example: 'SELECT SIGN(BalanceChange) FROM Transactions_DE', docs: 'https://learn.microsoft.com/en-us/sql/t-sql/functions/sign-transact-sql' }, { name: 'EXP', category: 'math', signature: 'EXP(float_expression)', returns: 'float', description: 'Returns e (the base of natural logarithms) raised to the given power.', example: 'SELECT EXP(1) AS EulersNumber', docs: 'https://learn.microsoft.com/en-us/sql/t-sql/functions/exp-transact-sql' }, { name: 'LOG', category: 'math', signature: 'LOG(float_expression [, base])', returns: 'float', description: 'Returns the natural logarithm of a number, or its logarithm to a specified base.', example: 'SELECT LOG(100, 10) AS Log10Of100', docs: 'https://learn.microsoft.com/en-us/sql/t-sql/functions/log-transact-sql' }, { name: 'LOG10', category: 'math', signature: 'LOG10(float_expression)', returns: 'float', description: 'Returns the base-10 logarithm of a number.', example: 'SELECT LOG10(1000) AS Log10', docs: 'https://learn.microsoft.com/en-us/sql/t-sql/functions/log10-transact-sql' }, { name: 'PI', category: 'math', signature: 'PI()', returns: 'float', description: 'Returns the constant value of pi.', example: 'SELECT PI() AS PiValue', docs: 'https://learn.microsoft.com/en-us/sql/t-sql/functions/pi-transact-sql' }, { name: 'RAND', category: 'math', signature: 'RAND([seed])', returns: 'float', description: 'Returns a pseudo-random float value between 0 and 1.', example: 'SELECT RAND() AS RandomValue', docs: 'https://learn.microsoft.com/en-us/sql/t-sql/functions/rand-transact-sql', sfmc: 'With no seed it gives one value for every row of the query: three rows showed the same number, while RAND(CHECKSUM(NEWID())) gave one per row (org check, 2026-10-14).' }, { name: 'ACOS', category: 'math', signature: 'ACOS(float_expression)', returns: 'float', description: 'Returns the angle, in radians, whose cosine is the given number.', example: 'SELECT ACOS(0.5) AS AngleRadians', docs: 'https://learn.microsoft.com/en-us/sql/t-sql/functions/acos-transact-sql' }, { name: 'ASIN', category: 'math', signature: 'ASIN(float_expression)', returns: 'float', description: 'Returns the angle, in radians, whose sine is the given number.', example: 'SELECT ASIN(0.5) AS AngleRadians', docs: 'https://learn.microsoft.com/en-us/sql/t-sql/functions/asin-transact-sql' }, { name: 'ATAN', category: 'math', signature: 'ATAN(float_expression)', returns: 'float', description: 'Returns the angle, in radians, whose tangent is the given number.', example: 'SELECT ATAN(1) AS AngleRadians', docs: 'https://learn.microsoft.com/en-us/sql/t-sql/functions/atan-transact-sql' }, { name: 'ATN2', category: 'math', signature: 'ATN2(float_expression1, float_expression2)', returns: 'float', description: 'Returns the angle, in radians, whose tangent is between the two given numbers (atan2).', example: 'SELECT ATN2(1, 1) AS AngleRadians', docs: 'https://learn.microsoft.com/en-us/sql/t-sql/functions/atn2-transact-sql' }, { name: 'COS', category: 'math', signature: 'COS(float_expression)', returns: 'float', description: 'Returns the cosine of an angle given in radians.', example: 'SELECT COS(PI()) AS CosineValue', docs: 'https://learn.microsoft.com/en-us/sql/t-sql/functions/cos-transact-sql' }, { name: 'SIN', category: 'math', signature: 'SIN(float_expression)', returns: 'float', description: 'Returns the sine of an angle given in radians.', example: 'SELECT SIN(PI() / 2) AS SineValue', docs: 'https://learn.microsoft.com/en-us/sql/t-sql/functions/sin-transact-sql' }, { name: 'TAN', category: 'math', signature: 'TAN(float_expression)', returns: 'float', description: 'Returns the tangent of an angle given in radians.', example: 'SELECT TAN(PI() / 4) AS TangentValue', docs: 'https://learn.microsoft.com/en-us/sql/t-sql/functions/tan-transact-sql' }, { name: 'COT', category: 'math', signature: 'COT(float_expression)', returns: 'float', description: 'Returns the cotangent of an angle given in radians.', example: 'SELECT COT(PI() / 4) AS CotangentValue', docs: 'https://learn.microsoft.com/en-us/sql/t-sql/functions/cot-transact-sql' }, { name: 'DEGREES', category: 'math', signature: 'DEGREES(numeric_expression)', returns: "the input's numeric type", description: 'Converts an angle in radians to degrees.', example: 'SELECT DEGREES(PI()) AS Degrees', docs: 'https://learn.microsoft.com/en-us/sql/t-sql/functions/degrees-transact-sql' }, { name: 'RADIANS', category: 'math', signature: 'RADIANS(numeric_expression)', returns: "the input's numeric type", description: 'Converts an angle in degrees to radians.', example: 'SELECT RADIANS(180) AS Radians', docs: 'https://learn.microsoft.com/en-us/sql/t-sql/functions/radians-transact-sql' }, { name: 'BIT_COUNT', category: 'math', signature: 'BIT_COUNT(expression)', returns: 'bigint for a bigint, else int', description: 'Returns the number of bits set to 1 in an integer or a binary value.', example: 'SELECT SubscriberKey, BIT_COUNT(BounceCount) AS BitsSet FROM _Subscribers', docs: 'https://learn.microsoft.com/en-us/sql/t-sql/functions/bit-count-transact-sql', sfmc: 'Added in SQL Server 2022. Validate accepted it in MCE (org check, 2026-10-14).' }, { name: 'GET_BIT', category: 'math', signature: 'GET_BIT(expression, bit_offset)', returns: 'bit', description: 'Returns the bit at bit_offset of an integer or a binary value, 0 or 1, counted from the lowest.', example: 'SELECT SubscriberKey, GET_BIT(BounceCount, 0) AS IsOdd FROM _Subscribers', docs: 'https://learn.microsoft.com/en-us/sql/t-sql/functions/get-bit-transact-sql', sfmc: 'Added in SQL Server 2022. Validate accepted it in MCE (org check, 2026-10-14).' }, { name: 'SET_BIT', category: 'math', signature: 'SET_BIT(expression, bit_offset [, bit_value])', returns: 'the type of expression', description: 'Returns the value with the bit at bit_offset set to bit_value, 1 when it is left out.', example: 'SELECT SubscriberKey, SET_BIT(BounceCount, 0) AS MadeOdd FROM _Subscribers', docs: 'https://learn.microsoft.com/en-us/sql/t-sql/functions/set-bit-transact-sql', sfmc: 'Added in SQL Server 2022. Validate accepted it in MCE (org check, 2026-10-14).' }, { name: 'LEFT_SHIFT', category: 'math', signature: 'LEFT_SHIFT(expression, shift_amount)', returns: 'the type of expression', description: 'Shifts the bits of an integer or a binary value left by shift_amount places.', example: 'SELECT SubscriberKey, LEFT_SHIFT(BounceCount, 1) AS Doubled FROM _Subscribers', docs: 'https://learn.microsoft.com/en-us/sql/t-sql/functions/left-shift-transact-sql', sfmc: 'Added in SQL Server 2022. Validate accepted it in MCE (org check, 2026-10-14).' }, { name: 'RIGHT_SHIFT', category: 'math', signature: 'RIGHT_SHIFT(expression, shift_amount)', returns: 'the type of expression', description: 'Shifts the bits of an integer or a binary value right by shift_amount places.', example: 'SELECT SubscriberKey, RIGHT_SHIFT(BounceCount, 1) AS Halved FROM _Subscribers', docs: 'https://learn.microsoft.com/en-us/sql/t-sql/functions/right-shift-transact-sql', sfmc: 'Added in SQL Server 2022. Validate accepted it in MCE (org check, 2026-10-14).' }, { name: 'CURRENT_TIMESTAMP', category: 'system', signature: 'CURRENT_TIMESTAMP', returns: 'datetime', description: 'ANSI SQL standard equivalent of GETDATE(); returns the current date and time. Written without parentheses.', example: 'SELECT CURRENT_TIMESTAMP AS Now', docs: 'https://learn.microsoft.com/en-us/sql/t-sql/functions/current-timestamp-transact-sql', sfmc: 'Behaves the same as GETDATE(): fixed Central Standard Time, no DST adjustment.' }, { name: 'USER_NAME', category: 'system', signature: 'USER_NAME([id])', returns: 'nvarchar', description: 'Returns the database user name corresponding to a given ID, or the current user if omitted.', example: 'SELECT USER_NAME() AS CurrentDbUser', docs: 'https://learn.microsoft.com/en-us/sql/t-sql/functions/user-name-transact-sql', sfmc: "Returns a generic database user for the whole account, such as C12345678_User, not the marketer's login." }, { name: 'HOST_NAME', category: 'system', signature: 'HOST_NAME()', returns: 'nvarchar', description: 'Returns the workstation name of the current session.', example: 'SELECT HOST_NAME() AS ClientHost', docs: 'https://learn.microsoft.com/en-us/sql/t-sql/functions/host-name-transact-sql', sfmc: "Returns the name of the Marketing Cloud Engagement server that ran the query, not the marketer's computer." }, { name: 'DATALENGTH', category: 'system', signature: 'DATALENGTH(expression)', returns: 'int (bigint for max types)', description: 'Returns the number of bytes used to store an expression, which can differ from LEN for Unicode or trimmed strings.', example: 'SELECT SubscriberKey, DATALENGTH(SubscriberKey) AS ByteLength FROM _Subscribers', docs: 'https://learn.microsoft.com/en-us/sql/t-sql/functions/datalength-transact-sql' }, { name: 'NEWID', category: 'system', signature: 'NEWID()', returns: 'uniqueidentifier', description: 'Generates a new pseudorandom GUID in the standard XXXXXXXX-XXXX-XXXX-XXXX-XXXXXXXXXXXX format.', example: 'SELECT NEWID() AS UniqueRowId', docs: 'https://mateuszdabrowski.pl/docs/salesforce/marketing-cloud-engagement/sql/sql-string-functions/', officialDocs: 'https://learn.microsoft.com/en-us/sql/t-sql/functions/newid-transact-sql', sfmc: 'Gives a new value on every row (org check, 2026-10-14). Commonly used to generate a unique key for a results Data Extension, and as a workaround for Synchronized Data Extension fields named Object:Field that cannot be selected directly.' }, { name: 'COMPRESS', category: 'system', signature: 'COMPRESS(expression)', returns: 'varbinary(max)', description: 'Compresses a text or binary value with the GZIP algorithm.', example: 'SELECT SubscriberKey, DATALENGTH(COMPRESS(EmailAddress)) AS CompressedBytes FROM _Subscribers', docs: 'https://learn.microsoft.com/en-us/sql/t-sql/functions/compress-transact-sql', sfmc: 'Runs in MCE (org check, 2026-10-14). The result is varbinary, which a Text field holds as unreadable characters: read its size with DATALENGTH, or write it as hex with CONVERT(VARCHAR(MAX), x, 2).' }, { name: 'SESSIONPROPERTY', category: 'system', signature: 'SESSIONPROPERTY(option)', returns: 'sql_variant', description: 'Returns the setting of a SET option of the session, 1 for on and 0 for off.', example: "SELECT CAST(SESSIONPROPERTY('QUOTED_IDENTIFIER') AS INT) AS QuotedIdentifier", docs: 'https://learn.microsoft.com/en-us/sql/t-sql/functions/sessionproperty-transact-sql', sfmc: 'In MCE ANSI_NULLS, ANSI_PADDING, ANSI_WARNINGS, CONCAT_NULL_YIELDS_NULL and QUOTED_IDENTIFIER are on, and ARITHABORT and NUMERIC_ROUNDABORT off (org check, 2026-10-14). The result is sql_variant: CAST it before writing it to a field.' }, { name: 'CONNECTIONPROPERTY', category: 'system', signature: 'CONNECTIONPROPERTY(property)', returns: 'sql_variant', description: 'Returns a property of the connection the query runs on, such as net_transport.', example: "SELECT CAST(CONNECTIONPROPERTY('net_transport') AS NVARCHAR(128)) AS NetTransport", docs: 'https://learn.microsoft.com/en-us/sql/t-sql/functions/connectionproperty-transact-sql', sfmc: "Runs in MCE: 'net_transport' gave TCP (org check, 2026-10-14)." }, { name: 'SUSER_SNAME', category: 'system', signature: 'SUSER_SNAME([server_user_sid])', returns: 'nvarchar(128)', description: 'Returns the login name of the session, or of the security ID given.', example: 'SELECT SUSER_SNAME() AS LoginName', docs: 'https://learn.microsoft.com/en-us/sql/t-sql/functions/suser-sname-transact-sql', sfmc: "Returns a security ID as text in MCE, starting S-1-9-3-, not a person's login (org check, 2026-10-14)." }, { name: 'SQL_VARIANT_PROPERTY', category: 'system', signature: 'SQL_VARIANT_PROPERTY(expression, property)', returns: 'sql_variant', description: 'Returns a property of a sql_variant value: BaseType, Precision, Scale, TotalBytes, Collation or MaxLength.', example: "SELECT CAST(SQL_VARIANT_PROPERTY(CAST(EmailAddress AS SQL_VARIANT), 'BaseType') AS NVARCHAR(30)) AS EmailType FROM _Subscribers", docs: 'https://learn.microsoft.com/en-us/sql/t-sql/functions/sql-variant-property-transact-sql', sfmc: 'Runs in MCE: a text value written in a query is nvarchar at Run, as MCE adds the N, and its collation is SQL_Latin1_General_CP1_CI_AS (org check, 2026-10-14).' }, { name: '@@VERSION', category: 'system', signature: '@@VERSION', returns: 'nvarchar(300)', description: 'Returns the version, edition and host of the engine.', example: 'SELECT @@VERSION AS EngineVersion', docs: 'https://learn.microsoft.com/en-us/sql/t-sql/functions/version-transact-sql-configuration-functions', sfmc: 'Returns Microsoft SQL Server 2022 (RTM-CU24) Enterprise Edition on Windows Server 2019 in MCE (org check, 2026-10-14).' }, { name: '@@SPID', category: 'system', signature: '@@SPID', returns: 'smallint', description: 'Returns the session ID of the connection the query runs on.', example: 'SELECT @@SPID AS SessionId', docs: 'https://learn.microsoft.com/en-us/sql/t-sql/functions/spid-transact-sql', sfmc: 'Runs in MCE (org check, 2026-10-14).' }, { name: '@@NESTLEVEL', category: 'system', signature: '@@NESTLEVEL', returns: 'int', description: 'Returns the nesting level of the procedure calls the query runs in.', example: 'SELECT @@NESTLEVEL AS NestLevel', docs: 'https://learn.microsoft.com/en-us/sql/t-sql/functions/nestlevel-transact-sql', sfmc: 'Returns 3 in MCE, which runs the query nested inside code of its own (org check, 2026-10-14).' }, { name: '@@LANGUAGE', category: 'system', signature: '@@LANGUAGE', returns: 'nvarchar(128)', description: "Returns the name of the session's language.", example: 'SELECT @@LANGUAGE AS SessionLanguage', docs: 'https://learn.microsoft.com/en-us/sql/t-sql/functions/language-transact-sql', sfmc: 'Returns us_english in MCE, so month and day names are English and date text reads as month, day, year (org check, 2026-10-14).' }, { name: '@@LANGID', category: 'system', signature: '@@LANGID', returns: 'smallint', description: "Returns the ID of the session's language.", example: 'SELECT @@LANGID AS LanguageId', docs: 'https://learn.microsoft.com/en-us/sql/t-sql/functions/langid-transact-sql', sfmc: 'Returns 0, us_english, in MCE (org check, 2026-10-14).' }, { name: '@@DATEFIRST', category: 'system', signature: '@@DATEFIRST', returns: 'tinyint', description: 'Returns the first day of the week of the session, 7 for Sunday.', example: 'SELECT @@DATEFIRST AS DateFirst', docs: 'https://learn.microsoft.com/en-us/sql/t-sql/functions/datefirst-transact-sql', sfmc: 'Returns 7 in MCE, so Sunday is day 1 of DATEPART(WEEKDAY, x) (org check, 2026-10-14).' }, { name: '@@MAX_PRECISION', category: 'system', signature: '@@MAX_PRECISION', returns: 'tinyint', description: 'Returns the precision of the decimal and numeric types, the most digits they hold.', example: 'SELECT @@MAX_PRECISION AS MaxPrecision', docs: 'https://learn.microsoft.com/en-us/sql/t-sql/functions/max-precision-transact-sql', sfmc: 'Returns 38 in MCE (org check, 2026-10-14).' }, { name: '@@TEXTSIZE', category: 'system', signature: '@@TEXTSIZE', returns: 'int', description: 'Returns the TEXTSIZE setting of the session, the most bytes of a text, ntext or image value a SELECT returns.', example: 'SELECT @@TEXTSIZE AS SessionTextSize', docs: 'https://learn.microsoft.com/en-us/sql/t-sql/functions/textsize-transact-sql', sfmc: 'Returns -1 in MCE (org check, 2026-10-14).' }, { name: '@@OPTIONS', category: 'system', signature: '@@OPTIONS', returns: 'int', description: 'Returns the SET options of the session as one number, a bit for each option that is on.', example: 'SELECT @@OPTIONS AS SessionOptions', docs: 'https://learn.microsoft.com/en-us/sql/t-sql/functions/options-transact-sql', sfmc: 'Returns 5944 in MCE: ANSI_WARNINGS, ANSI_PADDING, ANSI_NULLS, QUOTED_IDENTIFIER, NOCOUNT, ANSI_NULL_DFLT_ON and CONCAT_NULL_YIELDS_NULL (org check, 2026-10-14).' }, { name: '@@LOCK_TIMEOUT', category: 'system', signature: '@@LOCK_TIMEOUT', returns: 'int', description: 'Returns the lock time-out of the session in milliseconds, -1 for none.', example: 'SELECT @@LOCK_TIMEOUT AS LockTimeout', docs: 'https://learn.microsoft.com/en-us/sql/t-sql/functions/lock-timeout-transact-sql', sfmc: 'Returns -1 in MCE, so the query waits for a lock with no time-out (org check, 2026-10-14).' }, { name: '@@TRANCOUNT', category: 'system', signature: '@@TRANCOUNT', returns: 'int', description: 'Returns the number of transactions open on the connection.', example: 'SELECT @@TRANCOUNT AS OpenTransactions', docs: 'https://learn.microsoft.com/en-us/sql/t-sql/functions/trancount-transact-sql', sfmc: 'Returns 2 in MCE, which runs the query inside transactions of its own (org check, 2026-10-14).' }, { name: 'ISJSON', category: 'json', signature: 'ISJSON(expression [, VALUE | ARRAY | OBJECT | SCALAR])', returns: 'int (1 or 0)', description: 'Returns 1 if a string contains valid JSON, otherwise 0. The optional second argument, from SQL Server 2022, asks for a JSON value, array, object or scalar.', example: 'SELECT * FROM WebhookPayload_DE WHERE ISJSON(RawPayload) = 1', docs: 'https://mateuszdabrowski.pl/docs/salesforce/marketing-cloud-engagement/sql/sql-json-functions/', officialDocs: 'https://learn.microsoft.com/en-us/sql/t-sql/functions/isjson-transact-sql', sfmc: 'Works in a Query Activity, the type argument included: ISJSON(\'[1,2]\', ARRAY) returned 1 (org check, 2026-09-28).' }, { name: 'JSON_VALUE', category: 'json', signature: 'JSON_VALUE(expression, path)', returns: 'nvarchar', description: 'Extracts a single scalar value from a JSON string at the given path.', example: "SELECT JSON_VALUE(RawPayload, '$.orderId') AS OrderId FROM WebhookPayload_DE", docs: 'https://mateuszdabrowski.pl/docs/salesforce/marketing-cloud-engagement/sql/sql-json-functions/', officialDocs: 'https://learn.microsoft.com/en-us/sql/t-sql/functions/json-value-transact-sql', sfmc: 'Works in a Query Activity. OPENJSON does not, so read one value at a time with JSON_VALUE. A value over 4,000 characters comes back as NULL, so read a long object or array with JSON_QUERY instead (org check, 2026-09-28). Text that is not valid JSON fails the whole query, and the run ends with 0 rows, so guard it: CASE WHEN ISJSON(x) = 1 THEN JSON_VALUE(x, path) END.' }, { name: 'JSON_QUERY', category: 'json', signature: 'JSON_QUERY(expression [, path])', returns: 'nvarchar', description: 'Extracts an object or array fragment from a JSON string at the given path.', example: "SELECT JSON_QUERY(RawPayload, '$.lineItems') AS LineItems FROM WebhookPayload_DE", docs: 'https://mateuszdabrowski.pl/docs/salesforce/marketing-cloud-engagement/sql/sql-json-functions/', officialDocs: 'https://learn.microsoft.com/en-us/sql/t-sql/functions/json-query-transact-sql', sfmc: 'Works in a Query Activity (org check, 2026-09-28).' }, { name: 'JSON_MODIFY', category: 'json', signature: 'JSON_MODIFY(expression, path, new_value)', returns: 'nvarchar', description: 'Returns a JSON string with the value at the given path updated, inserted or removed.', example: "SELECT JSON_MODIFY(RawPayload, '$.status', 'processed') FROM WebhookPayload_DE", docs: 'https://mateuszdabrowski.pl/docs/salesforce/marketing-cloud-engagement/sql/sql-json-functions/', officialDocs: 'https://learn.microsoft.com/en-us/sql/t-sql/functions/json-modify-transact-sql', sfmc: 'Works in a Query Activity (org check, 2026-09-28).' }, { name: 'JSON_OBJECT', category: 'json', signature: 'JSON_OBJECT([key : value [, ...]] [NULL ON NULL | ABSENT ON NULL])', returns: 'nvarchar(max)', description: 'Builds JSON object text from key: value pairs. Keeps a NULL value as null unless ABSENT ON NULL is given.', example: "SELECT JSON_OBJECT('key': s.SubscriberKey, 'email': s.EmailAddress) AS Payload FROM _Subscribers AS s", docs: 'https://mateuszdabrowski.pl/docs/salesforce/marketing-cloud-engagement/sql/sql-json-functions/', officialDocs: 'https://learn.microsoft.com/en-us/sql/t-sql/functions/json-object-transact-sql', sfmc: 'A SQL Server 2022 function that works in a Query Activity (org check, 2026-09-28).' }, { name: 'JSON_ARRAY', category: 'json', signature: 'JSON_ARRAY([value [, ...]] [NULL ON NULL | ABSENT ON NULL])', returns: 'nvarchar(max)', description: 'Builds JSON array text from a list of values. Leaves a NULL value out unless NULL ON NULL is given.', example: 'SELECT JSON_ARRAY(s.SubscriberKey, s.EmailAddress) AS Pair FROM _Subscribers AS s', docs: 'https://mateuszdabrowski.pl/docs/salesforce/marketing-cloud-engagement/sql/sql-json-functions/', officialDocs: 'https://learn.microsoft.com/en-us/sql/t-sql/functions/json-array-transact-sql', sfmc: 'A SQL Server 2022 function that works in a Query Activity. JSON_ARRAY(1, \'two\', NULL) returned [1,"two"] (org check, 2026-09-28).' }, { name: 'JSON_PATH_EXISTS', category: 'json', signature: 'JSON_PATH_EXISTS(expression, path)', returns: 'int (1, 0 or NULL)', description: 'Returns 1 when the path exists in the JSON text, 0 when it does not, and NULL for a NULL expression.', example: "SELECT w.SubscriberKey FROM WebhookPayload_DE AS w WHERE JSON_PATH_EXISTS(w.RawPayload, '$.orderId') = 1", docs: 'https://mateuszdabrowski.pl/docs/salesforce/marketing-cloud-engagement/sql/sql-json-functions/', officialDocs: 'https://learn.microsoft.com/en-us/sql/t-sql/functions/json-path-exists-transact-sql', sfmc: 'A SQL Server 2022 function that works in a Query Activity (org check, 2026-09-28).' }, { name: 'HASHBYTES', category: 'hash', signature: 'HASHBYTES(algorithm, input)', returns: 'varbinary', description: 'Computes a cryptographic hash of the input using the specified algorithm (MD5, SHA1, SHA2_256, SHA2_512, etc.).', example: "SELECT CONVERT(VARCHAR(64), HASHBYTES('SHA2_256', EmailAddress), 2) AS EmailHash FROM _Subscribers", docs: 'https://learn.microsoft.com/en-us/sql/t-sql/functions/hashbytes-transact-sql', sfmc: 'SHA2_256 and MD5 both run in MCE (org check, 2026-10-14). The result is varbinary, which a Text field holds as unreadable characters, so wrap it in CONVERT(VARCHAR(64), HASHBYTES(...), 2) for hex text. MD2, MD4, SHA and SHA1 are deprecated: prefer SHA2_256 or SHA2_512.' }, { name: 'CHECKSUM', category: 'hash', signature: 'CHECKSUM(* | expression [, expression2, ...])', returns: 'int', description: 'Computes a checksum value over a row or a list of expressions, useful for cheap change detection.', example: 'SELECT SubscriberKey, CHECKSUM(EmailAddress, Status, BounceCount) AS RowChecksum FROM _Subscribers', docs: 'https://learn.microsoft.com/en-us/sql/t-sql/functions/checksum-transact-sql', sfmc: 'Can produce identical checksums for different input (collisions); good for spotting likely changes, not for uniquely identifying a row.' }, { name: 'BINARY_CHECKSUM', category: 'hash', signature: 'BINARY_CHECKSUM(* | expression [, expression2, ...])', returns: 'int', description: 'Like CHECKSUM, but sensitive to the exact binary representation, so it can detect a case-only change that CHECKSUM would treat as unchanged under a case-insensitive collation.', example: 'SELECT SubscriberKey, BINARY_CHECKSUM(EmailAddress) AS RowChecksum FROM _Subscribers', docs: 'https://learn.microsoft.com/en-us/sql/t-sql/functions/binary-checksum-transact-sql' }, { name: 'CRYPT_GEN_RANDOM', category: 'hash', signature: 'CRYPT_GEN_RANDOM(length [, seed])', returns: 'varbinary(8000)', description: 'Returns length bytes of cryptographic random data from the Crypto API.', example: 'SELECT SubscriberKey, CONVERT(VARCHAR(16), CRYPT_GEN_RANDOM(8), 2) AS RandomHex FROM _Subscribers', docs: 'https://learn.microsoft.com/en-us/sql/t-sql/functions/crypt-gen-random-transact-sql', sfmc: 'Runs in MCE (org check, 2026-10-14). The result is varbinary, which a Text field holds as unreadable characters: write it as hex with CONVERT(VARCHAR(16), CRYPT_GEN_RANDOM(8), 2).' } ], dataTypes: [ { name: 'VARCHAR', description: 'Variable-length, non-Unicode string. Specify a maximum length in parentheses, e.g. VARCHAR(50).', example: 'CAST(BounceCount AS VARCHAR(10))' }, { name: 'NVARCHAR', description: 'Variable-length, Unicode string. The recommended default string type for MCE conversions.', example: 'CONVERT(NVARCHAR(100), GETDATE(), 101)' }, { name: 'CHAR', description: 'Fixed-length, non-Unicode string, padded with spaces to its declared length.', example: 'CAST(StatusCode AS CHAR(3))' }, { name: 'NCHAR', description: 'Fixed-length, Unicode string, padded with spaces to its declared length.', example: 'CAST(CountryCode AS NCHAR(2))' }, { name: 'INT', description: '32-bit signed integer, roughly -2.1 billion to 2.1 billion.', example: 'CAST(BounceCount AS INT)' }, { name: 'BIGINT', description: '64-bit signed integer, for values larger than INT can hold.', example: 'CAST(TotalSends AS BIGINT)' }, { name: 'SMALLINT', description: '16-bit signed integer, -32,768 to 32,767.', example: 'CAST(Rating AS SMALLINT)' }, { name: 'TINYINT', description: '8-bit unsigned integer, 0 to 255.', example: 'CAST(Priority AS TINYINT)' }, { name: 'DECIMAL', description: 'Fixed-precision, fixed-scale number. Always specify precision and scale, e.g. DECIMAL(18,2); without them, conversions round to a whole number.', example: "CONVERT(DECIMAL(10,2), '123.99')" }, { name: 'NUMERIC', description: 'Functionally identical to DECIMAL in SQL Server; specify precision and scale the same way.', example: 'CAST(OrderTotal AS NUMERIC(10,2))' }, { name: 'FLOAT', description: 'Approximate floating-point number.', example: 'CAST(Latitude AS FLOAT)', sfmc: "MCE's own SQL docs warn against using FLOAT for values compared in WHERE clauses, since approximate storage can make equality comparisons behave unpredictably; use DECIMAL instead." }, { name: 'REAL', description: 'Approximate floating-point number with less precision than FLOAT.', example: 'CAST(Latitude AS REAL)', sfmc: 'Same approximate-value caveat as FLOAT; avoid for values compared with = or in aggregates that feed a WHERE clause.' }, { name: 'BIT', description: 'Single-bit integer used to store boolean-like values (0, 1 or NULL).', example: 'CAST(IsActive AS BIT)' }, { name: 'DATE', description: 'Calendar date only, no time component.', example: 'CONVERT(DATE, GETDATE())' }, { name: 'DATETIME', description: 'Date and time with roughly 3-millisecond precision; the classic SQL Server datetime type and the one most Data Extension Date fields map to.', example: 'CAST(EventDate AS DATETIME)' }, { name: 'DATETIME2', description: 'Date and time with configurable fractional-second precision (up to 100 nanoseconds), wider date range than DATETIME.', example: 'CAST(EventDate AS DATETIME2)' }, { name: 'DATETIMEOFFSET', description: 'Date and time with a time zone offset. Produced by AT TIME ZONE, SYSDATETIMEOFFSET, TODATETIMEOFFSET.', example: 'CAST(SYSDATETIMEOFFSET() AS DATETIMEOFFSET)' }, { name: 'TIME', description: 'Time of day only, no date component.', example: 'CAST(SendTime AS TIME)' }, { name: 'UNIQUEIDENTIFIER', description: '128-bit GUID value, typically produced by NEWID().', example: 'CAST(NEWID() AS UNIQUEIDENTIFIER)' }, { name: 'VARBINARY', description: 'Variable-length binary data. HASHBYTES returns this type; convert it to VARCHAR to store it in a Data Extension text field.', example: "CONVERT(VARCHAR(64), HASHBYTES('SHA2_256', EmailAddress), 2)" } ], dateParts: [ { name: 'year', aliases: ['yy', 'yyyy'], description: 'The calendar year.' }, { name: 'quarter', aliases: ['qq', 'q'], description: 'The calendar quarter (1 to 4).' }, { name: 'month', aliases: ['mm', 'm'], description: 'The calendar month (1 to 12).' }, { name: 'dayofyear', aliases: ['dy', 'y'], description: 'The day number within the year (1 to 366).' }, { name: 'day', aliases: ['dd', 'd'], description: 'The day of the month.' }, { name: 'week', aliases: ['wk', 'ww'], description: 'The week number within the year.' }, { name: 'weekday', aliases: ['dw', 'w'], description: 'The day of the week.' }, { name: 'hour', aliases: ['hh'], description: 'The hour, 0 to 23.' }, { name: 'minute', aliases: ['mi', 'n'], description: 'The minute, 0 to 59.' }, { name: 'second', aliases: ['ss', 's'], description: 'The second, 0 to 59.' }, { name: 'millisecond', aliases: ['ms'], description: 'The millisecond, 0 to 999.' }, { name: 'microsecond', aliases: ['mcs'], description: 'The microsecond; only meaningful on datetime2/time values with sufficient precision.' }, { name: 'nanosecond', aliases: ['ns'], description: 'The nanosecond; only meaningful on datetime2/time values with sufficient precision.' } ], convertStyles: [ { code: 100, format: 'Mon dd yyyy hh:mmAM/PM', description: 'Default with century, e.g. Oct 30 2020 1:15AM' }, { code: 101, format: 'mm/dd/yyyy', description: 'US' }, { code: 102, format: 'yyyy.mm.dd', description: 'ANSI' }, { code: 103, format: 'dd/mm/yyyy', description: 'British/French' }, { code: 104, format: 'dd.mm.yyyy', description: 'German' }, { code: 105, format: 'dd-mm-yyyy', description: 'Italian' }, { code: 106, format: 'dd Mon yyyy', description: 'e.g. 30 Oct 2020' }, { code: 107, format: 'Mon dd, yyyy', description: 'e.g. Oct 30, 2020' }, { code: 108, format: 'hh:mm:ss', description: 'Time only' }, { code: 109, format: 'Mon dd yyyy hh:mm:ss:mmmAM/PM', description: 'Default with century and milliseconds' }, { code: 110, format: 'mm-dd-yyyy', description: 'US with dashes' }, { code: 111, format: 'yyyy/mm/dd', description: 'Japan' }, { code: 112, format: 'yyyymmdd', description: 'ISO, no separators' }, { code: 113, format: 'dd Mon yyyy hh:mm:ss:mmm', description: 'Europe default with milliseconds, 24-hour time' }, { code: 114, format: 'hh:mm:ss:mmm', description: 'Time only with milliseconds, 24-hour' }, { code: 120, format: 'yyyy-mm-dd hh:mm:ss', description: 'ODBC canonical' }, { code: 121, format: 'yyyy-mm-dd hh:mm:ss.mmm', description: 'ODBC canonical with milliseconds' }, { code: 126, format: 'yyyy-mm-ddThh:mm:ss.mmm', description: 'ISO8601, no time zone' }, { code: 20, format: 'yyyy-mm-dd hh:mm:ss', description: 'Two-digit-style code for ODBC canonical (same output as 120)' }, { code: 21, format: 'yyyy-mm-dd hh:mm:ss.mmm', description: 'Two-digit-style code for ODBC canonical with milliseconds (same output as 121)' }, { code: 23, format: 'yyyy-mm-dd', description: 'ISO date only' }, { code: 25, format: 'yyyy-mm-dd hh:mm:ss.mmm', description: 'Alias behaviour matching 121 in most SQL Server versions' } ], dataViews: [ { name: '_Subscribers', alias: 'sub', fullQuery: 'SELECT\n sub.SubscriberID\n , sub.DateUndeliverable\n , sub.DateJoined\n , sub.DateUnsubscribed\n , sub.Domain\n , sub.EmailAddress\n , sub.BounceCount\n , sub.SubscriberKey\n , sub.SubscriberType\n , sub.Status\n , sub.Locale\nFROM _Subscribers AS sub', description: 'Core subscriber record for every entry on the All Subscribers list: identity, status and top-level bounce/join/unsubscribe dates.', docs: 'https://mateuszdabrowski.pl/docs/salesforce/marketing-cloud-engagement/config/system-data-views/#_subscribers', officialDocs: 'https://help.salesforce.com/s/articleView?id=mktg.mc_as_data_view_subscribers.htm&type=5', joinKey: 'SubscriberID / SubscriberKey', isEnterpriseLevel: true, notes: "No 6-month retention limit, unlike most tracking views. From a child Business Unit, add the Ent. prefix (Ent._Subscribers) to see parent-level subscribers instead of only the child BU's own rows.", fields: [ { name: 'SubscriberID', type: 'Number', sqlType: 'int', description: 'The subscriber ID for the affected subscriber. This number represents the unique ID for each subscriber record.' }, { name: 'DateUndeliverable', type: 'Date', nullable: true, description: 'The date an email for the subscriber was returned as undeliverable.' }, { name: 'DateJoined', type: 'Date', sqlType: 'smalldatetime', nullable: true, description: 'The Date, the subscriber, joined your list. A smalldatetime, which holds no seconds (org check, 2026-10-14).' }, { name: 'DateUnsubscribed', type: 'Date', nullable: true, description: 'The Date, the subscriber, unsubscribed from your list.' }, { name: 'Domain', type: 'Text', length: 254, nullable: true, description: 'The domain of the subscriber.' }, { name: 'EmailAddress', type: 'Email', length: 254, sqlType: 'nvarchar(254)', description: "The subscriber's email address." }, { name: 'BounceCount', type: 'Number', sqlType: 'smallint', description: 'The total number of bounces accrued by the subscriber.' }, { name: 'SubscriberKey', type: 'Text', length: 254, sqlType: 'nvarchar(254)', description: 'A potential alternate identifier for subscribers.' }, { name: 'SubscriberType', type: 'Text', length: 100, description: 'The subscriber type for the affected subscriber.', values: [{ value: 'ExactTarget', label: '' }, { value: 'Unknown External System', label: '' }] }, { name: 'Status', type: 'Text', length: 12, sqlType: 'varchar', nullable: true, description: 'The status of the subscriber. A varchar, not an nvarchar (org check, 2026-10-14), so STRING_AGG of it needs a CAST to NVARCHAR(MAX) beside a text separator, which MCE writes as nvarchar.', values: [{ value: 'active', label: '' }, { value: 'held', label: '' }, { value: 'unsubscribed', label: '' }, { value: 'bounced', label: '' }] }, { name: 'Locale', type: 'Locale', nullable: true, description: 'The locale code for the subscriber.' } ] }, { name: '_EnterpriseAttribute', alias: 'ea', fullQuery: 'SELECT\n ea._SubscriberID AS SubscriberID\n , ea.ProfileAttributeWithoutSpaces\n , ea.[Profile Attribute With Spaces] AS ProfileAttributeWithSpace\n , ea.PreferenceAttributeWithoutSpaces\n , ea.[Preference Attribute With Spaces] AS PreferenceAttributeWithSpace\nFROM _EnterpriseAttribute AS ea', description: 'Profile and Preference Attributes captured in Email Studio, one row per subscriber.', docs: 'https://mateuszdabrowski.pl/docs/salesforce/marketing-cloud-engagement/config/system-data-views/#_enterpriseattribute', officialDocs: 'https://help.salesforce.com/s/articleView?id=mktg.mc_as_data_view_enterpriseattribute.htm&type=5', joinKey: '_SubscriberID', isEnterpriseLevel: true, notes: "Enterprise 2.0 only. From a child Business Unit, query it as Ent._EnterpriseAttribute. In the parent, leave the prefix out, since the parent refuses Ent. (org check, 2026-09-28). Joins to _Subscribers on _SubscriberID (leading underscore, unlike every other view's SubscriberID). Attribute column names containing spaces need [brackets]. No 6-month retention limit.", fields: [ { name: '_SubscriberID', type: 'Number', description: 'The subscriber ID for the affected subscriber. This number represents the unique ID for each subscriber record stored.' }, { name: '[Profile/Preference Attribute]', type: 'Text', unverified: true, description: 'One column per Profile or Preference Attribute defined in Email Studio; exact column names are account-specific and must be discovered per account. The built-in "HTML Email" preference is not exposed here.' } ] }, { name: '_Job', alias: 'job', fullQuery: 'SELECT\n job.JobID\n , job.EmailID\n , job.AccountID\n , job.AccountUserID\n , job.FromName\n , job.FromEmail\n , job.SchedTime\n , job.PickupTime\n , job.DeliveredTime\n , job.EventID\n , job.IsMultipart\n , job.JobType\n , job.JobStatus\n , job.ModifiedBy\n , job.ModifiedDate\n , job.EmailName\n , job.EmailSubject\n , job.IsWrapped\n , job.TestEmailAddr\n , job.Category\n , job.BccEmail\n , job.OriginalSchedTime\n , job.CreatedDate\n , job.CharacterSet\n , job.IPAddress\n , job.SalesForceTotalSubscriberCount\n , job.SalesForceErrorSubscriberCount\n , job.SendType\n , job.DynamicEmailSubject\n , job.SuppressTracking\n , job.SendClassificationType\n , job.SendClassification\n , job.ResolveLinksWithCurrentData\n , job.EmailSendDefinition\n , job.DeduplicateByEmail\n , job.TriggererSendDefinitionObjectID\n , job.TriggeredSendCustomerKey\nFROM _Job AS job', description: 'One row per email send job: subject, from address, schedule and delivery timing, useful for context missing from the tracking views.', docs: 'https://mateuszdabrowski.pl/docs/salesforce/marketing-cloud-engagement/config/system-data-views/#_job', officialDocs: 'https://help.salesforce.com/s/articleView?id=mktg.mc_as_data_view_job.htm&type=5', joinKey: 'JobID', notes: 'Business-Unit-specific. 6-month retention. Join to _Sent/_Open/_Click/_Bounce/_Complaint/_Unsubscribe on JobID.', fields: [ { name: 'JobID', type: 'Number', sqlType: 'int', description: 'The job ID number for the email send.' }, { name: 'EmailID', type: 'Number', nullable: true, description: 'The email ID for the job.' }, { name: 'AccountID', type: 'Number', nullable: true, description: 'The ID number for the account that performed the job.' }, { name: 'AccountUserID', type: 'Number', nullable: true, description: 'The ID number for the account user that performed the job.' }, { name: 'FromName', type: 'Text', length: 130, nullable: true, description: 'The from name in the email send.' }, { name: 'FromEmail', type: 'Email', length: 100, nullable: true, description: 'The from email address in the email send.' }, { name: 'SchedTime', type: 'Date', nullable: true, description: 'The time the job was scheduled.' }, { name: 'PickupTime', type: 'Date', nullable: true, description: 'The time the MCE application started the job.' }, { name: 'DeliveredTime', type: 'Date', sqlType: 'smalldatetime', nullable: true, description: 'The time the email was delivered. A smalldatetime, which holds no seconds (org check, 2026-10-14).' }, { name: 'EventID', type: 'Text', length: 50, nullable: true, description: 'The ID for the job event.' }, { name: 'IsMultipart', type: 'Boolean', description: 'Whether the job was sent as multi-part MIME or not.' }, { name: 'JobType', type: 'Text', length: 50, nullable: true, description: 'The type of job.', values: [{ value: 'null', label: '' }, { value: 'MULTIPLE_SEND', label: '' }] }, { name: 'JobStatus', type: 'Text', length: 50, nullable: true, description: 'The status of the job.', values: [{ value: 'Deleted', label: '' }, { value: 'New', label: '' }] }, { name: 'ModifiedBy', type: 'Number', nullable: true, description: 'If modified, the user who modified the job.' }, { name: 'ModifiedDate', type: 'Date', nullable: true, description: 'The date the job was modified.' }, { name: 'EmailName', type: 'Text', length: 100, sqlType: 'nvarchar', nullable: true, description: 'The name of the email sent by the job.' }, { name: 'EmailSubject', type: 'Text', length: 200, nullable: true, description: 'The subject of the email send.' }, { name: 'IsWrapped', type: 'Boolean', description: 'Whether the links in the email were wrapped for tracking.' }, { name: 'TestEmailAddr', type: 'Email', length: 128, nullable: true, description: 'The test email address used in the job.' }, { name: 'Category', type: 'Text', length: 100, description: 'The job category. Besides the two fixed values, it can be Version XX, where XX is a number, or GUID_VXX, a 36-character hyphen-separated GUID followed by an underscore and VXX.', values: [{ value: 'Test Send Emails', label: '' }, { value: 'Triggered Sends', label: '' }] }, { name: 'BccEmail', type: 'Email', length: 100, nullable: true, description: 'Any email address send in the BCC field.' }, { name: 'OriginalSchedTime', type: 'Date', nullable: true, description: 'The initially scheduled time for the job.' }, { name: 'CreatedDate', type: 'Date', description: 'The date the job was created.' }, { name: 'CharacterSet', type: 'Text', length: 30, nullable: true, description: 'The character set used in the job.', values: [{ value: 'Unicode - UTF-8', label: '' }] }, { name: 'IPAddress', type: 'Text', length: 50, nullable: true, description: 'This value will always be a null value.' }, { name: 'SalesForceTotalSubscriberCount', type: 'Number', description: 'The total number of Salesforce subscribers included in the job.' }, { name: 'SalesForceErrorSubscriberCount', type: 'Number', description: 'The total number of Salesforce subscribers included in the job that received errors.' }, { name: 'SendType', type: 'Text', length: 128, description: 'The type of send used in the job.', values: [{ value: 'ExactTargetSend', label: '' }] }, { name: 'DynamicEmailSubject', type: 'Text', nullable: true, description: 'The dynamic email subject included in the job.' }, { name: 'SuppressTracking', type: 'Boolean', description: 'If tracking information for this job was suppressed.' }, { name: 'SendClassificationType', type: 'Text', length: 32, nullable: true, description: 'The type of send classification for the job.', values: [{ value: 'Default Commercial', label: '' }, { value: 'Default Transactional', label: '' }] }, { name: 'SendClassification', type: 'Text', length: 36, nullable: true, description: 'The custom name of send classification for the job.' }, { name: 'ResolveLinksWithCurrentData', type: 'Boolean', description: 'If the job resolved links with current data.' }, { name: 'EmailSendDefinition', type: 'Text', length: 36, nullable: true, description: 'The email send definition used in the job.' }, { name: 'DeduplicateByEmail', type: 'Boolean', description: 'Whether the email addresses in the job are used to deduplicate subscribers.' }, { name: 'TriggererSendDefinitionObjectID', type: 'Text', nullable: true, description: 'The object ID for the triggered send definition.' }, { name: 'TriggeredSendCustomerKey', type: 'Text', nullable: true, description: 'The customer key for the triggered send.' } ] }, { name: '_Sent', alias: 'sent', fullQuery: 'SELECT\n sent.AccountID\n , sent.OYBAccountID\n , sent.JobID\n , sent.ListID\n , sent.BatchID\n , sent.SubscriberID\n , sent.SubscriberKey\n , sent.EventDate\n , sent.Domain\n , sent.TriggererSendDefinitionObjectID\n , sent.TriggeredSendCustomerKey\nFROM _Sent AS sent', description: 'One row per successful send event: who received a given email job and when.', docs: 'https://mateuszdabrowski.pl/docs/salesforce/marketing-cloud-engagement/config/system-data-views/#_sent', officialDocs: 'https://help.salesforce.com/s/articleView?id=mktg.mc_as_data_view_sent.htm&type=5', joinKey: 'JobID, ListID, BatchID, SubscriberID / SubscriberKey', notes: '6-month retention. Readable at the parent level across Business Units via OYBAccountID. Filter by EventDate or JobID to avoid the 30-minute AutoKill timeout on this potentially large view.', fields: [ { name: 'AccountID', type: 'Number', description: 'Your parent account ID (MID) number.' }, { name: 'OYBAccountID', type: 'Number', nullable: true, description: 'The account ID (MID) number of the child Business Unit that owns the data. If queried on parent Business Unit, it will be Null.' }, { name: 'JobID', type: 'Number', description: 'The job ID number for the email send.' }, { name: 'ListID', type: 'Number', description: 'The list ID number for the list used in the send.' }, { name: 'BatchID', type: 'Number', description: 'The batch ID number for any batches used in the send.' }, { name: 'SubscriberID', type: 'Number', description: 'The subscriber ID for the affected subscriber. This number represents the unique ID for each subscriber record.' }, { name: 'SubscriberKey', type: 'Text', length: 254, description: 'The subscriber key for the affected subscriber.' }, { name: 'EventDate', type: 'Date', sqlType: 'datetime', description: 'The date the send took place. A datetime, which keeps milliseconds (org check, 2026-10-14).' }, { name: 'Domain', type: 'Text', length: 128, description: 'The domain at which the send occurred.' }, { name: 'TriggererSendDefinitionObjectID', type: 'Text', nullable: true, description: 'The object ID for the triggered send definition.' }, { name: 'TriggeredSendCustomerKey', type: 'Text', nullable: true, description: 'The customer key for the triggered send.' } ] }, { name: '_Open', alias: 'opened', fullQuery: 'SELECT\n opened.AccountID\n , opened.OYBAccountID\n , opened.JobID\n , opened.ListID\n , opened.BatchID\n , opened.SubscriberID\n , opened.SubscriberKey\n , opened.EventDate\n , opened.Domain\n , opened.IsUnique\n , opened.TriggererSendDefinitionObjectID\n , opened.TriggeredSendCustomerKey\nFROM _Open AS opened', description: 'One row per email open event, based on the tracking pixel loading.', docs: 'https://mateuszdabrowski.pl/docs/salesforce/marketing-cloud-engagement/config/system-data-views/#_open', officialDocs: 'https://help.salesforce.com/s/articleView?id=mktg.mc_as_data_view_open.htm&type=5', joinKey: 'JobID, ListID, BatchID, SubscriberID / SubscriberKey', notes: '6-month retention. Relies on an invisible 1px tracking image loading, so opens can be under-reported when images are blocked. Use IsUnique = 1 to count first opens instead of every reopen.', fields: [ { name: 'AccountID', type: 'Number', description: 'Your parent account ID (MID) number.' }, { name: 'OYBAccountID', type: 'Number', nullable: true, description: 'The account ID (MID) number of the child Business Unit that owns the data. If queried on parent Business Unit, it will be Null.' }, { name: 'JobID', type: 'Number', description: 'The job ID number for the email send.' }, { name: 'ListID', type: 'Number', description: 'The list ID number for the list used in the send.' }, { name: 'BatchID', type: 'Number', description: 'The batch ID number for any batches used in the send.' }, { name: 'SubscriberID', type: 'Number', description: 'The subscriber ID for the affected subscriber. This number represents the unique ID for each subscriber record.' }, { name: 'SubscriberKey', type: 'Text', length: 254, description: 'The subscriber key for the affected subscriber.' }, { name: 'EventDate', type: 'Date', description: 'The date the open took place.' }, { name: 'Domain', type: 'Text', length: 128, description: 'The domain at which the open occurred.' }, { name: 'IsUnique', type: 'Boolean', sqlType: 'bit', nullable: true, description: 'Whether the event is unique or repeated.' }, { name: 'TriggererSendDefinitionObjectID', type: 'Text', nullable: true, description: 'The object ID for the triggered send definition.' }, { name: 'TriggeredSendCustomerKey', type: 'Text', nullable: true, description: 'The customer key for the triggered send.' } ] }, { name: '_Click', alias: 'click', fullQuery: 'SELECT\n click.AccountID\n , click.OYBAccountID\n , click.JobID\n , click.ListID\n , click.BatchID\n , click.SubscriberID\n , click.SubscriberKey\n , click.EventDate\n , click.Domain\n , click.URL\n , click.LinkName\n , click.LinkContent\n , click.IsUnique\n , click.TriggererSendDefinitionObjectID\n , click.TriggeredSendCustomerKey\nFROM _Click AS click', description: 'One row per link click event inside a tracked email.', docs: 'https://mateuszdabrowski.pl/docs/salesforce/marketing-cloud-engagement/config/system-data-views/#_click', officialDocs: 'https://help.salesforce.com/s/articleView?id=mktg.mc_as_data_view_click.htm&type=5', joinKey: 'JobID, ListID, BatchID, SubscriberID / SubscriberKey', notes: '6-month retention. Use IsUnique = 1 to count first clicks. Group by URL rather than LinkContent to avoid splitting one link across its many personalized final values.', fields: [ { name: 'AccountID', type: 'Number', description: 'Your parent account ID (MID) number.' }, { name: 'OYBAccountID', type: 'Number', nullable: true, description: 'The account ID (MID) number of the child Business Unit that owns the data. If queried on parent Business Unit, it will be Null.' }, { name: 'JobID', type: 'Number', description: 'The job ID number for the email send.' }, { name: 'ListID', type: 'Number', description: 'The list ID number for the list used in the send.' }, { name: 'BatchID', type: 'Number', description: 'The batch ID number for any batches used in the send.' }, { name: 'SubscriberID', type: 'Number', description: 'The subscriber ID for the affected subscriber. This number represents the unique ID for each subscriber record.' }, { name: 'SubscriberKey', type: 'Text', length: 254, description: 'The subscriber key for the affected subscriber.' }, { name: 'EventDate', type: 'Date', description: 'The date the click took place.' }, { name: 'Domain', type: 'Text', length: 128, description: 'The domain at which the click occurred.' }, { name: 'URL', type: 'Text', length: 900, nullable: true, description: 'The URL for the link clicked. Dynamic elements are shown as their code, for example a SubID personalization string.' }, { name: 'LinkName', type: 'Text', length: 1024, nullable: true, description: 'The link name assigned in the email send.' }, { name: 'LinkContent', type: 'Text', nullable: true, description: 'The link content assigned in the email send. Dynamic elements are shown as result: www.example.com?id=12345.' }, { name: 'IsUnique', type: 'Boolean', description: 'Whether the event is unique or repeated.' }, { name: 'TriggererSendDefinitionObjectID', type: 'Text', nullable: true, description: 'The object ID for the triggered send definition.' }, { name: 'TriggeredSendCustomerKey', type: 'Text', nullable: true, description: 'The customer key for the triggered send.' } ] }, { name: '_Bounce', alias: 'bounce', fullQuery: 'SELECT\n bounce.AccountID\n , bounce.OYBAccountID\n , bounce.JobID\n , bounce.ListID\n , bounce.BatchID\n , bounce.SubscriberID\n , bounce.SubscriberKey\n , bounce.EventDate\n , bounce.IsUnique\n , bounce.Domain\n , bounce.BounceCategoryID\n , bounce.BounceCategory\n , bounce.BounceSubcategoryID\n , bounce.BounceSubcategory\n , bounce.BounceTypeID\n , bounce.BounceType\n , bounce.SMTPBounceReason\n , bounce.SMTPMessage\n , bounce.SMTPCode\n , bounce.TriggererSendDefinitionObjectID\n , bounce.TriggeredSendCustomerKey\nFROM _Bounce AS bounce', description: 'One row per bounce event, categorized by bounce type and reason.', docs: 'https://mateuszdabrowski.pl/docs/salesforce/marketing-cloud-engagement/config/system-data-views/#_bounce', officialDocs: 'https://help.salesforce.com/s/articleView?id=mktg.mc_as_data_view_bounce.htm&type=5', joinKey: 'JobID, ListID, BatchID, SubscriberID / SubscriberKey', notes: '6-month retention. Watch SMTPCode 541/554 as spam/blocklist indicators. SMTPBounceReason is nvarchar(max); LEFT(SMTPBounceReason, 4000) before writing it to a Data Extension text field. Use IsUnique = 1 to isolate first bounces.', fields: [ { name: 'AccountID', type: 'Number', description: 'Your parent account ID (MID) number.' }, { name: 'OYBAccountID', type: 'Number', nullable: true, description: 'The account ID (MID) number of the child Business Unit that owns the data. If queried on parent Business Unit, it will be Null.' }, { name: 'JobID', type: 'Number', description: 'The job ID number for the email send.' }, { name: 'ListID', type: 'Number', description: 'The list ID number for the list used in the send.' }, { name: 'BatchID', type: 'Number', description: 'The batch ID number for any batches used in the send.' }, { name: 'SubscriberID', type: 'Number', description: 'The subscriber ID for the affected subscriber.' }, { name: 'SubscriberKey', type: 'Text', length: 254, description: 'The subscriber key for the affected subscriber.' }, { name: 'EventDate', type: 'Date', description: 'The date the bounce took place.' }, { name: 'IsUnique', type: 'Boolean', description: 'Whether the event is unique or repeated - 1 for the first occurrence, 0 for subsequent.' }, { name: 'Domain', type: 'Text', length: 128, description: 'The domain at which the bounce occurred.' }, { name: 'BounceCategoryID', type: 'Number', description: 'The ID number for the bounce category.', values: [{ value: '1', label: 'Hard Bounce' }, { value: '2', label: 'Soft Bounce' }, { value: '3', label: 'Block Bounce' }, { value: '4', label: 'Unknown Bounce' }, { value: '5', label: 'Technical/Other Bounce' }] }, { name: 'BounceCategory', type: 'Text', length: 50, nullable: true, description: 'The category of the bounce.', values: [{ value: 'Hard Bounce', label: '' }, { value: 'Soft Bounce', label: '' }, { value: 'Block Bounce', label: '' }, { value: 'Unknown Bounce', label: '' }, { value: 'Technical/Other Bounce', label: '' }] }, { name: 'BounceSubcategoryID', type: 'Number', nullable: true, description: 'The ID number for the bounce subcategory.' }, { name: 'BounceSubcategory', type: 'Text', length: 50, nullable: true, description: 'The subcategory of the bounce.', values: [{ value: 'Complaints', label: '' }, { value: 'Blocked', label: '' }, { value: 'Content', label: '' }, { value: 'Authentication', label: '' }, { value: 'User Unknown', label: '' }, { value: 'Domain Unknown', label: '' }, { value: 'Bad Address Syntax', label: '' }, { value: 'Mailbox Full', label: '' }, { value: 'Inactive Account', label: '' }, { value: 'Server Too Busy', label: '' }, { value: 'Data Format Error', label: '' }, { value: 'Network Error', label: '' }, { value: 'Other', label: '' }, { value: 'Unknown', label: '' }] }, { name: 'BounceTypeID', type: 'Number', description: 'The ID number for the bounce type.', values: [{ value: '0', label: 'immediate' }, { value: '1', label: 'delayed' }] }, { name: 'BounceType', type: 'Text', length: 50, nullable: true, description: 'The type of bounce that occurred.', values: [{ value: 'immediate', label: '' }, { value: 'delayed', label: '' }] }, { name: 'SMTPBounceReason', type: 'Text', nullable: true, description: 'The reason for the bounce relayed by the mail system.' }, { name: 'SMTPMessage', type: 'Text', nullable: true, description: 'The message regarding the bounce from the mail system.' }, { name: 'SMTPCode', type: 'Number', nullable: true, description: 'The error code for the bounce from the mail system.' }, { name: 'TriggererSendDefinitionObjectID', type: 'Text', nullable: true, description: 'The object ID for the triggered send definition.' }, { name: 'TriggeredSendCustomerKey', type: 'Text', nullable: true, description: 'The customer key for the triggered send.' }, { name: 'IsFalseBounce', type: 'Boolean', nullable: true, description: 'Indicates a false bounce if MCE received delivery receipt after receiving bounce.' } ] }, { name: '_Complaint', alias: 'complaint', fullQuery: 'SELECT\n complaint.AccountID\n , complaint.OYBAccountID\n , complaint.JobID\n , complaint.ListID\n , complaint.BatchID\n , complaint.SubscriberID\n , complaint.SubscriberKey\n , complaint.EventDate\n , complaint.IsUnique\n , complaint.Domain\nFROM _Complaint AS complaint', description: 'One row per spam complaint reported through an Email Service Provider Feedback Loop (FBL).', docs: 'https://mateuszdabrowski.pl/docs/salesforce/marketing-cloud-engagement/config/system-data-views/#_complaint', officialDocs: 'https://help.salesforce.com/s/articleView?id=mktg.mc_as_data_view_complaint.htm&type=5', joinKey: 'JobID, ListID, BatchID, SubscriberID / SubscriberKey', notes: '6-month retention. FBL is supported for many mailbox providers but not Yahoo or Gmail, which use separate complaint systems. Complaints also generate a matching row in _Unsubscribe.', fields: [ { name: 'AccountID', type: 'Number', description: 'Your parent account ID (MID) number.' }, { name: 'OYBAccountID', type: 'Number', nullable: true, description: 'The account ID (MID) number of the child Business Unit that owns the data. If queried on parent Business Unit, it will be Null.' }, { name: 'JobID', type: 'Number', description: 'The job ID number for the email send.' }, { name: 'ListID', type: 'Number', description: 'The list ID number for the list used in the send.' }, { name: 'BatchID', type: 'Number', description: 'The batch ID number for any batches used in the send.' }, { name: 'SubscriberID', type: 'Number', description: 'The subscriber ID for the affected subscriber.' }, { name: 'SubscriberKey', type: 'Text', length: 254, description: 'The subscriber key for the affected subscriber.' }, { name: 'EventDate', type: 'Date', description: 'The date the send took place.' }, { name: 'IsUnique', type: 'Boolean', description: 'Whether the event is unique or repeated - 1 for the first occurrence, 0 for subsequent.' }, { name: 'Domain', type: 'Text', length: 128, description: 'The domain at which the send occurred.' } ] }, { name: '_Unsubscribe', alias: 'unsub', fullQuery: 'SELECT\n unsub.AccountID\n , unsub.OYBAccountID\n , unsub.JobID\n , unsub.ListID\n , unsub.BatchID\n , unsub.SubscriberID\n , unsub.SubscriberKey\n , unsub.EventDate\n , unsub.IsUnique\n , unsub.Domain\nFROM _Unsubscribe AS unsub', description: "One row per unsubscribe event: the act of unsubscribing, not the subscriber's current status.", docs: 'https://mateuszdabrowski.pl/docs/salesforce/marketing-cloud-engagement/config/system-data-views/#_unsubscribe', officialDocs: 'https://help.salesforce.com/s/articleView?id=mktg.mc_as_data_view_unsubscribe.htm&type=5', joinKey: 'JobID, ListID, BatchID, SubscriberID / SubscriberKey', notes: '6-month retention. Sources include the List-Unsubscribe header, the LogUnsubEvent API, Reply Mail Management, spam complaints, and the Preference Center. Status changes made via import, API, or a manual UI edit show up in _Subscribers but not here. Use IsUnique = 1 to isolate first events.', fields: [ { name: 'AccountID', type: 'Number', description: 'Your account ID number.' }, { name: 'OYBAccountID', type: 'Number', nullable: true, description: 'The account ID (MID) number of the child Business Unit that owns the data. If queried on parent Business Unit, it will be Null.' }, { name: 'JobID', type: 'Number', description: 'The job ID number for the email send.' }, { name: 'ListID', type: 'Number', description: 'The list ID number for the list used in the send.' }, { name: 'BatchID', type: 'Number', description: 'The batch ID number for any batches used in the send.' }, { name: 'SubscriberID', type: 'Number', description: 'The subscriber ID for the affected subscriber.' }, { name: 'SubscriberKey', type: 'Text', length: 254, description: 'The subscriber key for the affected subscriber.' }, { name: 'EventDate', type: 'Date', description: 'The date the unsubscribe took place.' }, { name: 'IsUnique', type: 'Boolean', description: 'Whether the event is unique or repeated - 1 for the first occurrence, 0 for subsequent.' }, { name: 'Domain', type: 'Text', length: 128, description: 'The domain at which the unsubscribe occurred.' } ] }, { name: '_BusinessUnitUnsubscribes', alias: 'buUnsub', fullQuery: 'SELECT\n buUnsub.BusinessUnitID\n , buUnsub.SubscriberID\n , buUnsub.SubscriberKey\n , buUnsub.UnsubDateUTC\n , buUnsub.UnsubReason\nFROM _BusinessUnitUnsubscribes AS buUnsub', description: 'Current list of contacts unsubscribed at the Business Unit level (not a historical event log).', docs: 'https://mateuszdabrowski.pl/docs/salesforce/marketing-cloud-engagement/config/system-data-views/#_businessunitunsubscribes', officialDocs: 'https://help.salesforce.com/s/articleView?id=mktg.mc_as_data_view_businessunitunsubscribes.htm&type=5', joinKey: 'SubscriberID / SubscriberKey', notes: 'No 6-month retention limit; rows disappear if the contact is deleted or resubscribes. UnsubDateUTC is in UTC, unlike most other views which use Central Standard Time. To match a row here to its _Unsubscribe.EventDate counterpart, compare with a 6-hour offset, e.g. DATEDIFF(MINUTE, u.EventDate, DATEADD(HOUR, -6, b.UnsubDateUTC)) < 1.', fields: [ { name: 'BusinessUnitID', type: 'Number', description: 'The Account ID for the Business Unit.' }, { name: 'SubscriberID', type: 'Number', description: 'The subscriber ID for the affected subscriber.' }, { name: 'SubscriberKey', type: 'Text', length: 254, description: 'The subscriber key for the affected subscriber.' }, { name: 'UnsubDateUTC', type: 'Date', timeZone: 'UTC', nullable: true, description: 'The date the subscriber unsubscribed on the BU.' }, { name: 'UnsubReason', type: 'Text', length: 100, nullable: true, description: 'The reason listed is a custom value configured by your administrator.', values: [{ value: 'Spam Complaint', label: '' }, { value: 'Unsubscribed from list details on subscriber properties dialog.', label: '' }, { value: 'Unsubscribed by Salesforce MCE RMM service based on subscriber Leave/Reply email.', label: '' }, { value: 'Unsubscribed by subscriber edit API call.', label: '' }, { value: 'Unsubscribed from the Subscription Center', label: '' }, { value: 'Unsubscribed via Import', label: '' }] } ] }, { name: '_ListSubscribers', alias: 'listSub', fullQuery: 'SELECT\n listSub.AddedBy\n , listSub.AddMethod\n , listSub.CreatedDate\n , listSub.DateUnsubscribed\n , listSub.EmailAddress\n , listSub.ListID\n , listSub.ListName\n , listSub.ListType\n , listSub.Status\n , listSub.SubscriberID\n , listSub.SubscriberKey\n , listSub.SubscriberType\nFROM _ListSubscribers AS listSub', description: 'Current subscription status of every subscriber across every list and suppression list, not only the All Subscribers list.', docs: 'https://mateuszdabrowski.pl/docs/salesforce/marketing-cloud-engagement/config/system-data-views/#_listsubscribers', officialDocs: 'https://help.salesforce.com/s/articleView?id=mktg.mc_as_data_view_listsubscribers.htm&type=5', joinKey: 'SubscriberID / SubscriberKey, ListID', notes: "No 6-month retention limit. Business-Unit-specific; a parent BU cannot query a child BU's lists here except via the shared All Subscribers list.", fields: [ { name: 'AddedBy', type: 'Number', description: 'The ID of the user who ran the process that added the subscriber (might also be 0 or -1000).' }, { name: 'AddMethod', type: 'Text', length: 17, description: 'The method by which the subscriber was added.', values: [{ value: 'API', label: '' }, { value: 'Imported', label: '' }, { value: 'WebApplication', label: '' }, { value: 'DataExtensionSend', label: '' }, { value: 'Unknown', label: '' }] }, { name: 'CreatedDate', type: 'Date', nullable: true, description: 'The date the subscriber was added to the specific list.' }, { name: 'DateUnsubscribed', type: 'Date', nullable: true, description: 'The date the subscriber unsubscribed.' }, { name: 'EmailAddress', type: 'Text', length: 254, nullable: true, description: "The subscriber's email address." }, { name: 'ListID', type: 'Number', nullable: true, description: 'The list ID number for the list used in the send.' }, { name: 'ListName', type: 'Text', length: 50, nullable: true, description: 'The name of the list containing the subscribers.' }, { name: 'ListType', type: 'Text', length: 16, description: 'Shows whether the type is list or group.', values: [{ value: 'Publication List', label: '' }, { value: 'Suppression List', label: '' }, { value: 'List', label: '' }, { value: 'Group', label: '' }, { value: 'FTAF List', label: '' }, { value: 'Unknown', label: '' }] }, { name: 'Status', type: 'Text', length: 12, nullable: true, description: 'The status of the subscriber.', values: [{ value: 'active', label: '' }, { value: 'held', label: '' }, { value: 'unsubscribed', label: '' }, { value: 'bounced', label: '' }] }, { name: 'SubscriberID', type: 'Number', description: 'The subscriber ID for the affected subscriber.' }, { name: 'SubscriberKey', type: 'Text', length: 254, description: 'The subscriber key for the affected subscriber.' }, { name: 'SubscriberType', type: 'Text', length: 100, nullable: true, description: 'The type of subscriber (it will be ExactTarget).' } ] }, { name: '_ReconcilableDispositionView', alias: 'delivery', fullQuery: 'SELECT\n delivery.JobId\n , delivery.Channel\n , delivery.Disposition\n , delivery.MessageKey\n , delivery.SubscriberKey\n , delivery.SubscriberID\n , delivery.ErrorCodeID\n , delivery.ErrorName\n , delivery.ErrorDescription\n , delivery.StartTime\nFROM _ReconcilableDispositionView AS delivery', description: 'Final delivery status (queued, sent, or not sent) for transactional sends configured for reconciliation.', docs: 'https://mateuszdabrowski.pl/docs/salesforce/marketing-cloud-engagement/config/system-data-views/#_reconcilabledispositionview', officialDocs: 'https://help.salesforce.com/s/articleView?id=mktg.mc_as_data_view_reconcilable_disposition.htm&type=5', joinKey: 'SubscriberKey, MessageKey', notes: 'Only 7 days of retention, far shorter than other views. Requires a sendable reconcilable Data Extension template and transactional reconciliation to be configured. Without that setup, Validate says it is not a known data view. MessageKey lets you match a row back to the source system to prevent duplicate sends.', fields: [ { name: 'JobId', type: 'Number', description: 'The job ID number for the email or SMS send.' }, { name: 'Channel', type: 'Number', description: 'Identifies the channel of the send, either Email or SMS.', values: [{ value: '0', label: 'Email' }, { value: '1', label: 'SMS' }] }, { name: 'Disposition', type: 'Number', description: 'Stores final delivery status for the transactional message.', values: [{ value: '0', label: 'Queued' }, { value: '1', label: 'Sent' }, { value: '2', label: 'NotSent' }] }, { name: 'MessageKey', type: 'Text', length: 100, description: 'Stores unique message key for the send.' }, { name: 'SubscriberKey', type: 'Text', length: 254, description: 'The subscriber key for the affected subscriber.' }, { name: 'SubscriberID', type: 'Number', nullable: true, description: 'The subscriber ID for the affected subscriber. This number represents the unique ID for each subscriber record.' }, { name: 'ErrorCodeID', type: 'Number', nullable: true, description: 'The error code ID for the send.' }, { name: 'ErrorName', type: 'Text', nullable: true, description: 'The error name for the send.' }, { name: 'ErrorDescription', type: 'Text', nullable: true, description: 'The error description for the send.' }, { name: 'StartTime', type: 'Date', description: 'The datetime of transactional message trigger.' } ] }, { name: '_Journey', alias: 'journey', fullQuery: 'SELECT\n journey.VersionID\n , journey.JourneyID\n , journey.JourneyName\n , journey.VersionNumber\n , journey.CreatedDate\n , journey.LastPublishedDate\n , journey.ModifiedDate\n , journey.JourneyStatus\nFROM _Journey AS journey', description: 'Metadata and version history for every Journey (Journey Builder canvas).', docs: 'https://mateuszdabrowski.pl/docs/salesforce/marketing-cloud-engagement/config/system-data-views/#_journey', officialDocs: 'https://help.salesforce.com/s/articleView?id=mktg.mc_as_data_view_journey.htm&type=5', joinKey: 'VersionID', notes: 'Business-Unit-specific. 6-month retention. Use VersionID to join to _JourneyActivity; use JourneyStatus in WHERE/CASE logic to focus reporting on Running journeys.', fields: [ { name: 'VersionID', type: 'Text', description: 'The unique identifier for the version of the Journey.' }, { name: 'JourneyID', type: 'Text', description: "The unique identifier for the Journey. There are one or more VersionID's associated to a JourneyID." }, { name: 'JourneyName', type: 'Text', length: 200, description: 'The name of the Journey.' }, { name: 'VersionNumber', type: 'Number', description: 'The version number of the version of the Journey.' }, { name: 'CreatedDate', type: 'Date', description: 'The date that the version of the Journey was created.' }, { name: 'LastPublishedDate', type: 'Date', nullable: true, description: 'The date that the version of the Journey was last published.' }, { name: 'ModifiedDate', type: 'Date', description: 'The date that the version of the Journey was last edited.' }, { name: 'JourneyStatus', type: 'Text', length: 100, description: 'The current running mode of the Journey.', values: [{ value: 'Draft', label: '' }, { value: 'Running', label: '' }, { value: 'Finishing', label: '' }, { value: 'Stopped', label: '' }, { value: 'Deleted', label: '' }] } ] }, { name: '_JourneyActivity', alias: 'journeyActivity', fullQuery: 'SELECT\n journeyActivity.VersionID\n , journeyActivity.ActivityID\n , journeyActivity.ActivityName\n , journeyActivity.ActivityExternalKey\n , journeyActivity.JourneyActivityObjectID\n , journeyActivity.ActivityType\nFROM _JourneyActivity AS journeyActivity', description: 'One row per activity (email, wait, decision split, API event, ...) defined on a Journey canvas.', docs: 'https://mateuszdabrowski.pl/docs/salesforce/marketing-cloud-engagement/config/system-data-views/#_journeyactivity', officialDocs: 'https://help.salesforce.com/s/articleView?id=mktg.mc_as_data_view_journey_activity.htm&type=5', joinKey: 'VersionID, ActivityID', notes: "Business-Unit-specific. 6-month retention. JourneyActivityObjectID maps to TriggererSendDefinitionObjectID on _Job/_Sent/_Open/_Click/_Bounce, which is how a Journey's send activities link to engagement data. VersionID joins back to _Journey.", fields: [ { name: 'VersionID', type: 'Text', description: 'The unique identifier for the version of the Journey.' }, { name: 'ActivityID', type: 'Text', description: "The unique identifier for the activity. There are one or more ActivityID's associated to a VersionID." }, { name: 'ActivityName', type: 'Text', length: 200, nullable: true, description: 'The name of the activity.' }, { name: 'ActivityExternalKey', type: 'Text', length: 200, description: 'The external key associated with the activity.' }, { name: 'JourneyActivityObjectID', type: 'Text', nullable: true, description: "Use this unique identifier to join to email tracking system Data Views to identify a Journey email's Triggered Send Definition." }, { name: 'ActivityType', type: 'Text', length: 512, nullable: true, description: 'The type of activity.', values: [{ value: 'null', label: '' }, { value: 'APIEvent', label: '' }, { value: 'Audience', label: '' }, { value: 'AutomationAudience', label: '' }, { value: 'CloudPagesSmartCaptureSubmissionEvent', label: '' }, { value: 'ContactEvent', label: '' }, { value: 'EMAILAUDIENCE', label: '' }, { value: 'EMAILV2', label: '' }, { value: 'ENGAGEMENTDECISION', label: '' }, { value: 'Event', label: '' }, { value: 'ExitCriteria', label: '' }, { value: 'MULTICRITERIADECISION', label: '' }, { value: 'NOOP', label: '' }, { value: 'PUSHNOTIFICATIONACTIVITY', label: '' }, { value: 'RANDOMSPLIT', label: '' }, { value: 'SENDTOLINESYNC', label: '' }, { value: 'StartActivity', label: '' }, { value: 'STOWAIT', label: '' }, { value: 'transactional-api', label: '' }, { value: 'UPDATECONTACTDATA', label: '' }, { value: 'WAIT', label: '' }] } ] }, { name: '_AutomationInstance', alias: 'automation', fullQuery: 'SELECT\n automation.MemberID\n , automation.AutomationName\n , automation.AutomationDescription\n , automation.AutomationCustomerKey\n , automation.AutomationInstanceID\n , automation.AutomationType\n , automation.AutomationNotificationRecipient_Complete\n , automation.AutomationNotificationRecipient_Error\n , automation.AutomationNotificationRecipient_Skip\n , automation.AutomationStepCount\n , automation.AutomationInstanceIsRunOnce\n , automation.FilenameFromTrigger\n , automation.AutomationInstanceScheduledTime_UTC\n , automation.AutomationInstanceStartTime_UTC\n , automation.AutomationInstanceEndTime_UTC\n , automation.AutomationInstanceStatus\n , automation.AutomationInstanceActivityErrorDetails\nFROM _AutomationInstance AS automation', description: 'One row per historical run of an Automation Studio automation.', docs: 'https://mateuszdabrowski.pl/docs/salesforce/marketing-cloud-engagement/config/system-data-views/#_automationinstance', officialDocs: 'https://help.salesforce.com/s/articleView?id=mktg.mc_as_data_view_automation_instance.htm&type=5', joinKey: 'AutomationInstanceID', notes: 'Business-Unit-specific. Dates are UTC; compare with GETUTCDATE(). AutomationInstanceID joins to _AutomationActivityInstance.', fields: [ { name: 'MemberID', type: 'Number', description: 'The Account ID for the Business Unit.' }, { name: 'AutomationName', type: 'Text', description: 'The name of the automation.' }, { name: 'AutomationDescription', type: 'Text', nullable: true, description: 'The description of the automation.' }, { name: 'AutomationCustomerKey', type: 'Text', description: 'The unique ID of the automation.' }, { name: 'AutomationInstanceID', type: 'Text', description: 'The unique ID of the specific automation run.' }, { name: 'AutomationType', type: 'Text', description: 'The type of starting source used in automation.', values: [{ value: 'Schedule', label: '' }, { value: 'File Drop', label: '' }, { value: 'Trigger', label: '' }] }, { name: 'AutomationNotificationRecipient_Complete', type: 'Text', nullable: true, description: 'Email(s) receiving notification about completed runs.' }, { name: 'AutomationNotificationRecipient_Error', type: 'Text', nullable: true, description: 'Email(s) receiving notification about errored runs.' }, { name: 'AutomationNotificationRecipient_Skip', type: 'Text', nullable: true, description: 'Email(s) receiving notification about skipped runs.' }, { name: 'AutomationStepCount', type: 'Number', description: 'The number of steps in the automation.' }, { name: 'AutomationInstanceIsRunOnce', type: 'Boolean', description: 'Was this specific automation run executed with Run Once? 1 for true, 0 for false in File Drop and Triggered automations. Null in Schedule automations.' }, { name: 'FilenameFromTrigger', type: 'Text', nullable: true, description: 'Name of the file that triggered the automation for Triggered and File Drop automations. Null for Schedule automations.' }, { name: 'AutomationInstanceScheduledTime_UTC', type: 'Date', timeZone: 'UTC', nullable: true, description: 'Starting schedule datetime in UTC timezone for Schedule automations. Null for Triggered and File Drop automation.' }, { name: 'AutomationInstanceStartTime_UTC', type: 'Date', timeZone: 'UTC', nullable: true, description: 'Automation run start datetime in UTC timezone. Null if run is skipped.' }, { name: 'AutomationInstanceEndTime_UTC', type: 'Date', timeZone: 'UTC', nullable: true, description: 'Automation run end datetime in UTC timezone. Null if run is skipped or still running.' }, { name: 'AutomationInstanceStatus', type: 'Text', description: 'The status of the automation run at the time of querying.', values: [{ value: 'QueuedFile', label: '' }, { value: 'Initialized', label: '' }, { value: 'Executing', label: '' }, { value: 'Stopped', label: '' }, { value: 'Complete', label: '' }, { value: 'Error', label: '' }] }, { name: 'AutomationInstanceActivityErrorDetails', type: 'Text', nullable: true, description: 'The first error message encountered in the automation run.' } ] }, { name: '_AutomationActivityInstance', alias: 'automationActivity', fullQuery: 'SELECT\n automationActivity.MemberID\n , automationActivity.JobID\n , automationActivity.AutomationName\n , automationActivity.AutomationCustomerKey\n , automationActivity.AutomationInstanceID\n , automationActivity.ActivityCustomerKey\n , automationActivity.ActivityInstanceID\n , automationActivity.ActivityType\n , automationActivity.ActivityName\n , automationActivity.ActivityDescription\n , automationActivity.ActivityInstanceStep\n , automationActivity.ActivityInstanceStartTime_UTC\n , automationActivity.ActivityInstanceEndTime_UTC\n , automationActivity.ActivityInstanceStatus\n , automationActivity.ActivityInstanceStatusDetails\nFROM _AutomationActivityInstance AS automationActivity', description: 'One row per activity execution within an automation run, useful for finding which step failed or ran slowly.', docs: 'https://mateuszdabrowski.pl/docs/salesforce/marketing-cloud-engagement/config/system-data-views/#_automationactivityinstance', officialDocs: 'https://help.salesforce.com/s/articleView?id=mktg.mc_as_data_view_automation_activity_instance.htm&type=5', joinKey: 'AutomationInstanceID, ActivityInstanceID', notes: 'Business-Unit-specific. Dates are UTC; compare with GETUTCDATE(). AutomationInstanceID joins back to _AutomationInstance; JobID joins to _Job for email-send steps.', fields: [ { name: 'MemberID', type: 'Number', description: 'The Account ID for the Business Unit.' }, { name: 'JobID', type: 'Number', description: 'The job ID number for the email send.' }, { name: 'AutomationName', type: 'Text', description: 'The name of the automation.' }, { name: 'AutomationCustomerKey', type: 'Text', description: 'The unique ID of the automation.' }, { name: 'AutomationInstanceID', type: 'Text', description: 'The unique ID of the specific automation run.' }, { name: 'ActivityCustomerKey', type: 'Text', description: 'The unique ID of the activity.' }, { name: 'ActivityInstanceID', type: 'Text', description: 'The unique ID of the specific activity execution.' }, { name: 'ActivityType', type: 'Number', description: 'The type of the activity (numeric ID).', values: [{ value: '33', label: 'SMS Activity' }, { value: '42', label: 'Send Email' }, { value: '43', label: 'Import File' }, { value: '45', label: 'Refresh Group' }, { value: '53', label: 'File Transfer' }, { value: '73', label: 'Data Extract' }, { value: '84', label: 'Report Definition' }, { value: '300', label: 'SQL Query' }, { value: '303', label: 'Filter' }, { value: '423', label: 'Script' }, { value: '425', label: 'Data Factory Utility Activity' }, { value: '426', label: 'Refresh Segment Template' }, { value: '427', label: 'Publish Audience' }, { value: '467', label: 'Wait' }, { value: '724', label: 'Refresh Mobile Filtered List' }, { value: '725', label: 'Send SMS' }, { value: '726', label: 'Import Mobile Contacts' }, { value: '733', label: 'Journey Builder Event Activity' }, { value: '736', label: 'Send Push' }, { value: '749', label: 'Fire Event' }, { value: '771', label: 'Salesforce Email Send' }, { value: '772', label: 'Mobile Connect Send Salesforce Sync Subscriber' }, { value: '783', label: 'Send GroupConnect' }, { value: '1000', label: 'Verification' }, { value: '1010', label: 'Interaction Studio Data' }, { value: '1101', label: 'Interactions' }, { value: '1701', label: 'Batch Personalization' }, { value: '3700', label: 'Contact to Business Unit Mapping' }] }, { name: 'ActivityName', type: 'Text', description: 'The name of the activity.' }, { name: 'ActivityDescription', type: 'Text', nullable: true, description: 'The description of the activity.' }, { name: 'ActivityInstanceStep', type: 'Text', description: 'The two-dimensional description where in automation the activity is located.' }, { name: 'ActivityInstanceStartTime_UTC', type: 'Date', timeZone: 'UTC', nullable: true, description: 'Activity execution start datetime in UTC timezone. Null if run is skipped.' }, { name: 'ActivityInstanceEndTime_UTC', type: 'Date', timeZone: 'UTC', nullable: true, description: 'Activity execution end datetime in UTC timezone. Null if run is skipped or still running.' }, { name: 'ActivityInstanceStatus', type: 'Text', description: 'The status of the activity run at the time of querying.', values: [{ value: 'Initialized', label: '' }, { value: 'Executing', label: '' }, { value: 'Complete', label: '' }, { value: 'Error', label: '' }, { value: 'NotSelected', label: '' }] }, { name: 'ActivityInstanceStatusDetails', type: 'Text', length: 4000, nullable: true, description: 'The error message encountered in the activity execution.' } ] }, { name: '_MobileAddress', alias: 'mobileAddress', fullQuery: 'SELECT\n mobileAddress._MobileID AS MobileID\n , mobileAddress._ContactID AS ContactID\n , mobileAddress._MobileNumber AS MobileNumber\n , mobileAddress._Status AS Status\n , mobileAddress._Source AS Source\n , mobileAddress._SourceObjectId AS SourceObjectId\n , mobileAddress._Priority AS Priority\n , mobileAddress._Channel AS Channel\n , mobileAddress._CarrierID AS CarrierID\n , mobileAddress._CountryCode AS CountryCode\n , mobileAddress._CreatedDate AS CreatedDate\n , mobileAddress._CreatedBy AS CreatedBy\n , mobileAddress._ModifiedDate AS ModifiedDate\n , mobileAddress._ModifiedBy AS ModifiedBy\n , mobileAddress._City AS City\n , mobileAddress._State AS State\n , mobileAddress._ZipCode AS ZipCode\n , mobileAddress._FirstName AS FirstName\n , mobileAddress._LastName AS LastName\n , mobileAddress._UTCOffset AS UTCOffset\n , mobileAddress._IsHonorDST AS IsHonorDST\nFROM _MobileAddress AS mobileAddress', description: 'Mobile Connect contact record: one row per mobile number known to MobileConnect, with source, carrier and locale metadata.', docs: 'https://mateuszdabrowski.pl/docs/salesforce/marketing-cloud-engagement/config/mobile-connect-data-views/#_mobileaddress', joinKey: '_ContactID, _MobileNumber', notes: 'Officially unsupported by Salesforce but functional in practice. _SMSMessageTracking joins to this view on Mobile = _MobileNumber.', fields: [ { name: '_MobileID', type: 'Number', untyped: true, nullable: true, description: 'MobileConnect Contact ID.' }, { name: '_ContactID', type: 'Number', untyped: true, description: 'Global Contact ID (not Contact Key).' }, { name: '_MobileNumber', type: 'Text', length: 15, description: 'Mobile number for the subscriber.' }, { name: '_Status', type: 'Number', nullable: true, description: 'Status of the Contact.', values: [{ value: '1', label: 'Active' }, { value: '2', label: 'Inactive' }, { value: '3', label: 'Deleted' }] }, { name: '_Source', type: 'Number', nullable: true, description: 'Source of the Contact.', values: [{ value: '0', label: 'Unspecified' }, { value: '1', label: 'Webcollect' }, { value: '2', label: 'API' }, { value: '3', label: 'FTAF' }, { value: '4', label: 'Import' }, { value: '5', label: 'MoveCopy' }, { value: '6', label: 'Manual' }, { value: '7', label: 'SalesForce' }, { value: '8', label: 'Segmentation' }, { value: '9', label: 'GenericExtension' }, { value: '10', label: 'CustomObject' }, { value: '11', label: 'FacebookAPI' }, { value: '12', label: 'SmartCapture' }, { value: '13', label: 'MobileOptIn' }] }, { name: '_SourceObjectId', type: 'Text', length: 200, nullable: true, description: 'ID of the object if _Source = 10.' }, { name: '_Priority', type: 'Number', nullable: true, description: "In case the contact uses multiple mobile numbers, the Priority field's value establishes the order in which to use those mobile numbers. _Priority = 1 is first in order." }, { name: '_Channel', type: 'Text', length: 20, nullable: true, description: 'Communication channel. Used to be Mobile, now not in use.' }, { name: '_CarrierID', type: 'Number', description: 'Numeric code for the mobile carrier used by the contact.' }, { name: '_CountryCode', type: 'Text', length: 2, description: 'Two letter country code.' }, { name: '_CreatedDate', type: 'Date', description: 'Date the subscription was created.' }, { name: '_CreatedBy', type: 'Text', description: 'Person responsible for contact creation.' }, { name: '_ModifiedDate', type: 'Date', description: 'Date the subscription was modified.' }, { name: '_ModifiedBy', type: 'Text', nullable: true, description: 'Person responsible for contact modification.' }, { name: '_City', type: 'Text', length: 200, nullable: true, description: "Contact's City." }, { name: '_State', type: 'Text', length: 200, nullable: true, description: "Contact's State." }, { name: '_ZipCode', type: 'Text', length: 20, nullable: true, description: "Contact's Zip Code." }, { name: '_FirstName', type: 'Text', length: 100, nullable: true, description: "Contact's First Name." }, { name: '_LastName', type: 'Text', length: 100, nullable: true, description: "Contact's Last Name." }, { name: '_UTCOffset', type: 'Decimal', precision: 4, scale: 2, nullable: true, description: "The number of hours from which the user's time zone deviates from UTC. Defaults to 0." }, { name: '_IsHonorDST', type: 'Boolean', nullable: true, description: "Whether the user's time zone observes Daylight Saving Time or not. Defaults to false." } ] }, { name: '_MobileSubscription', alias: 'mobileSub', fullQuery: 'SELECT\n mobileSub._SubscriptionDefinitionID AS SubscriptionDefinitionID\n , mobileSub._MobileNumber AS MobileNumber\n , mobileSub._OptOutStatusID AS OptOutStatusID\n , mobileSub._OptOutMethodID AS OptOutMethodID\n , mobileSub._OptOutDate AS OptOutDate\n , mobileSub._OptInStatusID AS OptInStatusID\n , mobileSub._OptInMethodID AS OptInMethodID\n , mobileSub._OptInDate AS OptInDate\n , mobileSub._Source AS Source\n , mobileSub._CreatedDate AS CreatedDate\n , mobileSub._CreatedBy AS CreatedBy\n , mobileSub._ModifiedDate AS ModifiedDate\n , mobileSub._ModifiedBy AS ModifiedBy\nFROM _MobileSubscription AS mobileSub', description: 'Legacy MobileConnect subscription (opt-in/opt-out) log, superseded by _SMSSubscriptionLog.', docs: 'https://mateuszdabrowski.pl/docs/salesforce/marketing-cloud-engagement/config/mobile-connect-data-views/#_mobilesubscription', joinKey: '_MobileNumber', notes: 'Officially unsupported; use _SMSSubscriptionLog instead for new development.', fields: [ { name: '_SubscriptionDefinitionID', type: 'Text', description: 'The unique ID of the subscription record.' }, { name: '_MobileNumber', type: 'Text', length: 15, description: 'Mobile number for the subscriber.' }, { name: '_OptOutStatusID', type: 'Text', nullable: true, description: 'Whether the subscriber has opted out of SMS messages.', values: [{ value: '0', label: 'NotOptedOut' }, { value: '1', label: 'OptedOut' }, { value: '10', label: 'DeletingNotOptedOut' }, { value: '11', label: 'DeletingOptedOut' }] }, { name: '_OptOutMethodID', type: 'Text', nullable: true, description: 'Method the subscriber used to opt-out of SMS messages.', values: [{ value: '4', label: 'Import' }, { value: '6', label: 'Application' }, { value: '14', label: 'MobileOriginated' }, { value: '15', label: 'Suppression' }, { value: 'NULL', label: 'Subscriber texted an Opt-Out Keyword' }] }, { name: '_OptOutDate', type: 'Date', nullable: true, description: 'Date, the subscriber, opted out of SMS messages.' }, { name: '_OptInStatusID', type: 'Text', description: 'Whether the subscriber has opted-in to the SMS messages.', values: [{ value: '0', label: 'NotOptedIn' }, { value: '1', label: 'OptInPending' }, { value: '2', label: 'OptedIn' }, { value: '10', label: 'DeletingNotOptedIn' }, { value: '11', label: 'DeletingOptInPending' }, { value: '12', label: 'DeletingOptedIn' }] }, { name: '_OptInMethodID', type: 'Text', nullable: true, description: 'Method the subscriber used to opt-in to the SMS messages.', values: [{ value: '0', label: 'Unspecified' }, { value: '1', label: 'WebCollect' }, { value: '2', label: 'API' }, { value: '3', label: 'FTAF' }, { value: '4', label: 'Import' }, { value: '5', label: 'MoveCopy' }, { value: '6', label: 'Application' }, { value: '7', label: 'SalesForce' }, { value: '8', label: 'Segmentation' }, { value: '9', label: 'GenericExtension' }, { value: '10', label: 'CustomObject' }, { value: '11', label: 'RMM' }, { value: '12', label: 'Mobile Opt-In' }, { value: '13', label: 'DeviceRegistration' }] }, { name: '_OptInDate', type: 'Date', nullable: true, description: 'Date, the subscriber, opted-in to the SMS messages.' }, { name: '_Source', type: 'Text', nullable: true, description: 'The subscription source.', values: [{ value: '2', label: 'API' }, { value: '4', label: 'Import' }, { value: '6', label: 'Application' }, { value: '10', label: 'CustomObject' }, { value: '12', label: 'MobileOptIn' }] }, { name: '_CreatedDate', type: 'Date', description: 'Date the subscription was created.' }, { name: '_CreatedBy', type: 'Text', nullable: true, description: 'Person responsible for subscription creation.' }, { name: '_ModifiedDate', type: 'Date', description: 'Date the subscription was modified.' }, { name: '_ModifiedBy', type: 'Text', nullable: true, description: 'Person responsible for the modification.' } ] }, { name: '_SubscriberSMS', alias: 'smsSub', fullQuery: 'SELECT\n smsSub.SubscriberID\n , smsSub.SubscriberKey\n , smsSub.MobileNumber\n , smsSub.TransactionalOptIn\n , smsSub.TransactionalOptInDate\n , smsSub.TransactionalOptOutDate\n , smsSub.MarketingOptIn\n , smsSub.MarketingOptInDate\n , smsSub.MarketingOptOutDate\n , smsSub.SMSCarrierName\nFROM _SubscriberSMS AS smsSub', description: "Legacy view of a subscriber's transactional and marketing SMS opt-in/opt-out state.", docs: 'https://mateuszdabrowski.pl/docs/salesforce/marketing-cloud-engagement/config/mobile-connect-data-views/#_subscribersms', joinKey: 'SubscriberID / SubscriberKey', notes: 'Legacy and not recommended for new development; prefer _SMSSubscriptionLog and _SMSMessageTracking.', fields: [ { name: 'SubscriberID', type: 'Number', description: 'The subscriber ID for the affected subscriber.' }, { name: 'SubscriberKey', type: 'Text', length: 254, description: 'The subscriber key for the affected subscriber.' }, { name: 'MobileNumber', type: 'Phone', description: 'Mobile number for the subscriber.' }, { name: 'TransactionalOptIn', type: 'Boolean', description: 'Whether the subscriber has opted-in to Transactional SMS messages.' }, { name: 'TransactionalOptInDate', type: 'Date', description: 'Date, the subscriber, opted-in to Transactional SMS messages.' }, { name: 'TransactionalOptOutDate', type: 'Date', nullable: true, description: 'Date, the subscriber, opted out of Transactional SMS messages.' }, { name: 'MarketingOptIn', type: 'Boolean', description: 'Whether the subscriber has opted-in to Marketing SMS messages.' }, { name: 'MarketingOptInDate', type: 'Date', description: 'Date, the subscriber, opted-in to Marketing SMS messages.' }, { name: 'MarketingOptOutDate', type: 'Date', nullable: true, description: 'Date, the subscriber, opted out of Marketing SMS messages.' }, { name: 'SMSCarrierName', type: 'Text', nullable: true, description: 'Name of the SMS Carrier used for delivery.' } ] }, { name: '_SMSSubscriptionLog', alias: 'smsSubLog', fullQuery: 'SELECT\n smsSubLog.LogDate\n , smsSubLog.SubscriberKey\n , smsSubLog.MobileSubscriptionID\n , smsSubLog.SubscriptionDefinitionID\n , smsSubLog.MobileNumber\n , smsSubLog.OptOutStatusID\n , smsSubLog.OptOutMethodID\n , smsSubLog.OptOutDate\n , smsSubLog.OptInStatusID\n , smsSubLog.OptInMethodID\n , smsSubLog.OptInDate\n , smsSubLog.Source\n , smsSubLog.CreatedDate\n , smsSubLog.ModifiedDate\nFROM _SMSSubscriptionLog AS smsSubLog', description: 'Current, supported log of SMS opt-in/opt-out events per keyword subscription; the replacement for _MobileSubscription.', docs: 'https://mateuszdabrowski.pl/docs/salesforce/marketing-cloud-engagement/config/mobile-connect-data-views/#_smssubscriptionlog', officialDocs: 'https://help.salesforce.com/s/articleView?id=mktg.mc_as_data_view_sms_subscription_log.htm&type=5', joinKey: 'SubscriberKey, SubscriptionDefinitionID', fields: [ { name: 'LogDate', type: 'Date', nullable: true, description: 'The date the subscription is logged.' }, { name: 'SubscriberKey', type: 'Text', length: 254, description: 'The subscriber key for the affected subscriber.' }, { name: 'MobileSubscriptionID', type: 'Number', untyped: true, description: 'The unique ID of the subscription record.' }, { name: 'SubscriptionDefinitionID', type: 'Text', description: 'The unique Keyword ID (GUID()).' }, { name: 'MobileNumber', type: 'Phone', description: 'Mobile number for the subscriber.' }, { name: 'OptOutStatusID', type: 'Number', nullable: true, description: 'Whether the subscriber has opted out of SMS messages.', values: [{ value: '0', label: 'NotOptedOut' }, { value: '1', label: 'OptedOut' }, { value: '10', label: 'DeletingNotOptedOut' }, { value: '11', label: 'DeletingOptedOut' }] }, { name: 'OptOutMethodID', type: 'Number', nullable: true, description: 'Method the subscriber used to opt-out of SMS messages.', values: [{ value: '4', label: 'Import' }, { value: '6', label: 'Application' }, { value: '14', label: 'MobileOriginated' }, { value: '15', label: 'Suppression' }, { value: 'NULL', label: 'Subscriber texted an Opt-Out Keyword' }] }, { name: 'OptOutDate', type: 'Date', nullable: true, description: 'Date, the subscriber, opted out of SMS messages.' }, { name: 'OptInStatusID', type: 'Number', description: 'Whether the subscriber has opted-in to the SMS message.', values: [{ value: '0', label: 'NotOptedIn' }, { value: '1', label: 'OptInPending' }, { value: '2', label: 'OptedIn' }, { value: '10', label: 'DeletingNotOptedIn' }, { value: '11', label: 'DeletingOptInPending' }, { value: '12', label: 'DeletingOptedIn' }] }, { name: 'OptInMethodID', type: 'Number', nullable: true, description: 'Method the subscriber used to opt-in to the SMS messages.', values: [{ value: '0', label: 'Unspecified' }, { value: '1', label: 'WebCollect' }, { value: '2', label: 'API' }, { value: '3', label: 'FTAF' }, { value: '4', label: 'Import' }, { value: '5', label: 'MoveCopy' }, { value: '6', label: 'Application' }, { value: '7', label: 'SalesForce' }, { value: '8', label: 'Segmentation' }, { value: '9', label: 'GenericExtension' }, { value: '10', label: 'CustomObject' }, { value: '11', label: 'RMM' }, { value: '12', label: 'Mobile Opt-In' }, { value: '13', label: 'DeviceRegistration' }] }, { name: 'OptInDate', type: 'Date', nullable: true, description: 'Date, the subscriber, opted-in to the SMS messages.' }, { name: 'Source', type: 'Number', nullable: true, description: 'The subscription source.', values: [{ value: '2', label: 'API' }, { value: '4', label: 'Import' }, { value: '6', label: 'Application' }, { value: '10', label: 'CustomObject' }, { value: '12', label: 'MobileOptIn' }] }, { name: 'CreatedDate', type: 'Date', description: 'Date the subscription was created.' }, { name: 'ModifiedDate', type: 'Date', description: 'Date the subscription was modified.' } ] }, { name: '_SMSMessageTracking', alias: 'smsTracking', fullQuery: 'SELECT\n smsTracking.MobileMessageTrackingID\n , smsTracking.EID\n , smsTracking.MID\n , smsTracking.Mobile\n , smsTracking.MessageID\n , smsTracking.MessageTypeID\n , smsTracking.KeywordID\n , smsTracking.CodeID\n , smsTracking.ConversationID\n , smsTracking.CampaignID\n , smsTracking.Sent\n , smsTracking.Delivered\n , smsTracking.Undelivered\n , smsTracking.Outbound\n , smsTracking.Inbound\n , smsTracking.CreateDateTime\n , smsTracking.ModifiedDateTime\n , smsTracking.ActionDateTime\n , smsTracking.MessageText\n , smsTracking.IsTest\n , smsTracking.MobileMessageRecurrenceID\n , smsTracking.ResponseToMobileMessageTrackingID\n , smsTracking.IsValid\n , smsTracking.InvalidationCode\n , smsTracking.SMSJobID\n , smsTracking.SMSBatchID\n , smsTracking.SendDateTime\n , smsTracking.DeliveredDateTime\n , smsTracking.SendID\n , smsTracking.SendSplitID\n , smsTracking.SendSegmentID\n , smsTracking.SendJobID\n , smsTracking.SendGroupID\n , smsTracking.SendPersonID\n , smsTracking.SubscriberID\n , smsTracking.SubscriberKey\n , smsTracking.SMSStandardStatusCodeId\n , smsTracking.Description\n , smsTracking.Name\n , smsTracking.ShortCode\n , smsTracking.SharedKeyword\n , smsTracking.Ordinal\n , smsTracking.FromName\n , smsTracking.ConversationStateID\n , smsTracking.JBDefinitionID\n , smsTracking.JBActivityID\nFROM _SMSMessageTracking AS smsTracking', description: 'One row per SMS message sent or received through MobileConnect, with delivery status and journey linkage.', docs: 'https://mateuszdabrowski.pl/docs/salesforce/marketing-cloud-engagement/config/mobile-connect-data-views/#_smsmessagetracking', officialDocs: 'https://help.salesforce.com/s/articleView?id=mktg.mc_as_data_view_sms_message_tracking.htm&type=5', joinKey: 'SubscriberID / SubscriberKey, Mobile', notes: 'Dates are Central Standard Time, no DST. JBDefinitionID/JBActivityID link to _Journey.VersionID and _JourneyActivity.ActivityID for SMS steps inside a Journey.', fields: [ { name: 'MobileMessageTrackingID', type: 'Number', untyped: true, description: 'Unique Tracking ID - one for each SMS sent.' }, { name: 'EID', type: 'Number', nullable: true, description: 'Enterprise ID number for the sender.' }, { name: 'MID', type: 'Number', nullable: true, description: 'Member ID (Business Unit ID).' }, { name: 'Mobile', type: 'Phone', length: 15, description: 'Mobile number for the subscriber.' }, { name: 'MessageID', type: 'Number', untyped: true, description: 'Unique Mobile Message ID.' }, { name: 'MessageTypeID', type: 'Number', description: 'Identifies the type of message for the tracking record. Returns 0 when undefined.' }, { name: 'KeywordID', type: 'Text', nullable: true, description: 'Unique identifier for the keyword (GUID).' }, { name: 'CodeID', type: 'Text', nullable: true, description: 'Unique identifier for the SMS code (GUID).' }, { name: 'ConversationID', type: 'Text', nullable: true, description: 'Unique identifier for the SMS conversation. Currently always null.' }, { name: 'CampaignID', type: 'Number', untyped: true, nullable: true, description: 'The SMS campaign tied to the record.' }, { name: 'Sent', type: 'Boolean', description: 'If the message was sent.' }, { name: 'Delivered', type: 'Boolean', nullable: true, description: 'If the message was delivered.' }, { name: 'Undelivered', type: 'Boolean', nullable: true, description: 'Whether the message was delivered successfully or not.' }, { name: 'Outbound', type: 'Boolean', nullable: true, description: 'If the message was outgoing.' }, { name: 'Inbound', type: 'Boolean', nullable: true, description: 'If the message was incoming.' }, { name: 'CreateDateTime', type: 'Date', description: 'The date and time the tracking record was created.' }, { name: 'ModifiedDateTime', type: 'Date', description: 'The date and CST time the tracking record was modified.' }, { name: 'ActionDateTime', type: 'Date', description: 'The actual date/time the MCE received a delivery or non-delivery event. Timestamps for delivery receipts are not indicative of when a message was received on a handset. The SMS industry does not support read receipts, and the data returned in this view are based on when a delivery receipt is returned to our system.' }, { name: 'MessageText', type: 'Text', length: 160, nullable: true, description: 'The text of the message.' }, { name: 'IsTest', type: 'Boolean', nullable: true, description: 'If the message was a test message.' }, { name: 'MobileMessageRecurrenceID', type: 'Number', untyped: true, nullable: true, description: 'The ID of the recurrence schedule for the message. Can be used for troubleshooting send issues.' }, { name: 'ResponseToMobileMessageTrackingID', type: 'Number', untyped: true, nullable: true, description: 'The tracking ID of the response to the message.' }, { name: 'IsValid', type: 'Boolean', nullable: true, description: 'Populates to 1 for inbound message and 0 for Outbound message.' }, { name: 'InvalidationCode', type: 'Number', nullable: true, description: 'Invalidation code for the message. Currently always null.' }, { name: 'SMSJobID', type: 'Text', nullable: true, description: 'GUID with JobID matching value in SMS Send Log. Filled only for sends since Spring 2023.' }, { name: 'SMSBatchID', type: 'Number', untyped: true, nullable: true, description: 'Batch ID matching SmsBatchID in SMS Send Log. Filled only for sends since Spring 2023.' }, { name: 'SendDateTime', type: 'Date', nullable: true, description: 'The date and time the message was sent to the aggregator. Accurate to the second, in CST.' }, { name: 'DeliveredDateTime', type: 'Date', nullable: true, description: 'The date and time MCE received the delivery event. Accurate to the second, in CST. Filled only for successfully delivered messages and empty for all other statuses, including carrier-level acknowledgements, failures and undelivered messages.' }, { name: 'SendID', type: 'Number', untyped: true, nullable: true, description: 'The send ID number for the SMS send.' }, { name: 'SendSplitID', type: 'Number', untyped: true, nullable: true, description: 'If the message was split, the ID of the split.' }, { name: 'SendSegmentID', type: 'Number', untyped: true, nullable: true, description: 'The ID of the segment tied to the message.' }, { name: 'SendJobID', type: 'Number', untyped: true, nullable: true, description: 'The job ID for the SMS send.' }, { name: 'SendGroupID', type: 'Number', untyped: true, nullable: true, description: 'The group ID for the SMS send.' }, { name: 'SendPersonID', type: 'Number', untyped: true, nullable: true, description: 'The sendperson ID for the SMS send.' }, { name: 'SubscriberID', type: 'Number', untyped: true, nullable: true, description: 'The subscriber ID for the affected subscriber. This number represents the unique ID for each subscriber record.' }, { name: 'SubscriberKey', type: 'Text', length: 254, nullable: true, description: 'The subscriber key for the affected subscriber.' }, { name: 'SMSStandardStatusCodeId', type: 'Number', nullable: true, description: 'MCE delivery status code.' }, { name: 'Description', type: 'Text', nullable: true, description: 'Detailed description of the status code.' }, { name: 'Name', type: 'Text', nullable: true, description: 'The SMS Message or Journey Activity name.' }, { name: 'ShortCode', type: 'Text', nullable: true, description: 'The short code or long code used to send your message.' }, { name: 'SharedKeyword', type: 'Text', nullable: true, description: 'The keyword used in your message.' }, { name: 'Ordinal', type: 'Number', nullable: true, description: 'Represents the parts in a multi-part message. Represented in ascending order starting at 0.' }, { name: 'FromName', type: 'Text', length: 11, nullable: true, description: 'From Name that an individual message was deployed with. Maximum length: 11 characters.' }, { name: 'ConversationStateID', type: 'Text', nullable: true, description: 'Correlates MO/MT messages.' }, { name: 'JBDefinitionID', type: 'Text', nullable: true, description: 'Unique identifier for the related journey.' }, { name: 'JBActivityID', type: 'Text', nullable: true, description: 'Unique identifier for the related journey activity.' } ] }, { name: '_UndeliverableSMS', alias: 'undeliverableSMS', fullQuery: 'SELECT\n undeliverableSMS.MobileNumber\n , undeliverableSMS.Undeliverable\n , undeliverableSMS.BounceCount\n , undeliverableSMS.FirstBounceDate\n , undeliverableSMS.HoldDate\nFROM _UndeliverableSMS AS undeliverableSMS', description: 'Mobile numbers currently flagged as undeliverable due to repeated bounces.', docs: 'https://mateuszdabrowski.pl/docs/salesforce/marketing-cloud-engagement/config/mobile-connect-data-views/#_undeliverablesms', officialDocs: 'https://help.salesforce.com/s/articleView?id=mktg.mc_as_data_view_undeliverable_sms.htm&type=5', joinKey: 'MobileNumber', fields: [ { name: 'MobileNumber', type: 'Phone', length: 15, description: 'Mobile number for the subscriber.' }, { name: 'Undeliverable', type: 'Boolean', description: 'Whether the subscriber is eligible for future deliveries.' }, { name: 'BounceCount', type: 'Number', description: 'Number of times message was not delivered.' }, { name: 'FirstBounceDate', type: 'Date', description: 'Date of the first unsuccessful delivery.' }, { name: 'HoldDate', type: 'Date', nullable: true, description: 'Date when the subscriber can be used in sends again.' } ] }, { name: '_Coupon', alias: 'coupon', description: 'Coupons created for use with live content coupon blocks in Content Builder.', joinKey: 'ExternalKey', notes: "Not in Salesforce's current Data Views documentation and may have been retired. The field list comes from older, unofficial sources, so treat every field as unverified.", fields: [ { name: 'Name', type: 'Text', unverified: true, length: 128, description: 'Name of the coupon.' }, { name: 'ExternalKey', type: 'Text', unverified: true, length: 36, description: 'Unique external key of the coupon.' }, { name: 'Description', type: 'Text', unverified: true, description: 'Description of the coupon.' }, { name: 'BeginDate', type: 'Date', unverified: true, description: 'Date the coupon becomes valid.' }, { name: 'ExpirationDate', type: 'Date', unverified: true, description: 'Date the coupon expires.' } ] }, { name: '_FTAF', alias: 'ftaf', description: 'Forward-to-a-Friend events: who forwarded a tracked email using the FTAF block.', officialDocs: 'https://help.salesforce.com/s/articleView?id=mktg.mc_as_data_view_ftaf.htm&type=5', joinKey: 'JobID, SubscriberID / SubscriberKey', notes: "Field list confirmed against Salesforce's official Data Views reference instead of a community cross-reference.", fields: [ { name: 'AccountID', type: 'Number', description: 'Your account ID number.' }, { name: 'OYBAccountID', type: 'Number', nullable: true, description: 'The account ID number for any related On-Your-Behalf accounts. Applies to enterprise accounts only.' }, { name: 'JobID', type: 'Number', description: 'The job ID number for the email send.' }, { name: 'ListID', type: 'Number', description: 'The list ID number for the list used in the send.' }, { name: 'BatchID', type: 'Number', description: 'The batch ID number for any batches used in the send.' }, { name: 'SubscriberID', type: 'Number', description: 'The subscriber ID for the affected subscriber.' }, { name: 'SubscriberKey', type: 'Text', length: 254, description: 'The subscriber key for the affected subscriber.' }, { name: 'TransactionTime', type: 'Date', description: 'The date the forward took place.' }, { name: 'Domain', type: 'Text', length: 128, description: 'The domain at which the forward occurred.' }, { name: 'IsUnique', type: 'Boolean', description: 'Whether the event is unique or repeated.' }, { name: 'TriggererSendDefinitionObjectID', type: 'Text', nullable: true, description: 'The object ID for the triggered send definition.' }, { name: 'TriggeredSendCustomerKey', type: 'Text', length: 36, nullable: true, description: 'The customer key for the triggered send.' } ] }, { name: '_SurveyResponse', alias: 'survey', description: 'One row per answer submitted to an email survey question.', joinKey: 'JobID, SubscriberID / SubscriberKey, SurveyID, QuestionID', notes: "Not in Salesforce's current Data Views documentation and may have been retired. The field list comes from older, unofficial sources, so treat every field as unverified.", fields: [ { name: 'AccountID', type: 'Number', unverified: true, description: 'Parent account ID (MID).' }, { name: 'OYBAccountID', type: 'Number', unverified: true, description: 'Child Business Unit account ID.' }, { name: 'JobID', type: 'Number', unverified: true, description: 'Email send job ID; joins to _Job.' }, { name: 'ListID', type: 'Number', unverified: true, description: 'List ID used in the send.' }, { name: 'BatchID', type: 'Number', unverified: true, description: 'Batch ID, if applicable.' }, { name: 'SubscriberID', type: 'Number', unverified: true, description: 'Subscriber ID; joins to _Subscribers.' }, { name: 'SubscriberKey', type: 'Text', unverified: true, length: 254, description: 'Subscriber key; joins to _Subscribers.' }, { name: 'EventDate', type: 'Date', unverified: true, description: 'Date and time the response was submitted.' }, { name: 'IsUnique', type: 'Boolean', unverified: true, description: '1 for the first response for this subscriber/job, 0 for a subsequent one.' }, { name: 'Domain', type: 'Text', unverified: true, length: 128, description: "Domain of the respondent's email address." }, { name: 'SurveyID', type: 'Number', unverified: true, description: 'ID of the survey.' }, { name: 'SurveyName', type: 'Text', unverified: true, length: 100, description: 'Name of the survey.' }, { name: 'QuestionID', type: 'Number', unverified: true, description: 'ID of the question answered.' }, { name: 'QuestionName', type: 'Text', unverified: true, length: 50, description: 'Name of the question.' }, { name: 'Question', type: 'Text', unverified: true, length: 4000, description: 'Full text of the question.' }, { name: 'AnswerID', type: 'Number', unverified: true, description: 'ID of the answer choice selected.' }, { name: 'AnswerName', type: 'Text', unverified: true, length: 4000, description: 'Name of the answer choice.' }, { name: 'Answer', type: 'Text', unverified: true, length: 4000, description: 'Text of the answer choice.' }, { name: 'AnswerData', type: 'Text', unverified: true, description: 'Free-text response data, for open-ended questions.' } ] }, { name: '_SocialNetworkTracking', alias: 'socialTracking', description: 'Tracking events for content shared from an email send via the Social Forward action, at the subscriber level.', officialDocs: 'https://help.salesforce.com/s/articleView?id=mktg.mc_as_data_view_social_network_tracking.htm&type=5', joinKey: 'SubscriberID / SubscriberKey', notes: "Dates are stored in Central Standard Time and do not observe Daylight Saving Time. Field list confirmed against Salesforce's official Data Views reference instead of a community cross-reference.", fields: [ { name: 'SubscriberID', type: 'Number', description: 'The subscriber ID for the affected subscriber.' }, { name: 'SubscriberKey', type: 'Text', length: 254, description: 'The subscriber key for the affected subscriber.' }, { name: 'ListID', type: 'Number', description: 'The list ID number for the list used in the send.' }, { name: 'BatchID', type: 'Number', description: 'The batch ID number for any batches used in the send.' }, { name: 'SocialSharingSiteID', type: 'Number', description: 'The ID of the social network used to share the content region.' }, { name: 'SiteName', type: 'Text', description: 'The name of the social network involved in the sharing of the content region.' }, { name: 'CountryCode', type: 'Text', description: 'The country code for the social network involved in the sharing of the content region.' }, { name: 'PublishedSocialContentID', type: 'Text', description: 'The ID number for the content area published via Social Forward.' }, { name: 'RegionTitle', type: 'Text', description: 'The title of the region shared via Social Forward.' }, { name: 'RegionDescription', type: 'Text', nullable: true, description: 'The description of the region shared via Social Forward.' }, { name: 'RegionHTML', type: 'Text', nullable: true, description: 'The HTML associated with the Social Forward region.' }, { name: 'ContentRegionID', type: 'Text', nullable: true, description: 'The ID of the shared content region.' }, { name: 'OYBMemberID', type: 'Number', nullable: true, description: 'The ID number for any On-Your-Behalf account involved in the sharing of the content area.' }, { name: 'TransactionTime', type: 'Date', description: 'The time at which the content area was shared.' }, { name: 'IsUnique', type: 'Boolean', description: 'Whether the event is unique or not.' }, { name: 'Domain', type: 'Text', description: 'The domain from which the content was shared.' }, { name: 'PublishedSocialContentStatusID', type: 'Text', description: 'The status ID for the published social content.' }, { name: 'ShortCode', type: 'Text', description: 'The short code for the published social content status, which is Active, Inactive, or Deleted.' }, { name: 'PublishTime', type: 'Date', description: 'The time the social content area was published.' } ] }, { name: '_SocialNetworkImpressions', alias: 'socialImpressions', description: 'Impression counts on content shared from an email send via the Social Forward action, aggregated by region and site rather than by subscriber.', officialDocs: 'https://help.salesforce.com/s/articleView?id=mktg.mc_as_data_view_social_network_impressions.htm&type=5', joinKey: 'JobID', notes: "Field list confirmed against Salesforce's official Data Views reference.", fields: [ { name: 'JobID', type: 'Number', description: 'The job ID number for the email send.' }, { name: 'ListID', type: 'Number', description: 'The list ID number for the list used in the send.' }, { name: 'RegionTitle', type: 'Text', description: 'The title of the region shared via Social Forward.' }, { name: 'RegionDescription', type: 'Text', nullable: true, description: 'The description of the region shared via Social Forward.' }, { name: 'RegionHTML', type: 'Text', description: 'The HTML associated with the Social Forward region.' }, { name: 'ContentRegionID', type: 'Number', description: 'The ID of the shared content region.' }, { name: 'SocialSharingSiteID', type: 'Number', description: 'The ID of the social network involved in the sharing of the content region.' }, { name: 'SiteName', type: 'Text', description: 'The name of the social network involved in the sharing of the content region.' }, { name: 'CountryCode', type: 'Text', description: 'The country code for the social network involved in the sharing of the content region.' }, { name: 'ReferringURL', type: 'Text', nullable: true, description: 'The referring URL used in the Social Forward activity.' }, { name: 'IPAddress', type: 'Text', length: 50, nullable: true, description: 'The IP address for the URL from which the content region was shared.' }, { name: 'TransactionTime', type: 'Date', description: 'The time at which the content area was shared.' }, { name: 'PublishedSocialContentStatusID', type: 'Text', description: 'The status ID for the published social content.' }, { name: 'ShortCode', type: 'Text', description: 'The short code for the published social content status, which is Active, Inactive, or Deleted.' }, { name: 'PublishTime', type: 'Date', description: 'The time the social content area was published.' } ] }, { name: '_PushAddress', alias: 'pushAddress', fullQuery: 'SELECT\n pushAddress._ContactID AS ContactID\n , pushAddress._DeviceID AS DeviceID\n , pushAddress._APID AS APID\n , pushAddress._Status AS Status\n , pushAddress._Source AS Source\n , pushAddress._SourceObjectId AS SourceObjectId\n , pushAddress._Platform AS Platform\n , pushAddress._PlatformVersion AS PlatformVersion\n , pushAddress._Alias AS Alias\n , pushAddress._OptOutStatusID AS OptOutStatusID\n , pushAddress._OptOutMethodID AS OptOutMethodID\n , pushAddress._OptOutDate AS OptOutDate\n , pushAddress._OptInStatusID AS OptInStatusID\n , pushAddress._OptInMethodID AS OptInMethodID\n , pushAddress._OptInDate AS OptInDate\n , pushAddress._Channel AS Channel\n , pushAddress._CreatedDate AS CreatedDate\n , pushAddress._CreatedBy AS CreatedBy\n , pushAddress._ModifiedDate AS ModifiedDate\n , pushAddress._ModifiedBy AS ModifiedBy\n , pushAddress._City AS City\n , pushAddress._State AS State\n , pushAddress._ZipCode AS ZipCode\n , pushAddress._FirstName AS FirstName\n , pushAddress._LastName AS LastName\n , pushAddress._UTCOffset AS UTCOffset\n , pushAddress._IsHonorDST AS IsHonorDST\n , pushAddress._SystemToken AS SystemToken\n , pushAddress._ProviderToken AS ProviderToken\n , pushAddress._Badge AS Badge\n , pushAddress._LocationEnabled AS LocationEnabled\n , pushAddress._TimeZone AS TimeZone\n , pushAddress._Device AS Device\n , pushAddress._HardwareId AS HardwareId\n , pushAddress._DeviceType AS DeviceType\nFROM _PushAddress AS pushAddress', description: 'MobilePush device registrations: one row per device/app pairing (APID) known for a contact.', docs: 'https://mateuszdabrowski.pl/docs/salesforce/marketing-cloud-engagement/config/mobile-push-data-views/#_pushaddress', joinKey: '_ContactID, _DeviceID, _APID', notes: "Undocumented by Salesforce. Fields are sourced from the author's own article. _ContactID is Text here and Number in _MobileAddress. Filter by _APID when the Business Unit has more than one app. Use _DeviceID, not _SystemToken, as the device identifier because the token rotates.", fields: [ { name: '_ContactID', type: 'Text', description: 'Global Contact ID (not Contact Key). Stored as Text here, while _MobileAddress stores it as Number.' }, { name: '_DeviceID', type: 'Text', length: 200, description: 'Unique ID of the app installation, generated by the MobilePush SDK.' }, { name: '_APID', type: 'Text', length: 38, description: 'ID of the MobilePush app the device registered with.' }, { name: '_Status', type: 'Text', nullable: true, description: 'Whether the device is active. Inactive devices get no messages at all.', values: [{ value: '1', label: 'Active (default)' }, { value: '2', label: 'Inactive' }] }, { name: '_Source', type: 'Text', nullable: true, description: 'Source of the device record.' }, { name: '_SourceObjectId', type: 'Text', length: 200, nullable: true, description: 'ID of the source object.' }, { name: '_Platform', type: 'Text', length: 100, nullable: true, description: 'Operating system of the device, for example iOS or Android.' }, { name: '_PlatformVersion', type: 'Text', length: 100, nullable: true, description: 'Version of the operating system.' }, { name: '_Alias', type: 'Text', length: 100, nullable: true, description: 'Alias set by the app through the SDK.' }, { name: '_OptOutStatusID', type: 'Text', nullable: true, description: 'Current opt-out status of the device.', values: [{ value: '0', label: 'Not opted out' }, { value: '1', label: 'Opted out' }] }, { name: '_OptOutMethodID', type: 'Text', nullable: true, description: 'How the device opted out.', values: [{ value: '0', label: 'Unspecified' }, { value: '1', label: 'WebCollect' }, { value: '2', label: 'API' }, { value: '3', label: 'FTAF' }, { value: '4', label: 'Import' }, { value: '5', label: 'MoveCopy' }, { value: '6', label: 'Application' }, { value: '7', label: 'SalesForce' }, { value: '8', label: 'Segmentation' }, { value: '9', label: 'GenericExtension' }, { value: '10', label: 'CustomObject' }, { value: '11', label: 'RMM' }, { value: '13', label: 'ServiceFeedback' }, { value: '14', label: 'MobileOriginated' }, { value: '15', label: 'Contacts Suppression' }, { value: '16', label: 'User Disabled Push' }, { value: '17', label: 'Missing or Invalid Device Token' }, { value: '18', label: 'GlobalStop' }] }, { name: '_OptOutDate', type: 'Date', nullable: true, description: 'Date of the opt-out.' }, { name: '_OptInStatusID', type: 'Text', description: 'Current opt-in status of the device. Defaults to 0.', values: [{ value: '0', label: 'Not opted in (default)' }, { value: '2', label: 'Opted in' }] }, { name: '_OptInMethodID', type: 'Text', nullable: true, description: 'How the device opted in.', values: [{ value: '0', label: 'Unspecified' }, { value: '1', label: 'WebCollect' }, { value: '2', label: 'API' }, { value: '3', label: 'FTAF' }, { value: '4', label: 'Import' }, { value: '5', label: 'MoveCopy' }, { value: '6', label: 'Application' }, { value: '7', label: 'SalesForce' }, { value: '8', label: 'Segmentation' }, { value: '9', label: 'GenericExtension' }, { value: '10', label: 'CustomObject' }, { value: '11', label: 'RMM' }, { value: '12', label: 'Mobile Opt-In' }, { value: '13', label: 'DeviceRegistration' }] }, { name: '_OptInDate', type: 'Date', nullable: true, description: 'Date of the opt-in.' }, { name: '_Channel', type: 'Text', length: 20, nullable: true, description: 'Communication channel, Push.' }, { name: '_CreatedDate', type: 'Date', description: 'Date the device record was created. Defaults to the time of writing.' }, { name: '_CreatedBy', type: 'Text', nullable: true, description: 'Who or what created the device record. -1000 for the system.' }, { name: '_ModifiedDate', type: 'Date', description: 'Date the device record was last modified. Defaults to the time of writing.' }, { name: '_ModifiedBy', type: 'Text', nullable: true, description: 'Who or what last modified the device record. -1000 for the system.' }, { name: '_City', type: 'Text', length: 200, nullable: true, description: "Contact's City." }, { name: '_State', type: 'Text', length: 200, nullable: true, description: "Contact's State." }, { name: '_ZipCode', type: 'Text', length: 20, nullable: true, description: "Contact's Zip Code." }, { name: '_FirstName', type: 'Text', length: 100, nullable: true, description: "Contact's First Name." }, { name: '_LastName', type: 'Text', length: 100, nullable: true, description: "Contact's Last Name." }, { name: '_UTCOffset', type: 'Decimal', precision: 4, scale: 2, nullable: true, description: "Hours between the device's time zone and UTC. Defaults to 0." }, { name: '_IsHonorDST', type: 'Boolean', nullable: true, description: "Whether the device's time zone observes Daylight Saving Time. Defaults to false." }, { name: '_SystemToken', type: 'Text', length: 4000, nullable: true, description: 'Push token from Apple (APNS) or Google (FCM) used to deliver notifications. Empty for devices that never opted in.' }, { name: '_ProviderToken', type: 'Text', length: 200, nullable: true, description: 'Provider token for the device.' }, { name: '_Badge', type: 'Number', nullable: true, description: 'Badge number shown on the app icon.' }, { name: '_LocationEnabled', type: 'Boolean', nullable: true, description: 'Whether the user allowed location tracking for geofence and beacon messages.' }, { name: '_TimeZone', type: 'Text', length: 50, nullable: true, description: 'Time zone reported by the device.' }, { name: '_Device', type: 'Text', length: 100, nullable: true, description: 'Device name, usually empty.' }, { name: '_HardwareId', type: 'Text', length: 100, nullable: true, description: 'Device manufacturer and model, for example Google Pixel 8.' }, { name: '_DeviceType', type: 'Text', length: 20, nullable: true, description: 'Device type, usually empty.' } ] }, { name: '_PushTag', alias: 'pushTag', fullQuery: 'SELECT\n pushTag._DeviceID AS DeviceID\n , pushTag._APID AS APID\n , pushTag._Value AS Tag\n , pushTag._CreatedDate AS CreatedDate\n , pushTag._CreatedBy AS CreatedBy\n , pushTag._ModifiedDate AS ModifiedDate\n , pushTag._ModifiedBy AS ModifiedBy\nFROM _PushTag AS pushTag', description: 'Tags assigned to MobilePush devices, used to target push sends without a full contact record.', docs: 'https://mateuszdabrowski.pl/docs/salesforce/marketing-cloud-engagement/config/mobile-push-data-views/#_pushtag', joinKey: '_DeviceID, _APID', notes: "Undocumented by Salesforce. Fields are sourced from the author's own article. Join to _PushAddress on both _DeviceID and _APID.", fields: [ { name: '_DeviceID', type: 'Text', length: 200, description: 'ID of the device the tag belongs to.' }, { name: '_APID', type: 'Text', length: 38, description: 'ID of the MobilePush app.' }, { name: '_Value', type: 'Text', length: 128, nullable: true, description: 'The tag.' }, { name: '_CreatedDate', type: 'Date', description: 'Date the tag was added. Defaults to the time of writing.' }, { name: '_CreatedBy', type: 'Text', nullable: true, description: 'Who or what added the tag.' }, { name: '_ModifiedDate', type: 'Date', description: 'Date the tag was last modified. Defaults to the time of writing.' }, { name: '_ModifiedBy', type: 'Text', nullable: true, description: 'Who or what last modified the tag.' } ] } ], dataViewAliases: { _Subscribers: 'sub', _EnterpriseAttribute: 'ea', _Job: 'job', _Sent: 's', _Open: 'o', _Click: 'c', _Bounce: 'b', _Complaint: 'comp', _Unsubscribe: 'u', _BusinessUnitUnsubscribes: 'buu', _ListSubscribers: 'ls', _ReconcilableDispositionView: 'rd', _Journey: 'j', _JourneyActivity: 'ja', _AutomationInstance: 'ai', _AutomationActivityInstance: 'aai', _MobileAddress: 'ma', _MobileSubscription: 'ms', _SubscriberSMS: 'ssms', _SMSSubscriptionLog: 'ssl', _SMSMessageTracking: 'sms', _UndeliverableSMS: 'usms', _Coupon: 'cpn', _FTAF: 'ftaf', _SurveyResponse: 'sr', _SocialNetworkTracking: 'snt', _SocialNetworkImpressions: 'sni', _PushAddress: 'pa', _PushTag: 'pt' }, dataViewJoins: [ { a: '_Sent', b: '_Open', on: [['JobID', 'JobID'], ['ListID', 'ListID'], ['BatchID', 'BatchID'], ['SubscriberID', 'SubscriberID']], extra: ['IsUnique = 1'], note: 'JobID, ListID, BatchID and SubscriberID identify one send to one subscriber. IsUnique = 1 keeps the first open, as in the email-sends snippet.' }, { a: '_Sent', b: '_Click', on: [['JobID', 'JobID'], ['ListID', 'ListID'], ['BatchID', 'BatchID'], ['SubscriberID', 'SubscriberID']], extra: ['IsUnique = 1'], note: 'JobID, ListID, BatchID and SubscriberID identify one send to one subscriber. IsUnique = 1 keeps the first click, as in the email-sends snippet.' }, { a: '_Sent', b: '_Bounce', on: [['JobID', 'JobID'], ['ListID', 'ListID'], ['BatchID', 'BatchID'], ['SubscriberID', 'SubscriberID']], extra: ['IsUnique = 1'], note: 'JobID, ListID, BatchID and SubscriberID identify one send to one subscriber. IsUnique = 1 keeps the first bounce, as in the email-sends snippet.' }, { a: '_Sent', b: '_Unsubscribe', on: [['JobID', 'JobID'], ['ListID', 'ListID'], ['BatchID', 'BatchID'], ['SubscriberID', 'SubscriberID']], extra: ['IsUnique = 1'], note: 'JobID, ListID, BatchID and SubscriberID identify one send to one subscriber. IsUnique = 1 keeps the first unsubscribe, as in the email-sends snippet.' }, { a: '_Sent', b: '_Complaint', on: [['JobID', 'JobID'], ['ListID', 'ListID'], ['BatchID', 'BatchID'], ['SubscriberID', 'SubscriberID']], extra: ['IsUnique = 1'], note: 'JobID, ListID, BatchID and SubscriberID identify one send to one subscriber. IsUnique = 1 keeps the first complaint.' }, { a: '_Open', b: '_Click', on: [['JobID', 'JobID'], ['ListID', 'ListID'], ['BatchID', 'BatchID'], ['SubscriberID', 'SubscriberID']], extra: [], note: 'JobID, ListID, BatchID and SubscriberID match the events of one send to one subscriber.' }, { a: '_Open', b: '_Bounce', on: [['JobID', 'JobID'], ['ListID', 'ListID'], ['BatchID', 'BatchID'], ['SubscriberID', 'SubscriberID']], extra: [], note: 'JobID, ListID, BatchID and SubscriberID match the events of one send to one subscriber.' }, { a: '_Open', b: '_Complaint', on: [['JobID', 'JobID'], ['ListID', 'ListID'], ['BatchID', 'BatchID'], ['SubscriberID', 'SubscriberID']], extra: [], note: 'JobID, ListID, BatchID and SubscriberID match the events of one send to one subscriber.' }, { a: '_Click', b: '_Bounce', on: [['JobID', 'JobID'], ['ListID', 'ListID'], ['BatchID', 'BatchID'], ['SubscriberID', 'SubscriberID']], extra: [], note: 'JobID, ListID, BatchID and SubscriberID match the events of one send to one subscriber.' }, { a: '_Click', b: '_Complaint', on: [['JobID', 'JobID'], ['ListID', 'ListID'], ['BatchID', 'BatchID'], ['SubscriberID', 'SubscriberID']], extra: [], note: 'JobID, ListID, BatchID and SubscriberID match the events of one send to one subscriber.' }, { a: '_Open', b: '_Unsubscribe', on: [['JobID', 'JobID'], ['ListID', 'ListID'], ['BatchID', 'BatchID'], ['SubscriberID', 'SubscriberID']], extra: [], note: 'JobID, ListID, BatchID and SubscriberID match the events of one send to one subscriber.' }, { a: '_Click', b: '_Unsubscribe', on: [['JobID', 'JobID'], ['ListID', 'ListID'], ['BatchID', 'BatchID'], ['SubscriberID', 'SubscriberID']], extra: [], note: 'JobID, ListID, BatchID and SubscriberID match the events of one send to one subscriber.' }, { a: '_Bounce', b: '_Unsubscribe', on: [['JobID', 'JobID'], ['ListID', 'ListID'], ['BatchID', 'BatchID'], ['SubscriberID', 'SubscriberID']], extra: [], note: 'JobID, ListID, BatchID and SubscriberID match the events of one send to one subscriber.' }, { a: '_Bounce', b: '_Complaint', on: [['JobID', 'JobID'], ['ListID', 'ListID'], ['BatchID', 'BatchID'], ['SubscriberID', 'SubscriberID']], extra: [], note: 'JobID, ListID, BatchID and SubscriberID match the events of one send to one subscriber.' }, { a: '_Complaint', b: '_Unsubscribe', on: [['JobID', 'JobID'], ['ListID', 'ListID'], ['BatchID', 'BatchID'], ['SubscriberID', 'SubscriberID']], extra: [], note: 'JobID, ListID, BatchID and SubscriberID match the events of one send to one subscriber.' }, { a: '_Sent', b: '_Job', on: [['JobID', 'JobID']], extra: [], note: 'JobID adds the email name, subject and delivery time of the send.' }, { a: '_Open', b: '_Job', on: [['JobID', 'JobID']], extra: [], note: 'JobID adds the email name, subject and delivery time of the send.' }, { a: '_Click', b: '_Job', on: [['JobID', 'JobID']], extra: [], note: 'JobID adds the email name, subject and delivery time of the send.' }, { a: '_Bounce', b: '_Job', on: [['JobID', 'JobID']], extra: [], note: 'JobID adds the email name, subject and delivery time of the send.' }, { a: '_Unsubscribe', b: '_Job', on: [['JobID', 'JobID']], extra: [], note: 'JobID adds the email name, subject and delivery time of the send.' }, { a: '_Complaint', b: '_Job', on: [['JobID', 'JobID']], extra: [], note: 'JobID adds the email name, subject and delivery time of the send.' }, { a: '_Sent', b: '_JourneyActivity', on: [['TriggererSendDefinitionObjectID', 'JourneyActivityObjectID']], extra: [], note: 'The triggered send definition ties a send to its Journey activity, and _Journey then gives the Journey and its version.' }, { a: '_Open', b: '_JourneyActivity', on: [['TriggererSendDefinitionObjectID', 'JourneyActivityObjectID']], extra: [], note: 'The triggered send definition ties an open to its Journey activity, and _Journey then gives the Journey and its version.' }, { a: '_Click', b: '_JourneyActivity', on: [['TriggererSendDefinitionObjectID', 'JourneyActivityObjectID']], extra: [], note: 'The triggered send definition ties a click to its Journey activity, and _Journey then gives the Journey and its version.' }, { a: '_Bounce', b: '_JourneyActivity', on: [['TriggererSendDefinitionObjectID', 'JourneyActivityObjectID']], extra: [], note: 'The triggered send definition ties a bounce to its Journey activity, and _Journey then gives the Journey and its version.' }, { a: '_Job', b: '_JourneyActivity', on: [['TriggererSendDefinitionObjectID', 'JourneyActivityObjectID']], extra: [], note: 'The triggered send definition ties a send job to its Journey activity, and _Journey then gives the Journey and its version.' }, { a: '_JourneyActivity', b: '_Journey', on: [['VersionID', 'VersionID']], extra: [], note: 'Join on VersionID, not JourneyID. The first version reuses the Journey ID, so JourneyID repeats every version.' }, { a: '_Journey', b: '_SMSMessageTracking', on: [['VersionID', 'JBDefinitionID']], extra: [], note: 'JBDefinitionID holds the Journey version, and is empty for SMS activities created before October 2020.' }, { a: '_JourneyActivity', b: '_SMSMessageTracking', on: [['ActivityID', 'JBActivityID']], extra: [], note: 'JBActivityID holds the Journey activity, and is empty for SMS activities created before October 2020.' }, { a: '_Sent', b: '_Subscribers', on: [['SubscriberID', 'SubscriberID']], extra: [], note: 'SubscriberID adds the email address and status. In a child Business Unit, Ent._Subscribers holds the parent-level subscribers.' }, { a: '_Open', b: '_Subscribers', on: [['SubscriberID', 'SubscriberID']], extra: [], note: 'SubscriberID adds the email address and status. In a child Business Unit, Ent._Subscribers holds the parent-level subscribers.' }, { a: '_Click', b: '_Subscribers', on: [['SubscriberID', 'SubscriberID']], extra: [], note: 'SubscriberID adds the email address and status. In a child Business Unit, Ent._Subscribers holds the parent-level subscribers.' }, { a: '_Bounce', b: '_Subscribers', on: [['SubscriberID', 'SubscriberID']], extra: [], note: 'SubscriberID adds the email address and status. In a child Business Unit, Ent._Subscribers holds the parent-level subscribers.' }, { a: '_Unsubscribe', b: '_Subscribers', on: [['SubscriberID', 'SubscriberID']], extra: [], note: 'SubscriberID adds the email address and status. In a child Business Unit, Ent._Subscribers holds the parent-level subscribers.' }, { a: '_Complaint', b: '_Subscribers', on: [['SubscriberID', 'SubscriberID']], extra: [], note: 'SubscriberID adds the email address and status. In a child Business Unit, Ent._Subscribers holds the parent-level subscribers.' }, { a: '_Subscribers', b: '_EnterpriseAttribute', on: [['SubscriberID', '_SubscriberID']], extra: [], note: '_EnterpriseAttribute is the one view whose subscriber column starts with an underscore.' }, { a: '_AutomationInstance', b: '_AutomationActivityInstance', on: [['AutomationInstanceID', 'AutomationInstanceID']], extra: [], note: 'AutomationInstanceID adds the runs of each activity to the run of the Automation.' }, { a: '_AutomationActivityInstance', b: '_Job', on: [['JobID', 'JobID']], extra: [], note: 'JobID ties an email send activity to its send job.' }, { a: '_PushAddress', b: '_PushTag', on: [['_DeviceID', '_DeviceID'], ['_APID', '_APID']], extra: [], note: 'Tags belong to devices. Matching _APID as well keeps a tag with the device of its own app.' } ], deFieldTypes: [ { name: 'Text', description: 'Variable-length string, up to 4000 characters (or unlimited length when created with no length specified, via SOAP).', sqlType: 'nvarchar' }, { name: 'Number', description: 'Whole number (integer).', sqlType: 'int' }, { name: 'Date', description: 'Date and time value.', sqlType: 'datetime' }, { name: 'Boolean', description: 'True/false value, shown as Yes/No in the UI.', sqlType: 'bit' }, { name: 'EmailAddress', description: 'Text field validated as an email address.', sqlType: 'nvarchar' }, { name: 'Phone', description: 'Text field intended for a phone or mobile number.', sqlType: 'nvarchar' }, { name: 'Decimal', description: 'Fixed-precision decimal number; configured with a total length and a number of decimal places.', sqlType: 'decimal' }, { name: 'Locale', description: "Locale code, e.g. for a subscriber's language/region.", sqlType: 'int' } ] }; /* =============================================== sqlstudio-sql-tools.js == */ (function () { 'use strict'; var RESERVED_WORDS = [ 'ADD', 'ALL', 'ALTER', 'AND', 'ANY', 'AS', 'ASC', 'AUTHORIZATION', 'BACKUP', 'BEGIN', 'BETWEEN', 'BREAK', 'BROWSE', 'BULK', 'BY', 'CASCADE', 'CASE', 'CHECK', 'CHECKPOINT', 'CLOSE', 'CLUSTERED', 'COALESCE', 'COLLATE', 'COLUMN', 'COMMIT', 'COMPUTE', 'CONSTRAINT', 'CONTAINS', 'CONTAINSTABLE', 'CONTINUE', 'CONVERT', 'CREATE', 'CROSS', 'CURRENT', 'CURRENT_DATE', 'CURRENT_TIME', 'CURRENT_TIMESTAMP', 'CURRENT_USER', 'CURSOR', 'DATABASE', 'DBCC', 'DEALLOCATE', 'DECLARE', 'DEFAULT', 'DELETE', 'DENY', 'DESC', 'DISK', 'DISTINCT', 'DISTRIBUTED', 'DOUBLE', 'DROP', 'DUMP', 'ELSE', 'END', 'ERRLVL', 'ESCAPE', 'EXCEPT', 'EXEC', 'EXECUTE', 'EXISTS', 'EXIT', 'EXTERNAL', 'FETCH', 'FILE', 'FILLFACTOR', 'FOR', 'FOREIGN', 'FREETEXT', 'FREETEXTTABLE', 'FROM', 'FULL', 'FUNCTION', 'GOTO', 'GRANT', 'GROUP', 'HAVING', 'HOLDLOCK', 'IDENTITY', 'IDENTITY_INSERT', 'IDENTITYCOL', 'IF', 'IN', 'INDEX', 'INNER', 'INSERT', 'INTERSECT', 'INTO', 'IS', 'JOIN', 'KEY', 'KILL', 'LEFT', 'LIKE', 'LINENO', 'LOAD', 'MERGE', 'NATIONAL', 'NOCHECK', 'NONCLUSTERED', 'NOT', 'NULL', 'NULLIF', 'OF', 'OFF', 'OFFSETS', 'ON', 'OPEN', 'OPENDATASOURCE', 'OPENQUERY', 'OPENROWSET', 'OPENXML', 'OPTION', 'OR', 'ORDER', 'OUTER', 'OVER', 'PERCENT', 'PIVOT', 'PLAN', 'PRECISION', 'PRIMARY', 'PRINT', 'PROC', 'PROCEDURE', 'PUBLIC', 'RAISERROR', 'READ', 'READTEXT', 'RECONFIGURE', 'REFERENCES', 'REPLICATION', 'RESTORE', 'RESTRICT', 'RETURN', 'REVERT', 'REVOKE', 'RIGHT', 'ROLLBACK', 'ROWCOUNT', 'ROWGUIDCOL', 'RULE', 'SAVE', 'SCHEMA', 'SECURITYAUDIT', 'SELECT', 'SEMANTICKEYPHRASETABLE', 'SEMANTICSIMILARITYDETAILSTABLE', 'SEMANTICSIMILARITYTABLE', 'SESSION_USER', 'SET', 'SETUSER', 'SHUTDOWN', 'SOME', 'STATISTICS', 'SYSTEM_USER', 'TABLE', 'TABLESAMPLE', 'TEXTSIZE', 'THEN', 'TO', 'TOP', 'TRAN', 'TRANSACTION', 'TRIGGER', 'TRUNCATE', 'TRY_CONVERT', 'TSEQUAL', 'UNION', 'UNIQUE', 'UNPIVOT', 'UPDATE', 'UPDATETEXT', 'USE', 'USER', 'VALUES', 'VARYING', 'VIEW', 'WAITFOR', 'WHEN', 'WHERE', 'WHILE', 'WITH', 'WRITETEXT' ]; var TRACKING_VIEWS = ['_Sent', '_Open', '_Click', '_Bounce', '_Complaint', '_Unsubscribe']; var SEND_LOG_KEYS = ['JobID', 'ListID', 'BatchID', 'SubscriberID']; var EXTRA_GRAMMAR_KEYWORDS = [ 'APPLY', 'TIES', 'ZONE', 'TIME', 'AT', 'PARTITION', 'ROWS', 'RANGE', 'FOLLOWING', 'PRECEDING', 'UNBOUNDED', 'ONLY', 'WITHIN', 'ASC', 'DESC' ]; var TOKEN_KEYWORDS = (function () { var set = {}; RESERVED_WORDS.forEach(function (w) { set[w] = true; }); EXTRA_GRAMMAR_KEYWORDS.forEach(function (w) { set[w] = true; }); return set; }()); var HIGHLIGHT_KEYWORDS = RESERVED_WORDS.concat(EXTRA_GRAMMAR_KEYWORDS, ['OFFSET', 'NEXT', 'FIRST', 'ROW', 'USING', 'ROLLUP', 'CUBE', 'NOLOCK', 'IGNORE', 'RESPECT', 'NULLS']); var HIGHLIGHT_TYPES = [ 'BIGINT', 'BINARY', 'BIT', 'CHAR', 'CHARACTER', 'DATE', 'DATETIME', 'DATETIME2', 'DATETIMEOFFSET', 'DEC', 'DECIMAL', 'FLOAT', 'INT', 'INTEGER', 'MONEY', 'NCHAR', 'NUMERIC', 'NVARCHAR', 'REAL', 'SMALLDATETIME', 'SMALLINT', 'SMALLMONEY', 'SQL_VARIANT', 'TIME', 'TINYINT', 'UNIQUEIDENTIFIER', 'VARBINARY', 'VARCHAR', 'XML' ]; var RESERVED_SET = (function () { var set = {}; RESERVED_WORDS.forEach(function (w) { set[w] = true; }); return set; }()); var STATEMENT_START_DISALLOWED = [ 'INSERT', 'UPDATE', 'DELETE', 'MERGE', 'CREATE', 'ALTER', 'DROP', 'TRUNCATE', 'EXEC', 'EXECUTE', 'DECLARE', 'SET', 'PRINT', 'GOTO', 'BEGIN', 'COMMIT', 'ROLLBACK', 'WHILE', 'IF', 'USE' ]; var DOCS_BASE = 'https://mateuszdabrowski.pl/docs/salesforce/marketing-cloud-engagement/'; var DOCS = { SEL: DOCS_BASE + 'sql/sql-select/', FROM: DOCS_BASE + 'sql/sql-from/', JOIN: DOCS_BASE + 'sql/sql-join/', WHERE: DOCS_BASE + 'sql/sql-where/', CASE: DOCS_BASE + 'sql/sql-case/', LIKE: DOCS_BASE + 'sql/sql-like/', STR: DOCS_BASE + 'sql/sql-string-functions/', DATE: DOCS_BASE + 'sql/sql-date-functions/', NUM: DOCS_BASE + 'sql/sql-numeric-functions/', CONV: DOCS_BASE + 'sql/sql-conversion-functions/', AGG: DOCS_BASE + 'sql/sql-aggregate-functions/', WINDOW: DOCS_BASE + 'sql/sql-window-functions/', JSON: DOCS_BASE + 'sql/sql-json-functions/', NULLF: DOCS_BASE + 'sql/sql-null-functions/', STYLE: DOCS_BASE + 'sql/sql-style-guide/', BASICS: DOCS_BASE + 'sql/sql-basics/', DV: DOCS_BASE + 'config/system-data-views/', SFREF: 'https://help.salesforce.com/s/articleView?id=mktg.mc_as_sql_reference.htm', SFOPT: 'https://help.salesforce.com/s/articleView?id=mktg.mc_as_optimize_the_query_activity.htm', SFBUILD: 'https://help.salesforce.com/s/articleView?id=mktg.mc_as_use_the_sql_query_activity.htm', SFDV: 'https://help.salesforce.com/s/articleView?id=mktg.mc_as_data_views.htm' }; function buildLineIndex(str) { var starts = [0]; for (var i = 0; i < str.length; i++) { if (str.charCodeAt(i) === 10 /* \n */) starts.push(i + 1); }; return starts; }; function lineColAt(starts, pos) { var lo = 0, hi = starts.length - 1; while (lo < hi) { var mid = (lo + hi + 1) >> 1; if (starts[mid] <= pos) lo = mid; else hi = mid - 1; }; return { line: lo + 1, col: pos - starts[lo] + 1 }; }; var WORD_CHAR = /[\p{L}\p{Nd}_$@#]/u; var WORD_START = /[\p{L}_]/u; var COMPLETION_WORD_CHAR = /[\p{L}\p{Nd}_]/u; var DIGIT = /[0-9]/; var SPACE = /\s/; var OP_CHAR = /[=<>!+\-*\/%&|^~]/; var SIGN_SPLIT_OPERATORS = { '=': 1, '<>': 1, '!=': 1, '<': 1, '>': 1, '<=': 1, '>=': 1, '!<': 1, '!>': 1, '*': 1, '/': 1, '%': 1, '+': 1, '-': 1, '||': 1, '&': 1, '|': 1, '^': 1, '~': 1 }; function isCombiningDot(sql, i) { return sql.charAt(i) === '.' && DIGIT.test(sql.charAt(i + 1) || ''); }; function tokenize(sql) { sql = sql == null ? '' : String(sql); var tokens = []; var n = sql.length; var i = 0; var depth = 0; var lineStarts = buildLineIndex(sql); function emit(type, raw, value, start, end, isUnterminated, extra) { var lc = lineColAt(lineStarts, start); var tok = { type: type, value: value, upper: String(value).toUpperCase(), raw: raw, start: start, end: end, line: lc.line, col: lc.col }; if (isUnterminated) tok.unterminated = true; if (extra) { for (var k in extra) if (Object.prototype.hasOwnProperty.call(extra, k)) tok[k] = extra[k]; }; if (type === 'punct' && value === '(') { tok.depth = depth; depth++; } else if (type === 'punct' && value === ')') { if (depth > 0) depth--; tok.depth = depth; } else { tok.depth = depth; }; tokens.push(tok); return tok; }; while (i < n) { var c = sql.charAt(i); if (SPACE.test(c)) { i++; continue; }; if (c === '-' && sql.charAt(i + 1) === '-') { var cs = i; while (i < n && sql.charAt(i) !== '\n') i++; emit('comment', sql.slice(cs, i), sql.slice(cs, i), cs, i); continue; }; if (c === '/' && sql.charAt(i + 1) === '*') { var bs = i; i += 2; var open = 1; while (i < n && open > 0) { if (sql.charAt(i) === '/' && sql.charAt(i + 1) === '*') { open++; i += 2; continue; }; if (sql.charAt(i) === '*' && sql.charAt(i + 1) === '/') { open--; i += 2; continue; }; i++; }; emit('comment', sql.slice(bs, i), sql.slice(bs, i), bs, i, open > 0); continue; }; if (c === "'" || ((c === 'N' || c === 'n') && sql.charAt(i + 1) === "'")) { var ss = i; if (c !== "'") i++; // N prefix i++; // opening quote var isClosed = false; while (i < n) { if (sql.charAt(i) === "'") { if (sql.charAt(i + 1) === "'") { i += 2; continue; }; i++; isClosed = true; break; }; i++; }; var rawStr = sql.slice(ss, i); emit('string', rawStr, rawStr, ss, i, !isClosed); continue; }; if (c === '[') { var bs2 = i; i++; var val = ''; var isBracketClosed = false; while (i < n) { if (sql.charAt(i) === ']') { if (sql.charAt(i + 1) === ']') { val += ']'; i += 2; continue; }; i++; isBracketClosed = true; break; }; val += sql.charAt(i++); }; emit('identifier', sql.slice(bs2, i), val, bs2, i, !isBracketClosed, { quote: 'bracket' }); continue; }; if (c === '"') { var qs = i; i++; var qval = ''; var isQuoteClosed = false; while (i < n) { if (sql.charAt(i) === '"') { if (sql.charAt(i + 1) === '"') { qval += '"'; i += 2; continue; }; i++; isQuoteClosed = true; break; }; qval += sql.charAt(i++); }; emit('identifier', sql.slice(qs, i), qval, qs, i, !isQuoteClosed, { quote: 'double' }); continue; }; var isMoney = c === '$' && (DIGIT.test(sql.charAt(i + 1) || '') || isCombiningDot(sql, i + 1)); if (DIGIT.test(c) || isCombiningDot(sql, i) || isMoney) { var ns = i; if (isMoney) i++; if (c === '0' && (sql.charAt(i + 1) === 'x' || sql.charAt(i + 1) === 'X')) { i += 2; while (i < n && /[0-9a-fA-F]/.test(sql.charAt(i))) i++; } else { var digitsFrom = i; while (i < n && DIGIT.test(sql.charAt(i))) i++; var afterDot = sql.charAt(i + 1) || ''; var isNumberEnd = i > digitsFrom && !WORD_CHAR.test(afterDot) && afterDot !== '[' && afterDot !== '"' && afterDot !== '.'; if (sql.charAt(i) === '.' && (isNumberEnd || DIGIT.test(afterDot))) { i++; while (i < n && DIGIT.test(sql.charAt(i))) i++; }; if (sql.charAt(i) === 'e' || sql.charAt(i) === 'E') { var save = i; var j = i + 1; if (sql.charAt(j) === '+' || sql.charAt(j) === '-') j++; if (DIGIT.test(sql.charAt(j) || '')) { i = j; while (i < n && DIGIT.test(sql.charAt(i))) i++; } else { i = save; }; }; }; var numRaw = sql.slice(ns, i); emit('number', numRaw, numRaw, ns, i); continue; }; if (c === '@') { var vs = i; i++; if (sql.charAt(i) === '@') i++; while (i < n && WORD_CHAR.test(sql.charAt(i))) i++; var vraw = sql.slice(vs, i); emit('variable', vraw, vraw, vs, i); continue; }; if (c === '#') { var ts = i; i++; if (sql.charAt(i) === '#') i++; while (i < n && WORD_CHAR.test(sql.charAt(i))) i++; var traw = sql.slice(ts, i); emit('temp', traw, traw, ts, i); continue; }; if (WORD_START.test(c)) { var ws = i; while (i < n && WORD_CHAR.test(sql.charAt(i))) i++; var wraw = sql.slice(ws, i); var wupper = wraw.toUpperCase(); var wtype = TOKEN_KEYWORDS[wupper] ? 'keyword' : 'identifier'; emit(wtype, wraw, wraw, ws, i); continue; }; if (c === '(' || c === ')' || c === ',' || c === '.' || c === ';') { emit('punct', c, c, i, i + 1); i++; continue; }; if (OP_CHAR.test(c)) { var os = i; while (i < n && OP_CHAR.test(sql.charAt(i)) && !(i > os && (sql.substr(i, 2) === '--' || sql.substr(i, 2) === '/*'))) i++; var oraw = sql.slice(os, i); for (var cut = oraw.length - 1; cut >= 1; cut--) { if (SIGN_SPLIT_OPERATORS[oraw.slice(0, cut)] && /^[+\-~]+$/.test(oraw.slice(cut))) { oraw = oraw.slice(0, cut); i = os + cut; break; }; }; emit('operator', oraw, oraw, os, i); continue; }; emit('unknown', c, c, i, i + 1); i++; }; var significant = tokens.filter(function (t) { return t.type !== 'comment'; }); for (var k = 2; k < significant.length; k++) { var fromTok = significant[k]; if (fromTok.type !== 'keyword' || fromTok.upper !== 'FROM' || significant[k - 1].upper !== 'DISTINCT') continue; var beforeDistinct = significant[k - 2]; var beforeNot = significant[k - 3]; if (beforeDistinct.upper === 'IS' || (beforeDistinct.upper === 'NOT' && beforeNot && beforeNot.upper === 'IS')) fromTok.isDistinctFrom = true; }; return tokens; }; function isKw(tok, word) { return !!tok && tok.type === 'keyword' && tok.upper === word && !(word === 'FROM' && tok.isDistinctFrom); } function isKwAny(tok, words) { return !!tok && tok.type === 'keyword' && words.indexOf(tok.upper) !== -1 && !(tok.upper === 'FROM' && tok.isDistinctFrom); } function isName(tok) { return !!tok && tok.type === 'identifier'; } function isBareName(tok) { return !!tok && tok.type === 'identifier' && !tok.quote; } function isUnreservedKeyword(tok) { return !!tok && tok.type === 'keyword' && !tok.quote && !RESERVED_SET[tok.upper]; } function matchParen(tokens, i) { if (tokens[i] && tokens[i].type === 'punct' && tokens[i].value === '(') { var known = parenMatchesOf(tokens)[i]; return known === -1 ? tokens.length - 1 : known; }; var d = 0; for (var j = i; j < tokens.length; j++) { if (tokens[j].type === 'punct' && tokens[j].value === '(') { d++; } else if (tokens[j].type === 'punct' && tokens[j].value === ')') { d--; if (d === 0) return j; }; }; return tokens.length - 1; }; function parenMatchesOf(tokens) { var kept = tokens.parenMatches; if (kept && kept.length === tokens.length) return kept; var matches = new Array(tokens.length); var open = []; for (var k = 0; k < tokens.length; k++) { var t = tokens[k]; if (t.type !== 'punct') continue; if (t.value === '(') { matches[k] = -1; open.push(k); } else if (t.value === ')' && open.length) { matches[open.pop()] = k; }; }; try { Object.defineProperty(tokens, 'parenMatches', { value: matches, enumerable: false, configurable: true, writable: true }); } catch (e) { }; return matches; }; var PAREN_QUERY_MAX_RUN = 32; var PAREN_QUERY_MAX_DEPTH = 100; var SET_OPERATOR_WORDS = { UNION: 1, EXCEPT: 1, INTERSECT: 1 }; var MCE077_PLACES = ['START', 'FROM', 'JOIN', 'APPLY', 'GROUP']; function fromListCommas(core) { var isFromAt = []; var commas = {}; for (var k = 0; k < core.length; k++) { var t = core[k]; if (t.type === 'punct' && t.value === '(') { isFromAt[t.depth + 1] = false; continue; }; if (t.type === 'punct' && t.value === ',' && isFromAt[t.depth] === true) commas[k] = true; if (t.type === 'punct' && t.value === ';') isFromAt[t.depth] = false; if (t.type === 'identifier' && !t.quote && t.upper === 'WINDOW' && isName(core[k + 1]) && isKw(core[k + 2], 'AS') && core[k + 3] && core[k + 3].type === 'punct' && core[k + 3].value === '(') { isFromAt[t.depth] = false; continue; }; if (t.type !== 'keyword') continue; if (isKw(t, 'FROM')) { isFromAt[t.depth] = true; } else if (['SELECT', 'WHERE', 'GROUP', 'HAVING', 'ORDER', 'UNION', 'EXCEPT', 'INTERSECT', 'OPTION', 'WINDOW'].indexOf(t.upper) !== -1 || (t.upper === 'FOR' && core[k + 1] && ['XML', 'JSON', 'BROWSE'].indexOf(core[k + 1].upper) !== -1)) { isFromAt[t.depth] = false; }; }; return commas; }; function parenCloseIndex(tokens, openIdx, from, end) { var depth = tokens[openIdx].depth; var known = tokens[openIdx].type === 'punct' && tokens[openIdx].value === '(' ? parenMatchesOf(tokens)[openIdx] : undefined; if (known === -1) return -1; if (known >= from && tokens[known].depth === depth) return known < end ? known : -1; for (var j = from; j < end; j++) { var t = tokens[j]; if (t.type === 'punct' && t.value === ')' && t.depth === depth) return j; }; return -1; }; function parenRunToSelect(tokens, i, end) { var r = i; while (r < end && tokens[r].type === 'punct' && tokens[r].value === '(') { r++; if (r - i > PAREN_QUERY_MAX_RUN) return -1; }; return r > i && r < end && isKw(tokens[r], 'SELECT') ? r : -1; }; function parenQueryFollows(tok) { if (!tok) return true; if (tok.type === 'punct') return tok.value === ')' || tok.value === '('; return tok.type === 'keyword' && (tok.upper === 'UNION' || tok.upper === 'EXCEPT' || tok.upper === 'INTERSECT' || tok.upper === 'ORDER' || tok.upper === 'FOR'); }; function parenHoldsQuery(tokens, openIdx, end) { var next = tokens[openIdx + 1]; if (openIdx + 1 >= end || !next) return false; if (isKwAny(next, ['SELECT', 'WITH'])) return true; var select = parenRunToSelect(tokens, openIdx + 1, end); if (select === -1) return false; var from = select + 1; for (var p = select - 1; p > openIdx; p--) { var close = parenCloseIndex(tokens, p, from, end); if (close === -1) return true; if (!parenQueryFollows(close + 1 < end ? tokens[close + 1] : null)) return false; from = close + 1; }; return true; }; function isBoundaryKeyword(tok, nextTok) { if (!tok || tok.type !== 'keyword' || tok.isDistinctFrom) return false; if (tok.upper === 'FROM' || tok.upper === 'WHERE' || tok.upper === 'HAVING' || tok.upper === 'UNION' || tok.upper === 'EXCEPT' || tok.upper === 'INTERSECT') return true; if (tok.upper === 'GROUP' || tok.upper === 'ORDER') return isKw(nextTok, 'BY'); return false; }; function stringLiteralContent(tok) { if (!tok || tok.type !== 'string') return ''; var v = tok.value; if (v.charAt(0) === 'N' || v.charAt(0) === 'n') v = v.slice(1); if (v.charAt(0) === "'") v = v.slice(1); if (v.charAt(v.length - 1) === "'") v = v.slice(0, -1); return v.replace(/''/g, "'"); }; function tokenSpanText(sql, a, b) { if (!a || !b) return ''; return sql.slice(a.start, b.end); }; function isGoLine(sql, tokens, idx) { var t = tokens[idx]; if (!t || t.type !== 'identifier' || t.quote || t.upper !== 'GO') return false; var prev = tokens[idx - 1], next = tokens[idx + 1]; var isLineStart = !prev || sql.slice(prev.end, t.start).indexOf('\n') !== -1; var isLineEnd = !next || sql.slice(t.end, next.start).indexOf('\n') !== -1; return isLineStart && isLineEnd; }; function withoutComments(tokens) { for (var i = 0; i < tokens.length; i++) { if (tokens[i].type === 'comment') return tokens.filter(function (t) { return t.type !== 'comment'; }); }; return tokens; }; function buildIndex(schema) { if (!schema || typeof schema !== 'object') schema = { tables: [] }; if (!Array.isArray(schema.tables)) schema.tables = []; var byName = {}; var sharedTables = []; var put = function (name, t) { var key = String(name).toLowerCase(); var held = byName[key]; if (held && held.kind === 'dataview' && t.kind !== 'dataview') return; byName[key] = t; }; schema.tables.forEach(function (t) { if (!t) return; if (t.isShared) { sharedTables.push(t); return; }; if (t.name) put(t.name, t); if (t.key && t.key !== t.name) put(t.key, t); }); sharedTables.forEach(function (t) { if (t.name) byName['ent.' + String(t.name).toLowerCase()] = t; }); schema.byName = byName; return schema; }; function ensureIndex(schema) { if (schema && schema.byName) return schema.byName; if (schema && Array.isArray(schema.tables)) { buildIndex(schema); return schema.byName; }; return {}; }; function stripEnt(name) { return String(name || '').replace(/^ENT\.\s*/i, ''); }; function lookupTable(byName, name) { if (!name) return null; var clean = stripEnt(name).replace(/^\[|\]$/g, ''); if (/^ENT\./i.test(String(name))) { var exact = byName['ent.' + clean.toLowerCase()]; if (exact) return exact; }; return byName[clean.toLowerCase()] || null; }; function reservedWordNames(sql, core, byName) { var found = []; if (!byName) return found; var isWord = function (t) { return !!t && (t.type === 'identifier' || t.type === 'keyword') && !t.quote; }; for (var i = 0; i + 2 < core.length; i++) { var t = core[i]; if (t.type !== 'keyword' || t.isDistinctFrom || (t.upper !== 'FROM' && t.upper !== 'JOIN' && t.upper !== 'APPLY')) continue; if (!isWord(core[i + 1]) || core[i + 1].type !== 'keyword' || !RESERVED_SET[core[i + 1].upper]) continue; var last = i + 1; while (last + 1 < core.length && last - i < 6 && isWord(core[last + 1]) && /^\s+$/.test(sql.slice(core[last].end, core[last + 1].start))) last++; for (var w = last; w >= i + 2; w--) { var table = byName[sql.slice(core[i + 1].start, core[w].end).replace(/\s+/g, ' ').toLowerCase()]; if (!table || table.kind === 'dataview') continue; found.push({ first: i + 1, last: w, table: table }); i = w; break; }; }; return found; }; function withoutMisreadTables(unknowns, ctx) { if (!unknowns.length || !ctx.schema || !ctx.schema.byName) return unknowns; var core = ctx.getCoreTokens(); var misread = []; var readElsewhere = []; ctx.getSelectScopes().forEach(function (scope) { scope.from.forEach(function (f) { var table = fieldCheckTable(ctx.schema.byName, f); if (!table) return; var before = core[tokenIndexAtOrAfter(core, f.start) - 1]; var isMisread = !!before && before.type === 'keyword' && ['FROM', 'JOIN', 'APPLY'].indexOf(before.upper) === -1; (isMisread ? misread : readElsewhere).push(table); }); }); if (!misread.length) return unknowns; return unknowns.filter(function (u) { return misread.indexOf(u.table) === -1 || readElsewhere.indexOf(u.table) !== -1; }); }; function fieldCheckTable(byName, f) { if (!byName || !f || f.kind !== 'table' || !f.name || f.isFunction) return null; var name = f.name.replace(/^\[|\]$/g, ''); if (!/^ENT\./i.test(name)) return lookupTable(byName, name); var clean = stripEnt(name).replace(/^\[|\]$/g, '').toLowerCase(); var parentTable = byName['ent.' + clean]; if (parentTable) return parentTable; var view = byName[clean]; return view && view.kind === 'dataview' ? view : null; }; function parseNameChain(tokens, i, end) { var parts = []; var j = i; while (j < end) { var t = tokens[j]; if (isName(t) || t.type === 'number') { parts.push(t); j++; } else break; if (tokens[j] && tokens[j].type === 'punct' && tokens[j].value === '.') { j++; continue; }; break; }; return { parts: parts, end: j }; }; function nameChainText(chain) { return chain.parts.map(function (p) { return p.value; }).join('.'); }; function parseSelect(tokens, start, end, ctx, sql) { if (!ctx.parseMemo) return parseSelectOnce(tokens, start, end, ctx, sql); var memoKey = start + ':' + end; if (!Object.prototype.hasOwnProperty.call(ctx.parseMemo, memoKey)) ctx.parseMemo[memoKey] = parseSelectOnce(tokens, start, end, ctx, sql); return ctx.parseMemo[memoKey]; }; function parseSelectOnce(tokens, start, end, ctx, sql) { var i = start; var errors = []; var localCtes = []; if (isKw(tokens[i], 'WITH')) { i++; while (i < end) { var nameTok = tokens[i]; if (!isName(nameTok)) break; i++; var declaredColumns = null; if (tokens[i] && tokens[i].type === 'punct' && tokens[i].value === '(') { var e0 = matchParen(tokens, i); declaredColumns = tokens.slice(i + 1, e0).filter(isName).map(function (t) { return t.value; }); i = e0 + 1; }; if (isKw(tokens[i], 'AS')) i++; if (tokens[i] && tokens[i].type === 'punct' && tokens[i].value === '(') { var e1 = matchParen(tokens, i); var inner = parseSelect(tokens, i + 1, e1, ctx, sql); var cols = declaredColumns || inner.selectColumnNames; var key = nameTok.value.toUpperCase(); ctx.ctes[key] = { name: nameTok.value, columns: cols, start: nameTok.start, end: e1 + 1, inner: inner }; localCtes.push(ctx.ctes[key]); i = e1 + 1; }; if (tokens[i] && tokens[i].type === 'punct' && tokens[i].value === ',') { i++; continue; }; break; }; }; if (i < end && tokens[i].type === 'punct' && tokens[i].value === '(' && parenRunToSelect(tokens, i, end) !== -1) { return parseParenQuery(tokens, start, i, end, ctx, sql, localCtes); }; if (!isKw(tokens[i], 'SELECT')) return noSelectShape(tokens, start, end, i, localCtes); var selectTok = tokens[i]; i++; var isDistinct = false; if (isKwAny(tokens[i], ['DISTINCT', 'ALL'])) { isDistinct = tokens[i].upper === 'DISTINCT'; i++; }; var top = null; if (isKw(tokens[i], 'TOP') && (i + 1 >= end || isPunctToken(tokens[i + 1], ';'))) { top = { start: tokens[i].start, end: tokens[i].end, percent: false, withTies: false, isUnfinished: true }; i++; } else if (isKw(tokens[i], 'TOP')) { var topStart = tokens[i].start; i++; if (tokens[i] && tokens[i].type === 'punct' && tokens[i].value === '(') { var te = matchParen(tokens, i); i = te + 1; } else { i++; }; var isPercent = false, hasTies = false; if (isKw(tokens[i], 'PERCENT')) { isPercent = true; i++; }; if (isKw(tokens[i], 'WITH') && isKw(tokens[i + 1], 'TIES')) { hasTies = true; i += 2; }; top = { start: topStart, end: tokens[i - 1].end, percent: isPercent, withTies: hasTies }; }; var baseDepth = tokens[i - 1] ? tokens[i - 1].depth : 0; var listStart = tokens[i] ? tokens[i].start : (tokens[i - 1] ? tokens[i - 1].end : 0); var items = []; var itemBounds = []; var currentTokens = []; var curStart = listStart; var fromIdx = -1; var stopIdx = end; for (; i < end; i++) { var x = tokens[i]; if (x.type === 'punct' && x.value === ',' && x.depth === baseDepth) { items.push(currentTokens); itemBounds.push({ start: curStart, end: x.start }); currentTokens = []; curStart = x.end; continue; }; var isForClause = isKw(x, 'FOR') && !!tokens[i + 1] && ['JSON', 'XML', 'BROWSE'].indexOf(tokens[i + 1].upper) !== -1; if (x.depth === baseDepth && ((x.type === 'punct' && x.value === ';') || isBoundaryKeyword(x, tokens[i + 1]) || isForClause)) { if (x.upper === 'FROM') fromIdx = i; stopIdx = i; break; }; currentTokens.push(x); }; var finalBoundEnd = tokens[stopIdx] ? tokens[stopIdx].start : (tokens[end - 1] ? tokens[end - 1].end : curStart); if (currentTokens.length || items.length) { items.push(currentTokens); itemBounds.push({ start: curStart, end: finalBoundEnd }); }; var listEnd = itemBounds.length ? itemBounds[itemBounds.length - 1].end : listStart; var selectItems = items.map(function (it, idx) { var item = classifyItem(it, sql); if (item.kind === 'empty') { item.start = itemBounds[idx].start; item.end = itemBounds[idx].end; }; return item; }); var fromTables = []; var joinGroups = []; if (fromIdx >= 0) { var j = fromIdx + 1; var isExpectingTable = true; // true right after FROM/JOIN/,/APPLY var groupDepth = baseDepth; var openGroupStarts = []; while (j < end) { var y = tokens[j]; if (y.depth === baseDepth && isBoundaryKeyword(y, tokens[j + 1]) && y.upper !== 'FROM') break; if (y.depth === baseDepth && y.type === 'punct' && y.value === ';') break; if (y.type === 'punct' && y.value === '(' && isExpectingTable && y.depth === groupDepth && tokens[j + 1] && (tokens[j + 1].type === 'identifier' || (tokens[j + 1].type === 'punct' && tokens[j + 1].value === '(' && !parenHoldsQuery(tokens, j, end)))) { groupDepth++; openGroupStarts.push(y.start); j++; continue; }; if (y.type === 'punct' && y.value === ')' && groupDepth > baseDepth && y.depth === groupDepth - 1) { groupDepth--; joinGroups.push({ start: openGroupStarts.pop(), end: y.end }); j++; continue; }; if (y.type === 'punct' && y.value === '(') { var pe = matchParen(tokens, j); var inner2 = parseSelect(tokens, j + 1, pe, ctx, sql); j = pe + 1; var hasAs2 = isKw(tokens[j], 'AS'); if (hasAs2) j++; var alias2 = null; var declaredColumns2 = null; if (isName(tokens[j]) || isUnreservedKeyword(tokens[j])) { alias2 = tokens[j].value; j++; if (tokens[j] && tokens[j].type === 'punct' && tokens[j].value === '(') { var listClose = matchParen(tokens, j); declaredColumns2 = tokens.slice(j + 1, listClose).filter(isName).map(function (t) { return t.value; }); j = listClose + 1; }; }; fromTables.push({ name: null, alias: alias2, kind: 'derived', start: y.start, end: (tokens[j - 1] || y).end, columns: declaredColumns2 || inner2.selectColumnNames, inner: inner2 }); isExpectingTable = false; continue; }; if (y.depth === groupDepth && y.type === 'keyword' && (y.upper === 'JOIN' || (y.upper === 'APPLY' && !isExpectingTable))) { j++; isExpectingTable = true; continue; }; if (y.depth === groupDepth && y.type === 'keyword' && ['INNER', 'LEFT', 'RIGHT', 'FULL', 'OUTER', 'CROSS'].indexOf(y.upper) !== -1) { j++; continue; }; if (y.depth === groupDepth && y.type === 'punct' && y.value === ',') { j++; isExpectingTable = true; continue; }; if (isExpectingTable && (isName(y) || y.type === 'number' || isUnreservedKeyword(y))) { var chain = referenceChain(tokens, j, end, true); var refStart = y.start; j = chain.end; var isFunction = !!(tokens[j] && tokens[j].type === 'punct' && tokens[j].value === '('); if (isFunction) { j = matchParen(tokens, j) + 1; if (isKw(tokens[j], 'WITH') && tokens[j + 1] && tokens[j + 1].type === 'punct' && tokens[j + 1].value === '(') j = matchParen(tokens, j + 1) + 1; }; var hasAs3 = isKw(tokens[j], 'AS'); if (hasAs3) j++; var alias3 = null; if (isUnreservedKeyword(tokens[j]) && (hasAs3 || !isKw(tokens[j], 'APPLY'))) { alias3 = tokens[j].value; j++; } else if (isName(tokens[j]) && !isKwAny(tokens[j], []) ) { if (tokens[j].type === 'identifier') { alias3 = tokens[j].value; j++; }; }; var tableEntry = { name: nameChainText(chain), nameParts: chain.parts.length, alias: alias3, kind: 'table', start: refStart, end: (tokens[j - 1] || y).end }; if (!isFunction && isKw(tokens[j], 'TABLESAMPLE')) { j++; if (isName(tokens[j]) && tokens[j].upper === 'SYSTEM') j++; if (tokens[j] && tokens[j].type === 'punct' && tokens[j].value === '(') j = matchParen(tokens, j) + 1; if (isName(tokens[j]) && tokens[j].upper === 'REPEATABLE' && tokens[j + 1] && tokens[j + 1].type === 'punct' && tokens[j + 1].value === '(') j = matchParen(tokens, j + 1) + 1; }; if (isFunction) tableEntry.isFunction = true; if (groupDepth > baseDepth) tableEntry.isInGroup = true; fromTables.push(tableEntry); isExpectingTable = false; continue; }; j++; }; }; var fromEnd = fromIdx >= 0 ? (fromTables.length ? fromTables[fromTables.length - 1].end : tokens[fromIdx].end) : listEnd; var clauseScanStart = fromIdx >= 0 ? findClauseScanStart(tokens, fromIdx + 1, end, baseDepth) : stopIdx; var clauses = { where: null, groupBy: null, having: null, orderBy: null, top: top, distinct: isDistinct, union: [], forClause: null }; var k = clauseScanStart; var stopAll = { UNION: 1, EXCEPT: 1, INTERSECT: 1 }; while (k < end) { var ct = tokens[k]; if (ct.depth === baseDepth && ct.type === 'keyword' && stopAll[ct.upper]) break; if (ct.depth === baseDepth && ct.type === 'punct' && ct.value === ';') break; if (ct.depth === baseDepth && isKw(ct, 'WHERE')) { var wEnd = findClauseEnd(tokens, k + 1, end, baseDepth); clauses.where = { start: ct.start, end: wEnd > k + 1 ? tokens[wEnd - 1].end : ct.end }; k = wEnd; continue; }; if (ct.depth === baseDepth && isKw(ct, 'GROUP') && isKw(tokens[k + 1], 'BY')) { var gStart = k; var gi = k + 1; if (isKw(tokens[gi], 'BY')) gi++; var gEnd = findClauseEnd(tokens, gi, end, baseDepth); clauses.groupBy = { start: tokens[gStart].start, end: gEnd > gi ? tokens[gEnd - 1].end : tokens[gStart].end }; k = gEnd; continue; }; if (ct.depth === baseDepth && isKw(ct, 'HAVING')) { var hEnd = findClauseEnd(tokens, k + 1, end, baseDepth); clauses.having = { start: ct.start, end: hEnd > k + 1 ? tokens[hEnd - 1].end : ct.end }; k = hEnd; continue; }; if (ct.depth === baseDepth && isKw(ct, 'ORDER') && isKw(tokens[k + 1], 'BY')) { var oStart = k; var oi = k + 1; if (isKw(tokens[oi], 'BY')) oi++; var oEnd = findClauseEnd(tokens, oi, end, baseDepth); clauses.orderBy = { start: tokens[oStart].start, end: oEnd > oi ? tokens[oEnd - 1].end : tokens[oStart].end }; k = oEnd; continue; }; k++; }; for (var fk = stopIdx; fk < k; fk++) { var forTok = tokens[fk]; if (forTok.depth === baseDepth && isKw(forTok, 'FOR') && tokens[fk + 1] && ['JSON', 'XML', 'BROWSE'].indexOf(tokens[fk + 1].upper) !== -1) { clauses.forClause = tokens[fk + 1].upper; break; }; }; var branches = [{ start: selectTok.start, end: k <= end ? (tokens[Math.max(k - 1, i - 1)] || selectTok).end : selectTok.end, itemCount: selectItems.length }]; var parenOperands = []; readSetOperands(tokens, k, end, baseDepth, ctx, sql, branches, parenOperands); clauses.union = branches; var overallEnd = tokens[end - 1] ? tokens[end - 1].end : (tokens[start] ? tokens[start].end : 0); var fromForLookup = fromTables; selectItems.forEach(function (item) { if (item.kind === 'star') { var srcCols = resolveStarSource(item.starTable, fromForLookup, ctx, sql); item._resolvedColumns = srcCols; // may be null (unresolved) item._certainColumns = resolveStarSource(item.starTable, fromForLookup, ctx, sql, true); }; }); var selectColumnNames = []; var certainColumnNames = []; selectItems.forEach(function (item) { if (item.kind === 'star') { if (item._resolvedColumns) selectColumnNames = selectColumnNames.concat(item._resolvedColumns); else selectColumnNames.push(null); if (certainColumnNames && item._certainColumns) certainColumnNames = certainColumnNames.concat(item._certainColumns); else certainColumnNames = null; } else if (item.kind === 'column' || item.kind === 'alias') { selectColumnNames.push(item.name); if (certainColumnNames) certainColumnNames.push(item.name); } else { selectColumnNames.push(null); if (certainColumnNames) certainColumnNames.push(null); }; }); var result = { start: selectTok.start, end: overallEnd, ctes: localCtes, selectList: { start: listStart, end: listEnd, items: selectItems }, selectColumnNames: selectColumnNames, certainColumnNames: certainColumnNames, from: fromTables, joinGroups: joinGroups, clauses: clauses, errors: errors }; if (parenOperands.length) result.parenOperands = parenOperands; return result; }; function noSelectShape(tokens, start, end, i, localCtes) { var errors = [{ message: 'no top-level SELECT found', start: tokens[i] ? tokens[i].start : (tokens[start - 1] ? tokens[start - 1].end : 0), end: tokens[i] ? tokens[i].end : (tokens[start - 1] ? tokens[start - 1].end : 0) }]; return { start: start < tokens.length ? tokens[start].start : 0, end: end > 0 && tokens[end - 1] ? tokens[end - 1].end : 0, ctes: localCtes, selectList: { start: 0, end: 0, items: [] }, selectColumnNames: [], from: [], clauses: { where: null, groupBy: null, having: null, orderBy: null, top: null, distinct: false, union: [] }, errors: errors, noSelect: true }; }; function readSetOperands(tokens, from, end, baseDepth, ctx, sql, branches, parenOperands) { var bIdx = from; while (bIdx < end && tokens[bIdx] && tokens[bIdx].depth === baseDepth && tokens[bIdx].type === 'keyword' && SET_OPERATOR_WORDS[tokens[bIdx].upper]) { var opTok = tokens[bIdx]; var isAll = false; var afterOp = bIdx + 1; if (opTok.upper === 'UNION' && isKw(tokens[afterOp], 'ALL')) { isAll = true; afterOp++; }; var branchEnd = end; if (afterOp < end && parenRunToSelect(tokens, afterOp, end) !== -1) { var operandClose = parenCloseIndex(tokens, afterOp, afterOp + 1, end); branchEnd = operandClose === -1 ? end : operandClose + 1; var operand = parseSelect(tokens, afterOp, branchEnd, ctx, sql); parenOperands.push(operand); var parts = []; (operand.clauses.union || []).forEach(function (b) { (b.parts && b.parts.length > 1 ? b.parts : [b]).forEach(function (part) { parts.push({ start: part.start, end: part.end, itemCount: part.itemCount, hasStar: !!part.hasStar }); }); }); branches.push({ start: opTok.start, end: tokens[branchEnd - 1].end, op: opTok.upper, all: isAll, itemCount: operand.selectList.items.length, hasStar: operand.selectList.items.some(function (item) { return item.kind === 'star'; }) || parts.some(function (part) { return part.hasStar; }), isParenthesised: true, parts: parts }); bIdx = branchEnd; continue; }; var scan = afterOp; while (scan < end) { var bt = tokens[scan]; if (bt.depth === baseDepth && bt.type === 'keyword' && SET_OPERATOR_WORDS[bt.upper]) { branchEnd = scan; break; }; if (bt.depth === baseDepth && bt.type === 'punct' && bt.value === ';') { branchEnd = scan; break; }; scan++; }; var branchCount = countSelectItems(tokens, afterOp, branchEnd, ctx, sql); branches.push({ start: opTok.start, end: tokens[branchEnd - 1] ? tokens[branchEnd - 1].end : opTok.end, op: opTok.upper, all: isAll, itemCount: branchCount.count, hasStar: branchCount.hasStar }); bIdx = branchEnd; }; return bIdx; }; function parseParenQuery(tokens, start, i, end, ctx, sql, localCtes) { ctx.parenLevel = (ctx.parenLevel || 0) + 1; try { if (ctx.parenLevel > PAREN_QUERY_MAX_DEPTH) return noSelectShape(tokens, start, end, i, localCtes); var baseDepth = tokens[i].depth; var close = parenCloseIndex(tokens, i, i + 1, end); var first = parseSelect(tokens, i + 1, close === -1 ? end : close, ctx, sql); if (first.noSelect) return first; var branches = first.clauses.union.slice(); branches[0] = Object.assign({}, branches[0], { isParenthesised: true }); var operands = [first]; var pos = readSetOperands(tokens, close === -1 ? end : close + 1, end, baseDepth, ctx, sql, branches, operands); var trailingOrderBy = null; var forClause = null; for (var q = pos; q < end; q++) { var tail = tokens[q]; if (tail.depth !== baseDepth) continue; if (tail.type === 'punct' && tail.value === ';') break; if (!trailingOrderBy && isKw(tail, 'ORDER') && isKw(tokens[q + 1], 'BY')) { var orderEnd = findClauseEnd(tokens, q + 2, end, baseDepth); trailingOrderBy = { start: tail.start, end: orderEnd > q + 2 ? tokens[orderEnd - 1].end : tokens[q + 1].end }; q = Math.max(q, orderEnd - 1); } else if (!forClause && isKw(tail, 'FOR') && tokens[q + 1] && ['JSON', 'XML', 'BROWSE'].indexOf(tokens[q + 1].upper) !== -1) { forClause = tokens[q + 1].upper; }; }; var overallEnd = tokens[end - 1] ? tokens[end - 1].end : first.end; var ctes = localCtes.length ? localCtes.concat(first.ctes || []) : first.ctes; if (branches.length === first.clauses.union.length && !trailingOrderBy && !forClause) { return Object.assign({}, first, { end: overallEnd, ctes: ctes, clauses: Object.assign({}, first.clauses, { union: branches }) }); }; var clauses = Object.assign({}, first.clauses, { union: branches }); if (forClause) clauses.forClause = forClause; return Object.assign({}, first, { end: overallEnd, ctes: ctes, clauses: clauses, isParenLed: true, parenOperands: operands, trailingOrderBy: trailingOrderBy }); } finally { ctx.parenLevel--; }; }; function findClauseScanStart(tokens, i, end, baseDepth) { var j = i; while (j < end) { var t = tokens[j]; if (t.depth === baseDepth && isBoundaryKeyword(t, tokens[j + 1]) && t.upper !== 'FROM') return j; if (t.depth === baseDepth && t.type === 'punct' && t.value === ';') return j; j++; }; return end; }; function findClauseEnd(tokens, i, end, baseDepth) { var j = i; while (j < end) { var t = tokens[j]; if (t.depth === baseDepth && isBoundaryKeyword(t, tokens[j + 1]) && t.upper !== 'FROM') return j; if (t.depth === baseDepth && t.type === 'punct' && t.value === ';') return j; j++; }; return end; }; function countSelectItems(tokens, start, end, ctx, sql) { var i = start; var none = { count: 0, hasStar: false }; if (isKw(tokens[i], 'WITH')) return none; // unusual; not expected mid-union if (!isKw(tokens[i], 'SELECT')) return none; i++; if (isKwAny(tokens[i], ['DISTINCT', 'ALL'])) i++; if (isKw(tokens[i], 'TOP')) { i++; if (tokens[i] && tokens[i].type === 'punct' && tokens[i].value === '(') i = matchParen(tokens, i) + 1; else i++; if (isKw(tokens[i], 'PERCENT')) i++; if (isKw(tokens[i], 'WITH') && isKw(tokens[i + 1], 'TIES')) i += 2; }; var baseDepth = tokens[i - 1] ? tokens[i - 1].depth : 0; var count = 0, hasAny = false, hasStar = false; for (; i < end; i++) { var x = tokens[i]; if (x.depth === baseDepth && x.type === 'punct' && x.value === ',') { count++; hasAny = false; continue; }; if (x.depth === baseDepth && ((x.type === 'punct' && x.value === ';') || isBoundaryKeyword(x, tokens[i + 1]))) break; var prevX = tokens[i - 1]; if (x.depth === baseDepth && x.type === 'operator' && x.value === '*' && (!hasAny || (prevX && prevX.type === 'punct' && prevX.value === '.'))) hasStar = true; hasAny = true; }; if (hasAny) count++; return { count: count, hasStar: hasStar }; }; function resolveStarSource(qualifier, fromTables, ctx, sql, isCertain) { var entry; if (qualifier) { entry = fromTables.filter(function (f) { return (f.alias && f.alias.toUpperCase() === qualifier.toUpperCase()) || (f.name && f.name.toUpperCase() === qualifier.toUpperCase()); })[0]; } else if (fromTables.length === 1) { entry = fromTables[0]; }; if (!entry) return null; return resolveEntryColumns(entry, ctx, isCertain); }; function resolveEntryColumns(entry, ctx, isCertain) { if (!entry) return null; var ownColumns = function (owner) { if (!isCertain) return owner.columns || null; if (!owner.inner || owner.columns !== owner.inner.selectColumnNames) return owner.columns || null; return owner.inner.noSelect ? null : owner.inner.certainColumnNames || null; }; if (entry.kind === 'derived') return ownColumns(entry); if (entry.name && !entry.isFunction) { var key = stripEnt(entry.name).replace(/^\[|\]$/g, '').toUpperCase(); if (ctx.ctes[key]) return ownColumns(ctx.ctes[key]); var table = isCertain ? fieldCheckTable(ctx.schemaIndex, entry) : lookupTable(ctx.schemaIndex, entry.name); var isListed = !!table && Array.isArray(table.fields) && (!isCertain || table.fields.length > 0); if (isListed && !hasOpenFieldList(table)) return table.fields.map(function (f) { return f.name; }); }; return null; }; function hasOpenFieldList(table) { return !!table && Array.isArray(table.fields) && table.fields.some(function (f) { return !!f && typeof f.name === 'string' && f.name.charAt(0) === '['; }); }; function plainColumnSource(exprTokens) { if (!exprTokens.length || exprTokens.length % 2 === 0) return null; var isPlainChain = exprTokens.every(function (t, idx) { return idx % 2 === 0 ? (isName(t) || isUnreservedKeyword(t)) : (t.type === 'punct' && t.value === '.'); }); if (!isPlainChain) return null; var column = exprTokens[exprTokens.length - 1]; return { sourceTable: exprTokens.length >= 3 ? exprTokens[exprTokens.length - 3].value : null, sourceColumn: column.value, sourceStart: column.start, sourceEnd: column.end }; }; function cteSubqueryFix(sql, significant, schema) { var name = significant[1]; var asKw = significant[2]; var open = significant[3]; if (!name || !isName(name) || !isKw(asKw, 'AS') || !open || open.value !== '(') return null; var depth = 0; var closeIndex = -1; for (var i = 3; i < significant.length; i++) { if (significant[i].value === '(') depth++; if (significant[i].value === ')') depth--; if (depth === 0) { closeIndex = i; break; }; }; var mainStart = significant[closeIndex + 1]; if (closeIndex === -1 || !mainStart || !isKw(mainStart, 'SELECT')) return null; var nameUpper = name.value.toUpperCase(); var refs = []; for (var j = closeIndex + 1; j < significant.length; j++) { var t = significant[j]; if (!isName(t) || t.value.toUpperCase() !== nameUpper) continue; var previous = significant[j - 1]; var next = significant[j + 1]; if (next && next.value === '.') continue; if (!(isKw(previous, 'FROM') || isKw(previous, 'JOIN'))) return null; refs.push({ token: t, next: next }); }; if (refs.length !== 1) return null; var body = sql.slice(open.end, significant[closeIndex].start).trim(); var ref = refs[0]; var hasAlias = ref.next && (isKw(ref.next, 'AS') || (isUnreservedKeyword(ref.next) && !isKw(ref.next, 'APPLY')) || (isName(ref.next) && !isKw(ref.next, 'WHERE') && !isKw(ref.next, 'INNER') && !isKw(ref.next, 'LEFT') && !isKw(ref.next, 'RIGHT') && !isKw(ref.next, 'FULL') && !isKw(ref.next, 'CROSS') && !isKw(ref.next, 'JOIN') && !isKw(ref.next, 'ON') && !isKw(ref.next, 'GROUP') && !isKw(ref.next, 'ORDER') && !isKw(ref.next, 'UNION'))); var replacement = '(' + body + ')' + (hasAlias ? '' : ' AS ' + name.value); var edits = [ { start: significant[0].start, end: mainStart.start, text: '' }, { start: ref.token.start, end: ref.token.end, text: replacement } ]; try { var laidOut = formatSql(sql.slice(mainStart.start).replace(/\s+$/, '')); var isMainLaidOut = !laidOut.changed && !laidOut.reason && !laidOut.skipped; var bodyText = sql.slice(open.end, significant[closeIndex].start); var isBodyOnOwnLines = /^[ \t]*\r?\n/.test(bodyText); if (isBodyOnOwnLines) { var bodyLines = bodyText.replace(/^[ \t]*\r?\n/, '').replace(/\s+$/, '').split(/\r?\n/); var bodyIndent = Math.min.apply(null, bodyLines.filter(function (l) { return l.trim(); }).map(function (l) { return /^[ ]*/.exec(l)[0].length; })); var dedented = bodyLines.map(function (l) { return l.slice(Math.min(bodyIndent, /^[ ]*/.exec(l)[0].length)); }); var bodyCheck = isMainLaidOut ? formatSql(dedented.join('\n')) : null; if (bodyCheck && !bodyCheck.changed && !bodyCheck.reason && !bodyCheck.skipped) { var relaid = formatSql(applyEdits(sql, edits)); if (relaid.changed && !relaid.reason && !relaid.skipped) edits = [{ start: 0, end: sql.length, text: relaid.sql }]; } else { var lineStart = sql.lastIndexOf('\n', ref.token.start - 1) + 1; var refIndent = /^[ \t]*/.exec(sql.slice(lineStart, ref.token.start))[0]; var inner = dedented.map(function (l) { return l.trim() ? refIndent + ' ' + l : ''; }).join('\n'); edits[1] = { start: ref.token.start, end: ref.token.end, text: '(\n' + inner + '\n' + refIndent + ')' + (hasAlias ? '' : ' AS ' + name.value) }; }; }; } catch (formatError) { }; var rewritten = applyEdits(sql, edits); var bodyFrom = 0; var bodyTo = rewritten.length; if (edits.length === 2) { bodyFrom = edits[1].start - (edits[0].end - edits[0].start) + edits[0].text.length; bodyTo = bodyFrom + edits[1].text.length; }; var unwrapped = unwrapParenQueries(rewritten, bodyFrom, bodyTo, schema); if (!unwrapped) return null; if (unwrapped.text !== rewritten) { edits = edits.length === 2 ? [edits[0], { start: edits[1].start, end: edits[1].end, text: unwrapped.text.slice(bodyFrom, unwrapped.to) }] : [{ start: 0, end: sql.length, text: unwrapped.text }]; }; if (parseStrict(applyEdits(sql, edits)).errors.length > parseStrict(sql).errors.length) return null; return { title: 'Rewrite as a subquery', edits: edits, isSafe: false, isInFixAll: false }; }; function unwrapParenQueries(text, from, to, schema) { for (var round = 0; round < 4 * PAREN_QUERY_MAX_RUN; round++) { var found = parenQueryFindings(text, withoutComments(tokenize(text)), schema).filter(function (f) { return f[4].start >= from && f[4].start < to; }); if (!found.length) { return { text: text, to: to }; }; var fixes = found[0][6]; if (!fixes || !fixes.length) return null; fixes[0].edits.forEach(function (e) { to += e.text.length - (e.end - e.start); }); text = applyEdits(text, fixes[0].edits); }; return null; }; function classifyItem(tokList, sql) { var itemStart = tokList.length ? tokList[0].start : 0; var itemEnd = tokList.length ? tokList[tokList.length - 1].end : itemStart; var text = tokList.map(function (t) { return t.value; }).join(' '); var base = { start: itemStart, end: itemEnd, text: text }; if (!tokList.length) { return Object.assign({}, base, { name: null, kind: 'empty' }); }; var last = tokList[tokList.length - 1]; var prev = tokList[tokList.length - 2]; if (last.type === 'operator' && last.value === '*') { var qualifier = null; var starPrefix = ''; if (prev && prev.type === 'punct' && prev.value === '.' && tokList.length >= 3) { qualifier = tokList[tokList.length - 3].value; starPrefix = tokList.slice(0, -1).map(function (t) { return t.raw; }).join(''); }; return Object.assign({}, base, { name: null, kind: 'star', starTable: qualifier, starPrefix: starPrefix }); }; if ((isName(last) || (last.type === 'keyword' && !last.quote)) && isKw(prev, 'AS')) { return Object.assign({}, base, { name: last.value, kind: 'alias', nameStart: last.start, nameEnd: last.end, tokens: tokList }, plainColumnSource(tokList.slice(0, -2))); }; if (last.type === 'string' && isKw(prev, 'AS') && tokList.length >= 3) { return Object.assign({}, base, { name: stringLiteralContent(last), kind: 'alias', nameStart: last.start, nameEnd: last.end, tokens: tokList }); }; if (tokList.length >= 3 && (isName(tokList[0]) || isUnreservedKeyword(tokList[0]) || tokList[0].type === 'string') && tokList[1].type === 'operator' && tokList[1].value === '=') { var assignedName = tokList[0].type === 'string' ? stringLiteralContent(tokList[0]) : tokList[0].value; return Object.assign({}, base, { name: assignedName, kind: 'alias', nameStart: tokList[0].start, nameEnd: tokList[0].end, tokens: tokList }); }; var isKeywordAlias = isUnreservedKeyword(last) && last.depth === tokList[0].depth; if ((isName(last) || isKeywordAlias) && tokList.length >= 2 && !(prev && prev.type === 'punct' && prev.value === '.') && (isKw(prev, 'COLLATE') === false) && ( (prev.type === 'punct' && prev.value === ')') || isName(prev) || prev.type === 'string' || prev.type === 'number' || (isUnreservedKeyword(prev) && prev.depth === tokList[0].depth) || (prev.type === 'keyword' && OPERAND_END_KEYWORDS.indexOf(prev.upper) !== -1) )) { var implicitSource = tokList.length >= 4 ? plainColumnSource(tokList.slice(0, -1)) : null; return Object.assign({}, base, { name: last.value, kind: 'alias', nameStart: last.start, nameEnd: last.end, tokens: tokList }, implicitSource); }; var isPlainChain = tokList.every(function (t, idx) { return idx % 2 === 0 ? (isName(t) || isUnreservedKeyword(t)) : (t.type === 'punct' && t.value === '.'); }); if (isPlainChain) { var sourceTable = tokList.length >= 3 ? tokList[tokList.length - 3].value : null; return Object.assign({}, base, { name: last.value, kind: 'column', sourceTable: sourceTable, sourceColumn: last.value, nameStart: last.start, nameEnd: last.end }); }; return Object.assign({}, base, { name: null, kind: 'unnamed', tokens: tokList }); }; function parseFromTokens(allTokens, sql, schema) { var tokens = withoutComments(allTokens); var schemaIndex = ensureIndex(schema); var ctx = { schemaIndex: schemaIndex, ctes: {} }; var statements = statementSegments(sql, tokens).segments.map(function (seg) { return { start: seg.start, end: seg.end }; }).filter(function (s) { return s.end > s.start; }); if (!statements.length) statements = [{ start: 0, end: tokens.length }]; var first = statements[0]; var result = parseSelect(tokens, first.start, first.end, ctx, sql); var ctes = Object.keys(ctx.ctes).map(function (k) { var c = ctx.ctes[k]; return { name: c.name, columns: c.columns, start: c.start, end: c.end }; }); return { statements: statements.map(function (s) { return { start: tokens[s.start] ? tokens[s.start].start : 0, end: s.end > s.start ? tokens[s.end - 1].end : 0 }; }), ctes: ctes, selectList: result.selectList, from: result.from, clauses: result.clauses, errors: result.errors, _tokens: tokens, _selectColumnNames: result.selectColumnNames, _noSelect: !!result.noSelect, _ctx: ctx }; }; function parse(sql, schema) { sql = sql == null ? '' : String(sql); var tokens = tokenize(sql); return parseFromTokens(tokens, sql, schema); }; function extractColumnsFromParsed(parsed, sql) { var items = parsed.selectList.items; var issues = []; if (parsed._noSelect) { issues.push({ code: 'NO_SELECT', itemIndex: -1, start: 0, end: sql.length, message: 'No top-level SELECT found.' }); return { columns: [], items: items, issues: issues }; }; if (!items.length) { issues.push({ code: 'EMPTY', itemIndex: -1, start: parsed.selectList.start, end: parsed.selectList.end, message: 'Select list is empty.' }); }; var columns = []; var seen = {}; items.forEach(function (item, idx) { if (item.kind === 'star') { if (item._resolvedColumns) { item._resolvedColumns.forEach(function (name) { registerColumn(name, idx); }); } else { issues.push({ code: 'STAR_UNRESOLVED', itemIndex: idx, start: item.start, end: item.end, message: 'Cannot resolve columns for "' + (item.starTable || '*') + '.*".' }); columns.push(null); }; } else if (item.kind === 'column' || item.kind === 'alias') { registerColumn(item.name, idx); } else if (item.kind === 'unnamed') { issues.push({ code: 'UNNAMED', itemIndex: idx, start: item.start, end: item.end, message: 'Column expression has no alias.' }); columns.push(null); } else if (item.kind === 'empty') { issues.push({ code: 'EMPTY', itemIndex: idx, start: item.start, end: item.end, message: 'Empty select-list item (check for a trailing comma).' }); columns.push(null); }; }); function registerColumn(name, idx) { columns.push(name); var key = String(name).toLowerCase(); if (seen[key] !== undefined) { issues.push({ code: 'DUPLICATE', itemIndex: idx, firstItemIndex: seen[key], name: name, start: items[idx].start, end: items[idx].end, message: 'Duplicate output column "' + name + '".' }); } else { seen[key] = idx; }; }; return { columns: columns, items: items, issues: issues }; }; function extractColumns(sql, schema) { var parsed; try { parsed = parse(sql, schema); } catch (e) { return { columns: [], items: [], issues: [{ code: 'NO_SELECT', itemIndex: -1, start: 0, end: 0, message: 'Unable to parse query.' }] }; }; return extractColumnsFromParsed(parsed, sql); }; var TYPED_FIELD_TYPES = { date: 'Date', number: 'Number', decimal: 'Decimal', boolean: 'Boolean' }; function columnTypeOfField(field) { if (!field || typeof field.type !== 'string') return null; if (field.untyped === true) return null; var typeKey = field.type.toLowerCase(); if (!Object.prototype.hasOwnProperty.call(TYPED_FIELD_TYPES, typeKey)) return null; var result = { type: TYPED_FIELD_TYPES[typeKey] }; if (result.type === 'Decimal') { var precision = field.precision != null ? field.precision : field.length; if (typeof precision === 'number' && precision > 0) result.precision = precision; if (typeof field.scale === 'number' && field.scale >= 0) result.scale = field.scale; }; return result; }; function findFromEntry(fromTables, qualifier) { var wanted = String(qualifier).toUpperCase(); var byAlias = fromTables.filter(function (f) { return !!f.alias && f.alias.toUpperCase() === wanted; }); if (byAlias.length) return byAlias.length === 1 ? byAlias[0] : null; var byName = fromTables.filter(function (f) { return !f.alias && !!f.name && stripEnt(f.name).toUpperCase() === wanted; }); return byName.length === 1 ? byName[0] : null; }; function findFieldNamed(table, name) { var wanted = String(name).toLowerCase(); for (var i = 0; i < table.fields.length; i++) { if (table.fields[i] && String(table.fields[i].name).toLowerCase() === wanted) return table.fields[i]; }; return null; }; var TYPING_MAX_NESTING = 24; var RUN_PASS_THROUGH_FUNCTIONS = { LAG: true, LEAD: true, FIRST_VALUE: true, LAST_VALUE: true, MIN: true, MAX: true }; var TYPING_INT_MAX = '2147483647'; var RUN_DATETIME_FUNCTIONS = { GETDATE: true, GETUTCDATE: true, DATETIMEFROMPARTS: true, SMALLDATETIMEFROMPARTS: true }; function isMisreadEntry(entry) { return !!entry && entry.kind === 'derived' && !entry.alias && !!entry.inner && !entry.inner.noSelect; }; function cteOfEntry(entry, env) { if (!entry || entry.kind !== 'table' || entry.isFunction || !entry.name) return null; var parts = typeof entry.nameParts === 'number' ? entry.nameParts : String(entry.name).split('.').length; if (parts !== 1) return null; var key = entry.name.replace(/^\[|\]$/g, '').toUpperCase(); if (!Object.prototype.hasOwnProperty.call(env.ctx.ctes, key)) return null; var cte = env.ctx.ctes[key]; return (typeof cte.start !== 'number' || typeof entry.start !== 'number' || cte.start <= entry.start) ? cte : null; }; function typingTableOfEntry(entry, env) { if (!entry || entry.kind !== 'table' || entry.isFunction || !entry.name) return null; if (cteOfEntry(entry, env)) return null; if (env.record) env.record.push(entry); var table = fieldCheckTable(env.byName, entry); return table && Array.isArray(table.fields) ? table : null; }; function runInnerOfEntry(entry, env) { if (!entry || isMisreadEntry(entry)) return null; var cte = entry.kind === 'derived' ? entry : cteOfEntry(entry, env); return cte && cte.inner ? { sel: cte.inner, names: cte.columns || null } : null; }; function runIsReadable(sel) { if (!sel || sel.noSelect || sel._noSelect || !sel.selectList) return false; var clauses = sel.clauses || {}; return !(clauses.union && clauses.union.length > 1) && !clauses.forClause; }; function runOutputWidth(sel) { var width = 0; sel.selectList.items.forEach(function (item) { width += item.kind === 'star' && item._resolvedColumns ? item._resolvedColumns.length : 1; }); return width; }; function runTypeOfColumnInEntry(entry, name, env) { var inner = runInnerOfEntry(entry, env); if (inner) { if (!inner.names || !runIsReadable(inner.sel) || inner.names.length !== runOutputWidth(inner.sel)) return null; var wanted = String(name).toLowerCase(); var found = -1; var count = 0; inner.names.forEach(function (n, idx) { if (n !== null && n !== undefined && String(n).toLowerCase() === wanted) { found = idx; count++; }; }); if (count === 0) runRecordStarTables(inner.sel, name, env); return count === 1 ? runTypeOfOutput(inner.sel, found, env) : null; }; var table = typingTableOfEntry(entry, env); return table ? columnTypeOfField(findFieldNamed(table, name)) : null; }; function runRecordStarTables(sel, name, env) { if (!env.record || !sel || !sel.selectList) return; if (env.active.indexOf(sel) !== -1 || env.active.length >= TYPING_MAX_NESTING) return; var from = sel.from || []; env.active.push(sel); try { sel.selectList.items.forEach(function (item) { if (item.kind !== 'star' || (item._resolvedColumns && !item._resolvedColumns.some(function (n) { return n === null || n === undefined; }))) return; (item.starTable ? [findFromEntry(from, item.starTable)] : from).forEach(function (entry) { if (runInnerOfEntry(entry, env)) runTypeOfColumnInEntry(entry, name, env); else typingTableOfEntry(entry, env); }); }); } finally { env.active.pop(); }; }; function runTypeOfReference(sel, qualifier, name, env) { var from = sel.from || []; if (qualifier) return runTypeOfColumnInEntry(findFromEntry(from, qualifier), name, env); var wanted = String(name).toLowerCase(); var isNamed = function (n) { return n !== null && n !== undefined && String(n).toLowerCase() === wanted; }; var isUnnamed = function (n) { return n === null || n === undefined; }; var owners = []; var isUnsure = false; for (var i = 0; i < from.length; i++) { var inner = runInnerOfEntry(from[i], env); var table = inner ? null : typingTableOfEntry(from[i], env); if (inner ? !inner.names : (!table || hasOpenFieldList(table))) { if (!env.record) return null; isUnsure = true; continue; }; if (env.record && inner && inner.names.some(isUnnamed)) isUnsure = true; if (inner ? inner.names.some(isNamed) : !!findFieldNamed(table, name)) owners.push(from[i]); }; if (isUnsure) { from.forEach(function (f) { var candidate = runInnerOfEntry(f, env); if (candidate && candidate.names && (candidate.names.some(isNamed) || candidate.names.some(isUnnamed))) runTypeOfColumnInEntry(f, name, env); }); return null; }; return owners.length === 1 ? runTypeOfColumnInEntry(owners[0], name, env) : null; }; function passThroughCall(tokens) { var first = tokens[0]; if (!first || first.type !== 'identifier' || first.quote || RUN_PASS_THROUGH_FUNCTIONS[first.upper] !== true) return null; if (!tokens[1] || tokens[1].type !== 'punct' || tokens[1].value !== '(') return null; var close = closingParenOf(tokens, 1); if (close === -1) return null; var isAggregate = first.upper === 'MIN' || first.upper === 'MAX'; var rest = close + 1; if (!isAggregate && tokens[rest] && (tokens[rest].upper === 'IGNORE' || tokens[rest].upper === 'RESPECT') && tokens[rest + 1] && tokens[rest + 1].upper === 'NULLS') rest += 2; if (tokens[rest] && tokens[rest].upper === 'OVER' && tokens[rest + 1] && tokens[rest + 1].type === 'punct' && tokens[rest + 1].value === '(') { var overClose = closingParenOf(tokens, rest + 1); if (overClose === -1) return null; rest = overClose + 1; }; if (rest !== tokens.length) return null; var args = tokens.slice(2, close); if (isAggregate && isKwAny(args[0], ['DISTINCT', 'ALL'])) args = args.slice(1); var groups = splitTopLevelCommas(args); var isLagOrLead = first.upper === 'LAG' || first.upper === 'LEAD'; if (groups.length < 1 || groups.length > (isLagOrLead ? 3 : 1) || !groups[0].length) return null; return { name: first.upper, args: groups }; }; function isIntLiteralText(text, isNegative) { if (!/^\d+$/.test(text)) return false; var digits = text.replace(/^0+(?=\d)/, ''); var max = isNegative ? '2147483648' : TYPING_INT_MAX; return digits.length < max.length || (digits.length === max.length && digits <= max); }; function lagDefaultKeepsType(tokens, kind, isSameType) { var first = tokens[0]; if (!first) return false; if (tokens.length === 1 && isKw(first, 'NULL')) return true; if (tokens.length === 1 && first.type === 'string') return kind === 'Date'; var isSigned = tokens.length === 2 && first.type === 'operator' && (first.value === '-' || first.value === '+'); var number = isSigned ? tokens[1] : (tokens.length === 1 ? first : null); if (number && number.type === 'number') return kind === 'Number' && isIntLiteralText(String(number.value), isSigned && first.value === '-'); return isSameType(); }; function isSameRunType(a, b) { if (!a || !b || a.type !== b.type) return false; if (a.type !== 'Decimal') return true; return typeof a.precision === 'number' && typeof a.scale === 'number' && a.precision === b.precision && a.scale === b.scale; }; function runTypeOfPassThrough(tokens, sel, env) { var call = passThroughCall(tokens); var source = call ? plainColumnSource(call.args[0]) : null; if (!source) return null; var type = runTypeOfReference(sel, source.sourceTable, source.sourceColumn, env); if (call.args.length < 3) return type; var other = runTypeOfPlainOrCall(call.args[2], sel, env); if (!type) return null; return lagDefaultKeepsType(call.args[2], type.type, function () { return isSameRunType(type, other); }) ? type : null; }; function runTypeOfPlainOrCall(tokens, sel, env) { var source = plainColumnSource(tokens); if (source) return runTypeOfReference(sel, source.sourceTable, source.sourceColumn, env); return passThroughCall(tokens) ? runTypeOfPassThrough(tokens, sel, env) : null; }; function runIsDatetimeType(tokens) { return tokens.length === 1 && !tokens[0].quote && (tokens[0].upper === 'DATETIME' || tokens[0].upper === 'SMALLDATETIME'); }; function runIsDatetime(tokens, sel, env, depth) { var first = tokens[0]; if (!first || depth > TYPING_MAX_NESTING) return false; if (tokens.length === 1) return isKw(first, 'CURRENT_TIMESTAMP'); var last = tokens.length - 1; if (first.type === 'punct' && first.value === '(') return closingParenOf(tokens, 0) === last && runIsDatetime(tokens.slice(1, last), sel, env, depth + 1); var source = plainColumnSource(tokens); if (source) { var type = runTypeOfReference(sel, source.sourceTable, source.sourceColumn, env); return !!type && type.type === 'Date'; }; if ((first.type !== 'identifier' && first.type !== 'keyword') || first.quote) return false; var open = tokens[1]; if (!open || open.type !== 'punct' || open.value !== '(' || closingParenOf(tokens, 1) !== last) return false; var name = first.upper; var args = tokens.slice(2, last); if (RUN_DATETIME_FUNCTIONS[name] === true) return true; if (name === 'CAST' || name === 'TRY_CAST') { for (var i = 1; i < args.length; i++) { if (args[i].depth === open.depth + 1 && isKw(args[i], 'AS')) return runIsDatetimeType(args.slice(i + 1)); }; return false; }; var groups = splitTopLevelCommas(args); if (name === 'CONVERT' || name === 'TRY_CONVERT') return groups.length >= 2 && runIsDatetimeType(groups[0]); if (name === 'DATEADD') return groups.length === 3 && ((groups[2].length === 1 && groups[2][0].type === 'string') || runIsDatetime(groups[2], sel, env, depth + 1)); if (name === 'ISNULL') return groups.length === 2 && runIsDatetime(groups[0], sel, env, depth + 1); if (name === 'COALESCE') { var hasDatetime = false; for (var g = 0; g < groups.length; g++) { var group = groups[g]; if (group.length === 1 && (isKw(group[0], 'NULL') || group[0].type === 'string')) continue; if (!runIsDatetime(group, sel, env, depth + 1)) return false; hasDatetime = true; }; return hasDatetime; }; return false; }; function runTypeOfItemColumn(sel, item, k, env) { var from = sel.from || []; if (item.kind === 'star') { var names = item._resolvedColumns; var entry = item.starTable ? findFromEntry(from, item.starTable) : (from.length === 1 ? from[0] : null); var inner = runInnerOfEntry(entry, env); if (inner) return names && runIsReadable(inner.sel) && runOutputWidth(inner.sel) === names.length ? runTypeOfOutput(inner.sel, k, env) : null; var table = typingTableOfEntry(entry, env); var isSameList = !!names && !!table && !hasOpenFieldList(table) && table.fields.length === names.length && names.every(function (n, idx) { return table.fields[idx].name === n; }); return isSameList ? columnTypeOfField(table.fields[k]) : null; }; if ((item.kind === 'column' || item.kind === 'alias') && item.sourceColumn) return runTypeOfReference(sel, item.sourceTable, item.sourceColumn, env); if (item.kind === 'alias') { var tokens = aliasItemExpressionTokens(item); return runTypeOfPassThrough(tokens, sel, env) || (runIsDatetime(tokens, sel, env, 0) ? { type: 'Date' } : null); }; return null; }; function runTypeOfOutput(sel, index, env) { if (!runIsReadable(sel)) return null; var held = null; for (var c = 0; c < env.cache.length; c++) { if (env.cache[c].sel === sel) { held = env.cache[c]; break; }; }; if (!held) { held = { sel: sel, types: {}, places: null }; env.cache.push(held); }; if (Object.prototype.hasOwnProperty.call(held.types, index)) return held.types[index]; if (env.active.indexOf(sel) !== -1 || env.active.length >= TYPING_MAX_NESTING) return null; if (!held.places) { held.places = []; sel.selectList.items.forEach(function (item) { var width = item.kind === 'star' && item._resolvedColumns ? item._resolvedColumns.length : 1; for (var w = 0; w < width; w++) { held.places.push({ item: item, k: w }); }; }); }; env.active.push(sel); var type = null; try { var place = held.places[index]; type = place ? runTypeOfItemColumn(sel, place.item, place.k, env) : null; } catch (e) { type = null; }; env.active.pop(); held.types[index] = type; return type; }; function inferColumnTypesFromParsed(parsed, sql, schema, record) { var extracted = extractColumnsFromParsed(parsed, sql); var allText = extracted.columns.map(function () { return null; }); if (parsed._noSelect || !extracted.columns.length || !runIsReadable(parsed)) return allText; var byName = ensureIndex(schema); var env = { ctx: parsed._ctx || { schemaIndex: byName, ctes: {} }, byName: byName, active: [], cache: [], record: record || null }; var types = []; try { parsed.selectList.items.forEach(function (item) { var width = item.kind === 'star' && item._resolvedColumns ? item._resolvedColumns.length : 1; for (var k = 0; k < width; k++) types.push(runTypeOfItemColumn(parsed, item, k, env)); }); } catch (e) { return allText; }; return types.length === extracted.columns.length ? types : allText; }; function inferColumnTypes(sql, schema) { sql = sql == null ? '' : String(sql); var parsed; try { parsed = parse(sql, schema); } catch (e) { return []; }; return inferColumnTypesFromParsed(parsed, sql, schema); }; function inferColumnTypeTables(sql, schema) { sql = sql == null ? '' : String(sql); var parsed; try { parsed = parse(sql, schema); } catch (e) { return []; }; var record = []; inferColumnTypesFromParsed(parsed, sql, schema, record); var byName = ensureIndex(schema); var seen = {}; var found = []; record.forEach(function (entry) { var name = String(entry.name).replace(/^\[|\]$/g, ''); var isTopLevel = (parsed.from || []).indexOf(entry) !== -1; var key = name.toLowerCase() + (isTopLevel ? ':top' : ':inner'); if (seen[key]) return; seen[key] = true; found.push({ name: name, table: fieldCheckTable(byName, entry), isTopLevel: isTopLevel }); }); return found; }; var SAVE_NUMBER_MAX = 2147483647; var SAVE_INT_PRECISION = 10; var SAVE_BIGINT_PRECISION = 19; var SAVE_DECIMAL_FLOOR = 18; var SAVE_DECIMAL_CEILING = 38; var SAVE_MAX_SELECTS = 20000; var SAVE_NULL_TYPE = { kind: 'Null' }; function saveNameSet(names) { var set = {}; names.forEach(function (name) { set[name] = true; }); return set; }; var SAVE_DATE_FUNCTIONS = saveNameSet(['GETDATE', 'GETUTCDATE', 'EOMONTH', 'DATEFROMPARTS', 'DATETIMEFROMPARTS', 'SMALLDATETIMEFROMPARTS']); var SAVE_DATETIME_MAX_FRACTION = 3; var SAVE_NUMBER_FUNCTIONS = saveNameSet(['COUNT', 'DATEDIFF', 'DATEPART', 'YEAR', 'MONTH', 'DAY', 'LEN', 'CHARINDEX', 'ISNUMERIC', 'ROW_NUMBER', 'RANK', 'DENSE_RANK', 'NTILE']); function saveKind(kind) { return { kind: kind }; }; function saveDecimal(precision, scale, isComputed) { var decimal = { kind: 'Decimal' }; if (typeof precision === 'number') decimal.precision = precision; if (typeof scale === 'number') decimal.scale = scale; if (isComputed) decimal.isComputed = true; return decimal; }; function saveResult(type, isField) { return { type: type, isField: !!isField }; }; function saveIsNumeric(type) { return !!type && (type.kind === 'Number' || type.kind === 'Decimal'); }; function savePrecisionOf(type) { if (!type) return null; if (type.kind === 'Number') { return { precision: SAVE_INT_PRECISION, scale: 0 }; }; if (type.kind === 'Decimal' && typeof type.precision === 'number' && typeof type.scale === 'number') { return { precision: type.precision, scale: type.scale }; }; return null; }; function saveFinalType(type) { if (!type || type.kind !== 'Decimal' || !type.isComputed) return type; if (typeof type.precision !== 'number') return saveDecimal(undefined, type.scale, false); return saveDecimal(Math.min(SAVE_DECIMAL_CEILING, Math.max(SAVE_DECIMAL_FLOOR, type.precision)), type.scale, false); }; function savePublicType(type) { var settled = saveFinalType(type); if (!settled || settled.kind === 'Null') return null; var result = { type: settled.kind }; if (settled.kind === 'Decimal') { if (typeof settled.precision === 'number') result.precision = settled.precision; if (typeof settled.scale === 'number') result.scale = settled.scale; }; return result; }; function saveInternalType(publicType) { if (!publicType) return null; if (publicType.type === 'Decimal') return saveDecimal(publicType.precision, publicType.scale, false); return saveKind(publicType.type); }; function saveWiden(types) { if (types.every(function (t) { return t.kind === 'Number'; })) return saveKind('Number'); var integerDigits = 0; var scale = 0; var isKnown = true; var isComputed = false; types.forEach(function (t) { var shape = savePrecisionOf(t); if (t.isComputed) isComputed = true; if (!shape) { isKnown = false; return; }; integerDigits = Math.max(integerDigits, shape.precision - shape.scale); scale = Math.max(scale, shape.scale); }); if (!isKnown) return saveDecimal(undefined, undefined, isComputed); var precision = Math.max(1, Math.min(SAVE_DECIMAL_CEILING, integerDigits + scale)); return saveDecimal(precision, Math.min(scale, precision), isComputed); }; function saveCombine(types) { var typed = []; for (var i = 0; i < types.length; i++) { if (!types[i]) return null; if (types[i].kind !== 'Null') typed.push(types[i]); }; if (!typed.length) return SAVE_NULL_TYPE; if (typed.every(saveIsNumeric)) return saveWiden(typed); var kind = typed[0].kind; return typed.every(function (t) { return t.kind === kind; }) ? typed[0] : null; }; function saveArithmetic(operator, left, right) { if (!saveIsNumeric(left) || !saveIsNumeric(right)) return null; if (left.kind === 'Number' && right.kind === 'Number') return saveKind('Number'); if (operator === '%') return null; var a = savePrecisionOf(left); var b = savePrecisionOf(right); if (!a || !b) return saveDecimal(undefined, undefined, true); var precision; var scale; if (operator === '+' || operator === '-') { scale = Math.max(a.scale, b.scale); precision = scale + Math.max(a.precision - a.scale, b.precision - b.scale) + 1; } else if (operator === '*') { scale = a.scale + b.scale; precision = a.precision + b.precision + 1; } else { scale = Math.max(6, a.scale + b.precision + 1); precision = a.precision - a.scale + b.scale + scale; }; if (precision > SAVE_DECIMAL_CEILING) { scale = Math.max(scale - (precision - SAVE_DECIMAL_CEILING), Math.min(scale, 6)); precision = SAVE_DECIMAL_CEILING; }; return saveDecimal(precision, scale, true); }; function saveTypeOfNumberToken(token) { var match = /^(\d*)(?:\.(\d+))?$/.exec(String(token.value)); if (!match) return null; var whole = match[1].replace(/^0+/, ''); if (match[2] === undefined) { if (whole.length < 10 || (whole.length === 10 && whole <= String(SAVE_NUMBER_MAX))) return saveKind('Number'); return whole.length > SAVE_DECIMAL_CEILING ? null : saveDecimal(whole.length, 0, true); }; var digits = whole.length + match[2].length; return digits > SAVE_DECIMAL_CEILING ? null : saveDecimal(Math.max(1, digits), match[2].length, true); }; function saveTypeOfSqlType(list) { if (!list.length || (list[0].type !== 'identifier' && list[0].type !== 'keyword')) return null; var name = list[0].upper; if (name === 'INT' || name === 'INTEGER' || name === 'SMALLINT' || name === 'TINYINT') return saveKind('Number'); if (name === 'BIGINT') return saveDecimal(SAVE_BIGINT_PRECISION, 0, false); if (name === 'MONEY') return saveDecimal(19, 4, false); if (name === 'SMALLMONEY') return saveDecimal(10, 4, false); if (name === 'DATE' || name === 'DATETIME' || name === 'SMALLDATETIME') return list.length === 1 ? saveKind('Date') : null; if (name === 'BIT') return saveKind('Boolean'); if (name !== 'DECIMAL' && name !== 'NUMERIC' && name !== 'DEC' && name !== 'DATETIME2') return null; if (list.length === 1) return name === 'DATETIME2' ? null : saveDecimal(18, 0, false); var last = list[list.length - 1]; if (!(list[1].type === 'punct' && list[1].value === '(' && last.type === 'punct' && last.value === ')')) return null; var args = splitTopLevelCommas(list.slice(2, list.length - 1)); var isWhole = function (group) { return group.length === 1 && group[0].type === 'number' && /^\d+$/.test(group[0].value); }; if (name === 'DATETIME2') return args.length === 1 && isWhole(args[0]) && parseInt(args[0][0].value, 10) <= SAVE_DATETIME_MAX_FRACTION ? saveKind('Date') : null; if (args.length > 2 || !args.every(isWhole)) return null; var precision = parseInt(args[0][0].value, 10); var scale = args.length === 2 ? parseInt(args[1][0].value, 10) : 0; if (precision < 1 || precision > SAVE_DECIMAL_CEILING || scale > precision) return null; return saveDecimal(precision, scale, false); }; function saveShapeOfEntry(entry, env) { var shape = { isDerived: false, names: null, outputs: null, table: null }; if (!entry || isMisreadEntry(entry)) return shape; var inner = null; if (entry.kind === 'derived') { inner = entry.inner; shape.names = entry.columns || null; } else { var cte = cteOfEntry(entry, env); if (cte) { inner = cte.inner; shape.names = cte.columns || null; }; }; if (inner) { shape.isDerived = true; shape.outputs = saveOutputOfSelect(inner, env); return shape; }; shape.table = typingTableOfEntry(entry, env); return shape; }; function saveColumnOfShape(shape, name) { var wanted = String(name).toLowerCase(); if (shape.isDerived) { if (!shape.outputs || !shape.names || shape.names.length !== shape.outputs.length) return saveResult(null, true); var found = -1; var count = 0; shape.names.forEach(function (n, idx) { if (n !== null && n !== undefined && String(n).toLowerCase() === wanted) { found = idx; count++; }; }); return count === 1 ? shape.outputs[found] : saveResult(null, true); }; if (shape.table) return saveResult(saveInternalType(columnTypeOfField(findFieldNamed(shape.table, name))), true); return saveResult(null, true); }; function saveColumnReference(sel, qualifier, name, env) { var from = sel.from || []; if (qualifier) return saveColumnOfShape(saveShapeOfEntry(findFromEntry(from, qualifier), env), name); var wanted = String(name).toLowerCase(); var owner = null; var matches = 0; for (var i = 0; i < from.length; i++) { var shape = saveShapeOfEntry(from[i], env); var has; if (shape.isDerived) { if (!shape.names) return saveResult(null, true); has = shape.names.some(function (n) { return n !== null && n !== undefined && String(n).toLowerCase() === wanted; }); } else if (shape.table && !hasOpenFieldList(shape.table)) { has = !!findFieldNamed(shape.table, name); } else { return saveResult(null, true); }; if (has) { matches++; owner = shape; }; }; return matches === 1 ? saveColumnOfShape(owner, name) : saveResult(null, true); }; function saveColumnsOfShape(shape, count) { if (shape.isDerived) return shape.outputs && shape.outputs.length === count ? shape.outputs : null; if (shape.table && !hasOpenFieldList(shape.table) && shape.table.fields.length === count) { return shape.table.fields.map(function (field) { return saveResult(saveInternalType(columnTypeOfField(field)), true); }); }; return null; }; function saveExpression(tokens, sel, env) { if (!tokens.length) return saveResult(null, false); var list = sel && sel.selectList; var last = tokens[tokens.length - 1]; var key = env.expressions && list && typeof list.start === 'number' && typeof tokens[0].start === 'number' && typeof last.end === 'number' ? list.start + '-' + list.end + ':' + tokens[0].start + '-' + last.end + ':' + tokens.length : null; if (key !== null && Object.prototype.hasOwnProperty.call(env.expressions, key)) return env.expressions[key]; var read = saveReadExpression(tokens, sel, env); if (key !== null) env.expressions[key] = read; return read; }; function saveReadExpression(tokens, sel, env) { var state = { tokens: tokens, pos: 0, isFailed: false, sel: sel, env: env }; var result = saveParseAdditive(state); if (state.isFailed || state.pos < tokens.length) return saveResult(null, true); return result; }; function saveIsOperator(state, operators) { var token = state.tokens[state.pos]; return !!token && token.type === 'operator' && operators.indexOf(token.value) !== -1; }; function saveParseAdditive(state) { var left = saveParseMultiplicative(state); while (!state.isFailed && saveIsOperator(state, ['+', '-'])) { var operator = state.tokens[state.pos].value; state.pos++; var right = saveParseMultiplicative(state); left = saveResult(saveArithmetic(operator, left.type, right.type), false); }; return left; }; function saveParseMultiplicative(state) { var left = saveParseUnary(state); while (!state.isFailed && saveIsOperator(state, ['*', '/', '%'])) { var operator = state.tokens[state.pos].value; state.pos++; var right = saveParseUnary(state); left = saveResult(saveArithmetic(operator, left.type, right.type), false); }; return left; }; function saveParseUnary(state) { if (saveIsOperator(state, ['+', '-', '~'])) { var operator = state.tokens[state.pos].value; state.pos++; var operand = saveParseUnary(state); var isKept = operator === '~' ? (!!operand.type && operand.type.kind === 'Number') : saveIsNumeric(operand.type); return saveResult(isKept ? operand.type : null, false); }; return saveParseCollated(state); }; function saveParseCollated(state) { var operand = saveParsePrimary(state); while (!state.isFailed && isKw(state.tokens[state.pos], 'COLLATE') && isName(state.tokens[state.pos + 1])) state.pos += 2; return operand; }; function saveFail(state) { state.isFailed = true; return saveResult(null, false); }; function closingParenOf(tokens, openIdx) { var close = matchParen(tokens, openIdx); var token = tokens[close]; return token && token.type === 'punct' && token.value === ')' && token.depth === tokens[openIdx].depth ? close : -1; }; function saveParsePrimary(state) { var tokens = state.tokens; var token = tokens[state.pos]; if (!token) return saveFail(state); if (token.type === 'number') { state.pos++; return saveResult(saveTypeOfNumberToken(token), false); }; if (token.type === 'string' || token.type === 'variable') { state.pos++; return saveResult(null, false); }; if (isKw(token, 'NULL')) { state.pos++; return saveResult(SAVE_NULL_TYPE, false); }; if (isKw(token, 'CURRENT_TIMESTAMP')) { state.pos++; return saveResult(saveKind('Date'), false); }; if (isKw(token, 'CASE')) return saveParseCase(state); if (token.type === 'punct' && token.value === '(') { var close = closingParenOf(tokens, state.pos); if (close === -1 || close === state.pos + 1) return saveFail(state); var inner = tokens.slice(state.pos + 1, close); state.pos = close + 1; if (isKw(inner[0], 'SELECT')) return saveScalarSubquery(inner, tokens[close], state); if (isKw(inner[0], 'WITH')) return saveResult(null, true); return saveExpression(inner, state.sel, state.env); }; if (token.type !== 'identifier' && token.type !== 'keyword') return saveFail(state); var parts = [token]; var next = state.pos + 1; while (tokens[next] && tokens[next].type === 'punct' && tokens[next].value === '.' && tokens[next + 1] && tokens[next + 1].type === 'identifier') { parts.push(tokens[next + 1]); next += 2; }; if (tokens[next] && tokens[next].type === 'punct' && tokens[next].value === '(') { state.pos = next; return saveParseCall(state, parts.length === 1 ? token.upper : ''); }; if (token.type !== 'identifier') return saveFail(state); state.pos = next; return saveColumnReference(state.sel, parts.length > 1 ? parts[parts.length - 2].value : null, parts[parts.length - 1].value, state.env); }; function saveScalarSubquery(inner, closeToken, state) { var all = state.env.tokens || []; var first = tokenIndexAtOrAfter(all, inner[0].start); if (!all[first] || all[first].start !== inner[0].start) return saveResult(null, true); var sub = parseSelect(all, first, tokenIndexAtOrAfter(all, closeToken.start), state.env.ctx, state.env.sql); var output = sub.noSelect ? null : saveOutputOfSelect(sub, state.env); return output && output.length === 1 ? output[0] : saveResult(null, true); }; function saveParseCase(state) { var tokens = state.tokens; var caseToken = tokens[state.pos]; var spans = []; var level = 0; var spanStart = -1; var endIdx = -1; for (var i = state.pos + 1; i < tokens.length; i++) { var t = tokens[i]; if (isKw(t, 'CASE')) { level++; } else if (isKw(t, 'END')) { if (level === 0) { endIdx = i; break; }; level--; } else if (level === 0 && t.depth === caseToken.depth && isKwAny(t, ['WHEN', 'THEN', 'ELSE'])) { if (spanStart !== -1) spans.push(tokens.slice(spanStart, i)); spanStart = t.upper === 'WHEN' ? -1 : i + 1; }; }; if (endIdx === -1) return saveFail(state); if (spanStart !== -1) spans.push(tokens.slice(spanStart, endIdx)); state.pos = endIdx + 1; if (!spans.length) return saveResult(null, false); var results = spans.map(function (span) { return saveExpression(span, state.sel, state.env); }); return saveResult(saveCombine(results.map(function (r) { return r.type; })), results.some(function (r) { return r.isField; })); }; function saveParseCall(state, name) { var tokens = state.tokens; var open = tokens[state.pos]; var close = closingParenOf(tokens, state.pos); if (close === -1) return saveFail(state); var args = tokens.slice(state.pos + 1, close); state.pos = close + 1; var nulls = tokens[state.pos]; if (nulls && (nulls.upper === 'IGNORE' || nulls.upper === 'RESPECT') && tokens[state.pos + 1] && tokens[state.pos + 1].upper === 'NULLS') state.pos += 2; if (tokens[state.pos] && tokens[state.pos].upper === 'OVER' && tokens[state.pos + 1] && tokens[state.pos + 1].type === 'punct' && tokens[state.pos + 1].value === '(') { var overClose = closingParenOf(tokens, state.pos + 1); if (overClose === -1) return saveFail(state); state.pos = overClose + 1; }; if (name && RUN_PASS_THROUGH_FUNCTIONS[name] === true) { var operand = isKwAny(args[0], ['DISTINCT', 'ALL']) ? args.slice(1) : args; var groups = splitTopLevelCommas(operand); var first = saveExpression(groups[0] || [], state.sel, state.env); var type = first.type && first.type.kind !== 'Null' ? first.type : null; if (type && (name === 'LAG' || name === 'LEAD') && (groups.length > 3 || (groups.length === 3 && !saveLagDefaultKeepsType(groups[2], type, state)))) type = null; return saveResult(type, first.isField); }; if (!name) return saveResult(null, false); return saveResult(saveTypeOfCall(name, args, open, state), saveCallPassesField(name, args, open, state)); }; function saveLagDefaultKeepsType(tokens, type, state) { return lagDefaultKeepsType(tokens, type.kind, function () { if (!plainColumnSource(tokens) && !passThroughCall(tokens)) return false; var other = saveExpression(tokens, state.sel, state.env).type; if (!other || other.kind !== type.kind) return false; if (type.kind !== 'Decimal') return true; return typeof type.precision === 'number' && typeof type.scale === 'number' && type.precision === other.precision && type.scale === other.scale; }); }; function saveCallPassesField(name, args, open, state) { var groups = splitTopLevelCommas(args); var passes = function (group) { return !!group && group.length > 0 && saveExpression(group, state.sel, state.env).isField; }; if (name === 'ISNULL' || name === 'COALESCE') return groups.some(passes); if (name === 'NULLIF' || name === 'LTRIM' || name === 'RTRIM') return passes(groups[0]); if (name === 'IIF') return passes(groups[1]) || passes(groups[2]); if (name === 'CHOOSE') return groups.slice(1).some(passes); if (name === 'CAST' || name === 'TRY_CAST') { for (var a = 1; a < args.length; a++) { if (args[a].depth === open.depth + 1 && isKw(args[a], 'AS')) return saveIsTextType(args.slice(a + 1)) && passes(args.slice(0, a)); }; return false; }; if (name === 'CONVERT' || name === 'TRY_CONVERT') return groups.length >= 2 && saveIsTextType(groups[0]) && passes(groups[1]); if (name === 'TRIM') { for (var i = 0; i < args.length; i++) { if (args[i].depth === open.depth + 1 && isKw(args[i], 'FROM')) return passes(args.slice(i + 1)); }; return passes(groups[0]); }; return false; }; function saveIsTextType(tokens) { var first = tokens && tokens[0]; return !!first && !first.quote && ['CHAR', 'VARCHAR', 'NCHAR', 'NVARCHAR', 'TEXT', 'NTEXT'].indexOf(first.upper) !== -1; }; function saveTypeOfCall(name, args, open, state) { if (name === 'CAST' || name === 'TRY_CAST') { for (var i = 1; i < args.length; i++) { if (args[i].depth === open.depth + 1 && isKw(args[i], 'AS')) return saveTypeOfSqlType(args.slice(i + 1)); }; return null; }; if (name === 'CONVERT' || name === 'TRY_CONVERT') { var groups = splitTopLevelCommas(args); return groups.length >= 2 ? saveTypeOfSqlType(groups[0]) : null; }; if (name === 'DATEADD') { var parts = splitTopLevelCommas(args); if (parts.length !== 3) return null; if (parts[2].length === 1 && parts[2][0].type === 'string') return saveKind('Date'); var date = saveExpression(parts[2], state.sel, state.env).type; return date && date.kind === 'Date' ? saveKind('Date') : null; }; if (SAVE_DATE_FUNCTIONS[name] === true) return saveKind('Date'); if (SAVE_NUMBER_FUNCTIONS[name] === true) return saveKind('Number'); if (name === 'COUNT_BIG') return saveDecimal(SAVE_BIGINT_PRECISION, 0, false); if (name === 'SUM' || name === 'AVG') { var operand = isKwAny(args[0], ['DISTINCT', 'ALL']) ? args.slice(1) : args; var argument = saveExpression(operand, state.sel, state.env).type; if (!saveIsNumeric(argument)) return null; if (argument.kind === 'Number') return argument; if (typeof argument.scale !== 'number') return saveDecimal(); return saveDecimal(SAVE_DECIMAL_CEILING, name === 'AVG' ? Math.max(argument.scale, 6) : argument.scale, false); }; if (name === 'ISNULL' || name === 'COALESCE') { var types = splitTopLevelCommas(args).map(function (group) { return saveExpression(group, state.sel, state.env).type; }); if (types.length < 2 || (name === 'ISNULL' && types.length !== 2)) return null; var common = saveCombine(types); if (!common || common.kind === 'Null') return null; if (name === 'COALESCE') return common; return types.filter(function (t) { return t.kind !== 'Null'; })[0]; }; return null; }; function saveParseBranch(branch, env) { var tokens = env.tokens; var opIdx = tokenIndexAtOrAfter(tokens, branch.start); if (!tokens[opIdx] || tokens[opIdx].start !== branch.start) return null; var first = opIdx + 1; if (branch.op === 'UNION' && isKw(tokens[first], 'ALL')) first++; var parsed = parseSelect(tokens, first, tokenIndexAtOrAfter(tokens, branch.end), env.ctx, env.sql); return parsed.noSelect ? null : parsed; }; function saveOutputOfOne(sel, env) { var from = sel.from || []; var output = []; sel.selectList.items.forEach(function (item) { if (item.kind === 'star') { var names = item._resolvedColumns; if (!names) { output.push(saveResult(null, true)); return; }; var entry = item.starTable ? findFromEntry(from, item.starTable) : (from.length === 1 ? from[0] : null); var columns = saveColumnsOfShape(saveShapeOfEntry(entry, env), names.length); names.forEach(function (n, idx) { output.push(columns ? columns[idx] : saveResult(null, true)); }); } else if ((item.kind === 'column' || item.kind === 'alias') && item.sourceColumn) { output.push(saveColumnReference(sel, item.sourceTable, item.sourceColumn, env)); } else if (item.kind === 'alias' || item.kind === 'unnamed') { output.push(saveExpression(item.kind === 'alias' ? aliasItemExpressionTokens(item) : (item.tokens || []), sel, env)); } else { output.push(saveResult(null, false)); }; }); return output; }; function saveCombineBranches(outputs) { for (var b = 0; b < outputs.length; b++) { if (!outputs[b] || outputs[b].length !== outputs[0].length) return null; }; return outputs[0].map(function (ignored, c) { return saveResult(saveCombine(outputs.map(function (o) { return o[c].type; })), outputs.some(function (o) { return o[c].isField; })); }); }; function saveOutputOfSelect(sel, env) { if (!sel || sel.noSelect || sel._noSelect || !sel.selectList) return null; var done = env.done.indexOf(sel); if (done !== -1) return env.doneOutputs[done]; if (env.active.indexOf(sel) !== -1 || env.active.length >= TYPING_MAX_NESTING || env.done.length >= SAVE_MAX_SELECTS) return null; env.active.push(sel); var output = null; try { var outputs = [sel.clauses && sel.clauses.forClause ? (sel.selectColumnNames || sel._selectColumnNames || []).map(function () { return saveResult(null, true); }) : saveOutputOfOne(sel, env)]; var branches = (sel.clauses && sel.clauses.union) || []; for (var b = 1; b < branches.length; b++) { var branch = saveParseBranch(branches[b], env); outputs.push(branch ? saveOutputOfSelect(branch, env) : null); }; output = branches.length > 1 ? saveCombineBranches(outputs) : outputs[0]; } catch (e) { output = null; }; env.active.pop(); env.done.push(sel); env.doneOutputs.push(output); return output; }; function analyzeSaveColumns(sql, schema) { sql = sql == null ? '' : String(sql); var parsed; try { parsed = parse(sql, schema); } catch (e) { return { types: [], isField: [] }; }; var types = inferColumnTypesFromParsed(parsed, sql, schema); var isField = types.map(function () { return true; }); if (parsed._noSelect || !types.length || !parsed._ctx) { return { types: types, isField: isField }; }; try { var env = { tokens: parsed._tokens, sql: sql, ctx: parsed._ctx, byName: parsed._ctx.schemaIndex, active: [], done: [], doneOutputs: [], expressions: {} }; var output = saveOutputOfSelect(parsed, env); if (output && output.length === types.length) { return { types: types.map(function (given, i) { return given || savePublicType(output[i].type); }), isField: output.map(function (o) { return o.isField; }) }; }; } catch (e) { }; return { types: types, isField: isField }; }; function inferSaveColumnTypes(sql, schema) { return analyzeSaveColumns(sql, schema).types; }; function inferSaveColumnSources(sql, schema) { return analyzeSaveColumns(sql, schema).isField; }; var VALUE_EMAIL_RE = /^[^\s@]+@[^\s@]+\.[^\s@]+$/; var VALUE_ISO_DATE_RE = /^(\d{4})-(\d{2})-(\d{2})(?:[T ](\d{2}):(\d{2})(?::(\d{2})(?:\.(\d{1,3}))?)?)?$/; var VALUE_WHOLE_RE = /^-?(?:0|[1-9]\d{0,9})$/; var VALUE_DECIMAL_RE = /^-?(0|[1-9]\d*)(?:\.(\d+))?$/; var VALUE_DATE_FIRST_YEAR = 1753; function isEmailAddress(value) { return value !== null && value !== undefined && VALUE_EMAIL_RE.test(String(value)); }; function isIsoDateText(text) { var m = VALUE_ISO_DATE_RE.exec(text); if (!m) return false; var year = parseInt(m[1], 10); var month = parseInt(m[2], 10); var day = parseInt(m[3], 10); if (year < VALUE_DATE_FIRST_YEAR || month < 1 || month > 12 || day < 1 || day > daysInMonth(year, month)) return false; if (m[4] !== undefined && (parseInt(m[4], 10) > 23 || parseInt(m[5], 10) > 59)) return false; if (m[6] !== undefined && parseInt(m[6], 10) > 59) return false; return !(year === 9999 && month === 12 && day === 31 && m[4] === '23' && m[5] === '59' && m[6] === '59' && m[7] === '999'); }; function isWholeNumberText(text) { if (!VALUE_WHOLE_RE.test(text) || text === '-0') return false; var n = parseInt(text, 10); return n >= -SAVE_NUMBER_MAX - 1 && n <= SAVE_NUMBER_MAX; }; function inferValueType(values, limits) { var maxPrecision = limits && typeof limits.maxPrecision === 'number' ? limits.maxPrecision : SAVE_DECIMAL_CEILING; var maxScale = limits && typeof limits.maxScale === 'number' ? limits.maxScale : SAVE_DECIMAL_CEILING; var texts = []; (Array.isArray(values) ? values : []).forEach(function (value) { if (value === null || value === undefined || String(value) === '') return; texts.push(String(value)); }); if (!texts.length) return null; if (texts.every(isIsoDateText)) return { type: 'Date' }; if (texts.every(isWholeNumberText)) return { type: 'Number' }; if (texts.every(function (t) { return VALUE_DECIMAL_RE.test(t) && t !== '-0'; }) && texts.some(function (t) { return t.indexOf('.') !== -1; })) { var integerDigits = 0; var scale = 0; texts.forEach(function (t) { var m = VALUE_DECIMAL_RE.exec(t); integerDigits = Math.max(integerDigits, m[1] === '0' ? 0 : m[1].length); scale = Math.max(scale, m[2] === undefined ? 0 : m[2].length); }); var precision = Math.max(SAVE_DECIMAL_FLOOR, integerDigits + scale); if (precision > Math.min(SAVE_DECIMAL_CEILING, maxPrecision) || scale > maxScale) return null; return { type: 'Decimal', precision: precision, scale: scale }; }; if (texts.every(isEmailAddress)) return { type: 'EmailAddress' }; return null; }; var MONTH_NUMBERS = { jan: 1, feb: 2, mar: 3, apr: 4, may: 5, jun: 6, jul: 7, aug: 8, sep: 9, oct: 10, nov: 11, dec: 12 }; var DATE_ISO_RE = /^(\d{4})-(\d{1,2})-(\d{1,2})(?:[T ](\d{1,2}):(\d{2})(?::(\d{2}))?(?:[.,](\d+))?\s*(?:Z|[+-]\d{2}(?::?\d{2})?)?)?$/i; var DATE_SLASH_RE = /^(\d{1,2})\/(\d{1,2})\/(\d{4})(?:,?\s+(\d{1,2}):(\d{2})(?::(\d{2}))?(?:[.,](\d+))?\s*([AP]M)?)?$/i; var DATE_DOT_RE = /^(\d{1,2})\.(\d{1,2})\.(\d{4})(?:,?\s+(\d{1,2}):(\d{2})(?::(\d{2}))?(?:[.,](\d+))?)?$/; var DATE_MONTH_NAME_RE = /^(?:(?:Mon|Tue|Wed|Thu|Fri|Sat|Sun)[a-z]*,?\s+)?([A-Za-z]{3,9})\.?\s+(\d{1,2}),?\s+(\d{4})(?:\s+(\d{1,2}):(\d{2})(?::(\d{2}))?(?:[:.](\d+))?\s*([AP]M)?(?:\s*(?:Z|UTC|GMT)?(?:[+-]\d{2}:?\d{2})?(?:\s*\([^)]*\))?)?)?$/i; function daysInMonth(year, month) { if (month === 2) return ((year % 4 === 0 && year % 100 !== 0) || year % 400 === 0) ? 29 : 28; return (month === 4 || month === 6 || month === 9 || month === 11) ? 30 : 31; }; function padTwo(n) { return (n < 10 ? '0' : '') + n; }; function fractionSuffix(fractionText) { if (!fractionText || /^0+$/.test(fractionText)) return ''; var digits = fractionText; while (digits.length > 3 && digits.charAt(digits.length - 1) === '0') digits = digits.slice(0, -1); while (digits.length < 3) digits += '0'; return '.' + digits; }; function dateText(year, month, day, hourText, minuteText, secondText, meridiem, fractionText) { var hour = hourText === undefined ? 0 : Number(hourText); var minute = minuteText === undefined ? 0 : Number(minuteText); var second = secondText === undefined ? 0 : Number(secondText); if (meridiem) { var isPm = meridiem.toUpperCase() === 'PM'; if (hour < 1 || hour > 12) return null; hour = hour === 12 ? (isPm ? 12 : 0) : (isPm ? hour + 12 : hour); }; if (month < 1 || month > 12 || day < 1 || day > daysInMonth(year, month) || hour > 23 || minute > 59 || second > 59) return null; return year + '-' + padTwo(month) + '-' + padTwo(day) + ' ' + padTwo(hour) + ':' + padTwo(minute) + ':' + padTwo(second) + (secondText === undefined ? '' : fractionSuffix(fractionText)); }; function formatDateValue(value) { if (typeof value !== 'string') return value; var text = value.trim(); if (!text) return value; var m = DATE_ISO_RE.exec(text); var formatted = null; if (m) { formatted = dateText(Number(m[1]), Number(m[2]), Number(m[3]), m[4], m[5], m[6], undefined, m[7]); } else if ((m = DATE_SLASH_RE.exec(text))) { var first = Number(m[1]); var second = Number(m[2]); var hasClock = m[4] !== undefined && m[4] !== ''; var isDayFirst; if (first > 12 && second <= 12) isDayFirst = true; else if (second > 12 && first <= 12) isDayFirst = false; else isDayFirst = hasClock && !m[8]; formatted = dateText(Number(m[3]), isDayFirst ? second : first, isDayFirst ? first : second, m[4], m[5], m[6], m[8], m[7]); } else if ((m = DATE_DOT_RE.exec(text))) { formatted = dateText(Number(m[3]), Number(m[2]), Number(m[1]), m[4], m[5], m[6], undefined, m[7]); } else if ((m = DATE_MONTH_NAME_RE.exec(text))) { var month = MONTH_NUMBERS[m[1].slice(0, 3).toLowerCase()]; if (month) formatted = dateText(Number(m[3]), month, Number(m[2]), m[4], m[5], m[6], m[8], m[7]); }; return formatted === null ? value : formatted; }; function formatBooleanValue(value) { if (value === true || value === 1) return '1'; if (value === false || value === 0) return '0'; if (typeof value !== 'string') return value; var text = value.trim().toLowerCase(); if (text === 'true' || text === '1') return '1'; if (text === 'false' || text === '0') return '0'; return value; }; var DECIMAL_TEXT_RE = /^([+-]?)(\d*)(?:\.(\d*))?$/; function decimalTextOf(value) { if (typeof value === 'string') return value.trim(); if (typeof value !== 'number' || !isFinite(value)) return null; var text = String(value); if (/e/i.test(text)) return Math.abs(value) < 1 ? value.toFixed(20).replace(/0+$/, '') : null; return text; }; function formatDecimalValue(value, scale) { if (typeof scale !== 'number' || scale < 0 || scale !== Math.floor(scale)) return value; var text = decimalTextOf(value); if (text === null) return value; var m = DECIMAL_TEXT_RE.exec(text); if (!m || (!m[2] && !m[3])) return value; var whole = m[2].replace(/^0+(?=\d)/, '') || '0'; var fraction = m[3] || ''; if (fraction.length > scale) { if (/[1-9]/.test(fraction.slice(scale))) return value; fraction = fraction.slice(0, scale); }; while (fraction.length < scale) fraction += '0'; var isZero = /^0*$/.test(whole + fraction); return (m[1] === '-' && !isZero ? '-' : '') + whole + (scale > 0 ? '.' + fraction : ''); }; function formatTypedValue(value, columnType) { if (!columnType || typeof columnType !== 'object') return value; if (columnType.type === 'Date') return formatDateValue(value); if (columnType.type === 'Boolean') return formatBooleanValue(value); if (columnType.type === 'Decimal') return formatDecimalValue(value, columnType.scale); return value; }; function suggestAliases(sql, schema) { sql = sql == null ? '' : String(sql); var parsed; try { parsed = parse(sql, schema); } catch (e) { return { sql: sql, edits: [] }; }; var items = parsed.selectList.items; var edits = []; var unnamedCount = 0; var seenNames = {}; items.forEach(function (item) { if (item.kind === 'unnamed' || item.kind === 'empty') { unnamedCount++; var newName = 'Column' + unnamedCount; edits.push({ start: item.end, end: item.end, text: ' AS ' + formatIdentifier(newName) }); return; }; if (item.kind === 'column' || item.kind === 'alias') { var key = String(item.name).toLowerCase(); if (seenNames[key] === undefined) { seenNames[key] = 1; } else { seenNames[key]++; var renamed = item.name + '_' + seenNames[key]; if (item.kind === 'alias' && item.nameStart !== undefined && item.nameEnd !== undefined) { edits.push({ start: item.nameStart, end: item.nameEnd, text: formatIdentifier(renamed) }); } else { edits.push({ start: item.end, end: item.end, text: ' AS ' + formatIdentifier(renamed) }); }; }; }; }); var newSql = applyEdits(sql, edits); return { sql: newSql, edits: edits }; }; function applyEdits(sql, edits) { var sortedEdits = edits.slice().sort(function (a, b) { return b.start - a.start; }); var out = sql; sortedEdits.forEach(function (e) { out = out.slice(0, e.start) + e.text + out.slice(e.end); }); return out; }; var SIMPLE_IDENT = /^[A-Za-z_][A-Za-z0-9_]*$/; function formatIdentifier(name) { var s = String(name == null ? '' : name); var areBracketsNeeded = !SIMPLE_IDENT.test(s) || RESERVED_SET[s.toUpperCase()]; if (!areBracketsNeeded) return s; return '[' + s.replace(/]/g, ']]') + ']'; }; function buildAliasedColumnRows(names, exprFor, isAligned) { var usedLower = {}; var rows = names.map(function (name) { var aliasName = null; if (/^_/.test(String(name))) { aliasName = uniqueOutputName(String(name).replace(/^_+/, ''), usedLower); } else { usedLower[String(name).toLowerCase()] = true; }; return { expr: exprFor(name), aliasName: aliasName }; }); var aliasedLens = rows.filter(function (r) { return r.aliasName; }).map(function (r) { return r.expr.length; }); var aliasCol = (isAligned !== false && aliasedLens.length >= 2) ? nextTabStop(6 + Math.max.apply(null, aliasedLens)) : 0; return rows.map(function (r) { var text = r.expr; if (r.aliasName) { text += (aliasCol ? fmtSpaces(aliasCol - 6 - r.expr.length) : ' ') + 'AS ' + r.aliasName; }; return text; }); }; function buildFullSelect(tableName, fields, alias) { var list = fields || []; var names = list.map(function (f) { return (f && typeof f === 'object') ? f.name : f; }); var rowTexts = buildAliasedColumnRows(names, function (name) { return alias + '.' + formatIdentifier(name); }); var lines = ['SELECT']; rowTexts.forEach(function (text, i) { lines.push((i === 0 ? ' ' : ' , ') + text); }); lines.push('FROM ' + tableName + ' AS ' + alias); return lines.join('\n'); }; var CLAUSE_COLUMN_CONTEXT = { SELECT: 1, WHERE: 1, ON: 1, GROUP: 1, HAVING: 1, ORDER: 1, BY: 1, AND: 1, OR: 1 }; var TABLE_CONTEXT_KEYWORDS = { FROM: 1, JOIN: 1, APPLY: 1 }; function nameChainEndingAt(tokens, idx) { var j = idx - 1; if (j < 0 || !isName(tokens[j])) return null; var column = tokens[j].value; var alias = null; if (j - 2 >= 0 && tokens[j - 1].type === 'punct' && tokens[j - 1].value === '.' && isName(tokens[j - 2])) { alias = tokens[j - 2].value; }; return { alias: alias, column: column }; }; function findEnclosingOpenParen(tokens, idx) { var depth = 0; for (var j = idx - 1; j >= 0; j--) { var t = tokens[j]; if (t.type !== 'punct') continue; if (t.value === ')') depth++; else if (t.value === '(') { if (depth === 0) return j; depth--; }; }; return -1; }; function findScopeEntry(qualifier, entries) { if (!qualifier) return null; var upper = String(qualifier).toUpperCase(); return entries.filter(function (t) { return t.alias && t.alias.toUpperCase() === upper; })[0] || entries.filter(function (t) { return t.name && t.name.toUpperCase() === upper; })[0] || null; }; function nestedScopeTables(tokens, sql, schemaIndex) { var entries = []; var ctx = { schemaIndex: schemaIndex, ctes: {} }; for (var i = 0; i < tokens.length; i++) { if (!(tokens[i].type === 'punct' && tokens[i].value === '(' && isKw(tokens[i + 1], 'SELECT'))) continue; var close = matchParen(tokens, i); var end = (tokens[close] && tokens[close].type === 'punct' && tokens[close].value === ')' && close > i) ? close : tokens.length; try { var shape = parseSelect(tokens, i + 1, end, ctx, sql); var froms = [shape.from]; (shape.clauses.union || []).forEach(function (branch, b) { if (b === 0 || !branch.op || branch.isParenthesised) return; var first = tokenIndexAtOrAfter(tokens, branch.start) + (branch.all ? 2 : 1); var last = Math.min(end, tokenIndexAtOrAfter(tokens, branch.end)); if (first < last && isKw(tokens[first], 'SELECT')) froms.push(parseSelect(tokens, first, last, ctx, sql).from); }); froms.forEach(function (from) { from.forEach(function (f) { entries.push({ name: f.name, alias: f.alias, table: f.name ? lookupTable(schemaIndex, f.name) : null }); }); }); } catch (e) { /* ignore */ } }; return entries; }; function resolveValueField(info, tablesInScope) { if (!info || !info.column) return null; var table = null; if (info.alias) { var match = findScopeEntry(info.alias, tablesInScope); table = match ? match.table : null; } else if (tablesInScope.length === 1) { table = tablesInScope[0].table; }; if (!table || !Array.isArray(table.fields)) return null; var field = table.fields.filter(function (f) { return f.name && f.name.toLowerCase() === info.column.toLowerCase(); })[0]; return field || null; }; var OPERAND_END_KEYWORDS = ['NULL', 'END', 'CURRENT_TIMESTAMP', 'CURRENT_DATE', 'CURRENT_TIME', 'CURRENT_USER', 'SESSION_USER', 'SYSTEM_USER', 'USER']; function endsOperand(t) { if (!t) return false; if (t.type === 'identifier' || t.type === 'number' || t.type === 'string' || t.type === 'variable') return true; if (t.type === 'punct') return t.value === ')'; return t.type === 'keyword' && OPERAND_END_KEYWORDS.indexOf(t.upper) !== -1; }; function isAfterJoinCondition(tokens, idx) { var last = tokens[idx]; if (!endsOperand(last)) return false; var closedCases = (last.type === 'keyword' && last.upper === 'END') ? 1 : 0; for (var k = idx - 1; k >= 0; k--) { var t = tokens[k]; if (t.depth > last.depth) continue; if (t.depth < last.depth) return false; if (t.type === 'keyword' && t.upper === 'END') closedCases++; if (t.type === 'keyword' && t.upper === 'CASE') { if (closedCases === 0) return false; closedCases--; }; if (t.type !== 'keyword' || t.upper === 'AND' || t.upper === 'OR') continue; if (t.upper === 'ON') return true; if (CLAUSE_COLUMN_CONTEXT[t.upper] || TABLE_CONTEXT_KEYWORDS[t.upper]) return false; }; return false; }; function closedParenAt(tokens, openIdx, lastIdx) { var close = matchParen(tokens, openIdx); return close <= lastIdx && tokens[close].type === 'punct' && tokens[close].value === ')' ? close : -1; }; function joinTableBefore(tokens, joinIdx, lastIdx) { if (lastIdx <= joinIdx) return null; var tableEnd; var first = tokens[joinIdx + 1]; if (first.type === 'punct' && first.value === '(') { var close = closedParenAt(tokens, joinIdx + 1, lastIdx); if (close === -1) return null; tableEnd = close + 1; } else { var chain = parseNameChain(tokens, joinIdx + 1, lastIdx + 1); if (!chain.parts.length) return null; if (tokens[chain.end - 1] && tokens[chain.end - 1].type === 'punct') return null; tableEnd = chain.end; if (tableEnd <= lastIdx && tokens[tableEnd].type === 'punct' && tokens[tableEnd].value === '(') { var argsClose = closedParenAt(tokens, tableEnd, lastIdx); if (argsClose === -1) return null; tableEnd = argsClose + 1; }; }; var i = tableEnd; var hasAlias = false; if (i <= lastIdx && isKw(tokens[i], 'AS')) { if (i === lastIdx) { return { hasAlias: false, hasHint: false, isAliasNext: true }; }; if (!isName(tokens[i + 1])) return null; hasAlias = true; i += 2; } else if (i <= lastIdx && isName(tokens[i])) { hasAlias = true; i += 1; }; var hasHint = false; if (i < lastIdx && isKw(tokens[i], 'WITH') && tokens[i + 1].type === 'punct' && tokens[i + 1].value === '(') { var hintClose = closedParenAt(tokens, i + 1, lastIdx); if (hintClose === -1) return null; hasHint = true; i = hintClose + 1; }; if (i !== lastIdx + 1) return null; return { hasAlias: hasAlias, hasHint: hasHint, isAliasNext: false }; }; function cursorBranchFrom(parsed, sql, offset) { var tokens = parsed._tokens; var shape = parsed; for (var level = 0; level < PAREN_QUERY_MAX_DEPTH; level++) { var branches = (shape.clauses && shape.clauses.union) || []; var at = -1; for (var b = branches.length - 1; b >= 1; b--) { var branch = branches[b]; if (!branch.op || offset < branch.start) continue; if (branch.isParenthesised && offset > branch.end) break; at = b; break; }; if (at === -1) return shape.from; var opIdx = tokenIndexAtOrAfter(tokens, branches[at].start); var first = opIdx + (branches[at].all ? 2 : 1); var last = tokenIndexAtOrAfter(tokens, branches[at].end); if (first >= last) return []; var operand = parseSelect(tokens, first, last, parsed._ctx, sql); if (operand.noSelect) return []; if (!branches[at].isParenthesised) { var orderBy = operand.clauses && operand.clauses.orderBy; return orderBy && offset >= orderBy.start && at === branches.length - 1 ? parsed.from : operand.from; }; shape = operand; }; return shape.from; }; function completionContext(sql, offset, schema) { sql = sql == null ? '' : String(sql); offset = Math.max(0, Math.min(offset | 0, sql.length)); var scope = cursorScope(sql, offset); if (!scope) return completionContextOf(sql, offset, schema); var found = completionContextOf(sql.slice(scope.start, scope.end), offset - scope.start, schema); found.replaceStart += scope.start; found.replaceEnd += scope.start; return found; }; function completionContextOf(sql, offset, schema) { sql = sql == null ? '' : String(sql); offset = Math.max(0, Math.min(offset | 0, sql.length)); var tokens; try { tokens = withoutComments(tokenize(sql)); } catch (e) { tokens = []; }; var pStart = offset; while (pStart > 0 && COMPLETION_WORD_CHAR.test(sql.charAt(pStart - 1))) pStart--; var prefix = sql.slice(pStart, offset); var replaceStart = pStart; var replaceEnd = offset; var lastBefore = null; var lastBeforeIdx = -1; for (var i = 0; i < tokens.length; i++) { if (tokens[i].start >= pStart) break; lastBefore = tokens[i]; lastBeforeIdx = i; }; var tablesInScope = []; var schemaIndex = ensureIndex(schema); try { var parsed = parse(sql, schema); tablesInScope = cursorBranchFrom(parsed, sql, offset).map(function (f) { return { name: f.name, alias: f.alias, table: f.name ? lookupTable(schemaIndex, f.name) : null }; }); } catch (e) { /* ignore */ } var nestedTables = null; function scopeEntryFor(qualifier) { var topLevel = findScopeEntry(qualifier, tablesInScope); if (topLevel || !qualifier) return topLevel; if (!nestedTables) nestedTables = nestedScopeTables(tokens, sql, schemaIndex); return findScopeEntry(qualifier, nestedTables); }; if (lastBefore) { var valueColumn = null; if (lastBefore.type === 'operator' && (lastBefore.value === '=' || lastBefore.value === '<>' || lastBefore.value === '!=')) { valueColumn = nameChainEndingAt(tokens, lastBeforeIdx); } else if (lastBefore.type === 'punct' && lastBefore.value === '(' && isKw(tokens[lastBeforeIdx - 1], 'IN')) { valueColumn = nameChainEndingAt(tokens, lastBeforeIdx - (isKw(tokens[lastBeforeIdx - 2], 'NOT') ? 2 : 1)); } else if (lastBefore.type === 'punct' && lastBefore.value === ',') { var openIdx = findEnclosingOpenParen(tokens, lastBeforeIdx); if (openIdx > 0 && isKw(tokens[openIdx - 1], 'IN')) valueColumn = nameChainEndingAt(tokens, openIdx - (isKw(tokens[openIdx - 2], 'NOT') ? 2 : 1)); }; if (valueColumn) { var valueScope = valueColumn.alias ? scopeEntryFor(valueColumn.alias) : null; var valueField = resolveValueField(valueColumn, valueScope ? [valueScope] : tablesInScope); if (valueField && valueField.values) { return { kind: 'value', alias: valueColumn.alias, column: valueColumn.column, field: valueField, prefix: prefix, replaceStart: replaceStart, replaceEnd: replaceEnd, tablesInScope: tablesInScope }; }; }; }; if (lastBefore && lastBefore.type === 'punct' && lastBefore.value === '.') { var aliasTok = null; for (var j = tokens.length - 1; j >= 0; j--) { if (tokens[j].end <= lastBefore.start) { aliasTok = tokens[j]; break; }; }; var aliasName = aliasTok && isName(aliasTok) ? aliasTok.value : null; var match = scopeEntryFor(aliasName); return { kind: 'column', alias: aliasName, table: match ? match.table : null, prefix: prefix, replaceStart: replaceStart, replaceEnd: replaceEnd, tablesInScope: tablesInScope }; }; if (lastBefore && lastBefore.type === 'keyword' && TABLE_CONTEXT_KEYWORDS[lastBefore.upper]) { var tableContext = { kind: 'table', prefix: prefix, replaceStart: replaceStart, replaceEnd: replaceEnd, tablesInScope: tablesInScope }; if (joinPosition(tokens, lastBeforeIdx) === 'join') tableContext.afterJoin = true; return tableContext; }; if (lastBefore && lastBefore.type === 'keyword' && JOIN_QUALIFIER_WORDS.indexOf(lastBefore.upper) !== -1) { return { kind: 'keyword', afterJoinModifier: true, prefix: prefix, replaceStart: replaceStart, replaceEnd: replaceEnd, tablesInScope: tablesInScope }; }; var nearestClause = null; var nearestClauseIdx = -1; var level = lastBefore ? (lastBefore.type === 'punct' && lastBefore.value === '(' ? lastBefore.depth + 1 : lastBefore.depth) : 0; for (var k = lastBeforeIdx; k >= 0; k--) { var t = tokens[k]; if (t.depth > level) continue; if (t.type === 'punct' && t.value === '(' && t.depth < level) { level = t.depth; continue; }; if (t.type === 'keyword' && t.depth === level && (TABLE_CONTEXT_KEYWORDS[t.upper] || CLAUSE_COLUMN_CONTEXT[t.upper])) { nearestClause = t; nearestClauseIdx = k; break; }; }; if (nearestClause && TABLE_CONTEXT_KEYWORDS[nearestClause.upper]) { var tableBefore = joinTableBefore(tokens, nearestClauseIdx, lastBeforeIdx); if (tableBefore) { var afterTableContext = { kind: 'keyword', afterTable: true, hasAlias: tableBefore.hasAlias, hasHint: tableBefore.hasHint, isAliasNext: tableBefore.isAliasNext, prefix: prefix, replaceStart: replaceStart, replaceEnd: replaceEnd, tablesInScope: tablesInScope }; if (joinPosition(tokens, nearestClauseIdx) === 'join') afterTableContext.afterJoinTable = true; return afterTableContext; }; }; if (nearestClause && (CLAUSE_COLUMN_CONTEXT[nearestClause.upper])) { if (isAfterJoinCondition(tokens, lastBeforeIdx)) { return { kind: 'keyword', afterJoinCondition: true, prefix: prefix, replaceStart: replaceStart, replaceEnd: replaceEnd, tablesInScope: tablesInScope }; }; var columnContext = { kind: 'column', prefix: prefix, replaceStart: replaceStart, replaceEnd: replaceEnd, tablesInScope: tablesInScope }; if (joinPosition(tokens, lastBeforeIdx) === 'on') columnContext.afterOn = true; if (onOperandBefore(tokens, lastBeforeIdx)) columnContext.afterOnEquals = true; return columnContext; }; return { kind: 'keyword', prefix: prefix, replaceStart: replaceStart, replaceEnd: replaceEnd, tablesInScope: tablesInScope }; }; var JOIN_LINE_PREFIX = /^[ \t]*(?:(?:INNER|LEFT|RIGHT|FULL|OUTER)[ \t]+)*(?:(?:LOOP|HASH|MERGE|REMOTE)[ \t]+)?JOIN[ \t]+$/i; var JOIN_LINE_START = /^[ \t]*(?:(?:INNER|LEFT|RIGHT|FULL|CROSS|OUTER)[ \t]+)*(?:(?:LOOP|HASH|MERGE|REMOTE)[ \t]+)?JOIN\b/i; var FROM_ITEM_PREFIX = /^[ \t]*(?:FROM[ \t]+|,[ \t]*|(?:(?:INNER|LEFT|RIGHT|FULL|CROSS|OUTER)[ \t]+)*(?:(?:LOOP|HASH|MERGE|REMOTE)[ \t]+)?(?:JOIN|APPLY)[ \t]+)$/i; var ON_LINE_START = /^[ \t]*ON\b/i; var JOIN_QUALIFIER_WORDS = ['INNER', 'LEFT', 'RIGHT', 'FULL', 'OUTER', 'CROSS']; function joinPosition(tokens, idx) { var t = tokens[idx]; if (!t || t.type !== 'keyword') return null; if (t.upper === 'ON') return 'on'; if (t.upper !== 'JOIN') return null; var before = tokens[idx - 1]; if (before && before.type === 'keyword' && before.upper === 'CROSS') return null; return 'join'; }; function onOperandBefore(tokens, idx) { var op = tokens[idx]; if (!op || op.type !== 'operator' || op.value !== '=') return null; var column = tokens[idx - 1]; var dot = tokens[idx - 2]; var alias = tokens[idx - 3]; var lead = tokens[idx - 4]; if (!isName(column) || !dot || dot.type !== 'punct' || dot.value !== '.' || !isName(alias) || !(isKw(lead, 'ON') || isKw(lead, 'AND'))) return null; var closedCases = 0; for (var k = idx - 4; k >= 0; k--) { var t = tokens[k]; if (t.depth > op.depth) continue; if (t.depth < op.depth) return null; if (t.type !== 'keyword') continue; if (t.upper === 'ON') return { onIdx: k, alias: alias.value, column: column.value, aliasStart: alias.start, columnEnd: column.end }; if (t.upper === 'AND') continue; if (t.upper === 'OR') return null; if (t.upper === 'END') { closedCases++; continue; }; if (t.upper === 'CASE') { if (closedCases === 0) return null; closedCases--; continue; }; if (CLAUSE_COLUMN_CONTEXT[t.upper] || TABLE_CONTEXT_KEYWORDS[t.upper]) return null; }; return null; }; function otherSideItems(items, operand, sql, start, end) { var left = operand.alias.toLowerCase(); var column = operand.column.toLowerCase(); var typed = sql.slice(operand.aliasStart, operand.columnEnd); var seen = {}; var sides = []; items.forEach(function (item) { var isJoined = item.alias.toLowerCase() === left; if (!isJoined && item.anchorAlias.toLowerCase() !== left) return; item.pairs.forEach(function (pair) { if ((isJoined ? pair.joined : pair.anchor).toLowerCase() !== column) return; var text = formatIdentifier(isJoined ? item.anchorAlias : item.alias) + '.' + formatIdentifier(isJoined ? pair.anchor : pair.joined); if (seen[text]) return; seen[text] = true; sides.push({ mode: 'side', label: text, filterText: text, detail: item.label, note: item.note, view: item.view, table: item.table, alias: item.alias, anchorAlias: item.anchorAlias, anchorView: item.anchorView, left: typed, conditions: [typed + ' = ' + text], insertText: text, start: start, end: end, additionalEdits: [] }); }); }); return sides; }; function lineStartOf(sql, pos) { return pos <= 0 ? 0 : sql.lastIndexOf('\n', pos - 1) + 1; }; function textColumns(text) { var col = 0; for (var i = 0; i < text.length; i++) col = text.charAt(i) === '\t' ? nextTabStop(col) : col + 1; return col; }; function conditionIndent(sql, pos) { var lineStart = lineStartOf(sql, pos); var lineEnd = sql.indexOf('\n', lineStart); var line = sql.slice(lineStart, lineEnd === -1 ? sql.length : lineEnd); var leading = textColumns(/^[ \t]*/.exec(line)[0]); if (JOIN_LINE_START.test(line)) return leading + 4; if (ON_LINE_START.test(line)) return leading; return leading + 8; }; function joinRunStart(tokens, joinIdx) { var i = joinIdx; while (i > 0 && tokens[i - 1].type === 'keyword' && JOIN_QUALIFIER_WORDS.indexOf(tokens[i - 1].upper) !== -1) i--; return i; }; function joinKind(tokens, joinIdx) { for (var i = joinRunStart(tokens, joinIdx); i < joinIdx; i++) { if (tokens[i].upper === 'LEFT' || tokens[i].upper === 'RIGHT' || tokens[i].upper === 'FULL') return tokens[i].upper; }; return 'INNER'; }; function joinScopeEntries(tokens, sql, idx, schemaIndex) { var selectIdx = scopeSelectIndex(tokens, idx); if (selectIdx < 0) return []; try { return parseSelect(tokens, selectIdx, idx + 1, { schemaIndex: schemaIndex, ctes: {} }, sql).from; } catch (e) { return []; }; }; function scopeSelectIndex(tokens, idx) { var depth = tokens[idx].depth; for (var i = idx; i >= 0; i--) { if (tokens[i].depth < depth) break; if (tokens[i].depth === depth && isKw(tokens[i], 'SELECT')) return i; }; return -1; }; function entryReference(entry) { return entry.alias || stripEnt(entry.name).replace(/^\[|\]$/g, ''); }; function viewOfEntry(entry, viewByLower) { if (!entry || entry.kind !== 'table' || entry.isFunction || !entry.name) return null; return viewByLower[stripEnt(entry.name).replace(/^\[|\]$/g, '').toLowerCase()] || null; }; function relatedJoinOptions(entries, data, viewByLower) { var options = []; entries.forEach(function (anchor, anchorIndex) { var anchorView = viewOfEntry(anchor, viewByLower); if (!anchorView) return; data.dataViewJoins.forEach(function (relation) { var view = relation.a === anchorView ? relation.b : (relation.b === anchorView ? relation.a : null); if (!view) return; options.push({ view: view, anchor: anchor, anchorView: anchorView, anchorIndex: anchorIndex, relation: relation, isJoinedB: relation.b === view, order: options.length }); }); }); return options; }; function isExtraAdded(option, kind) { if (kind === 'INNER') return true; if (kind === 'LEFT') return option.isJoinedB; if (kind === 'RIGHT') return !option.isJoinedB; return false; }; function joinConditions(option, joinedRef, anchorRef, isExtraIn) { var conditions = option.relation.on.map(function (pair) { var joinedField = option.isJoinedB ? pair[1] : pair[0]; var anchorField = option.isJoinedB ? pair[0] : pair[1]; return joinedRef + '.' + formatIdentifier(joinedField) + ' = ' + anchorRef + '.' + formatIdentifier(anchorField); }); if (isExtraIn) { var bRef = option.isJoinedB ? joinedRef : anchorRef; (option.relation.extra || []).forEach(function (condition) { conditions.push(bRef + '.' + condition); }); }; return conditions; }; function joinNote(option, isExtraIn, kind) { var note = option.relation.note || ''; var extra = option.relation.extra || []; if (isExtraIn || !extra.length) return note; var sentences = note.split(/\.(?:\s+|$)/).filter(function (sentence) { return sentence && !extra.some(function (condition) { return sentence.indexOf(condition) !== -1; }); }).map(function (sentence) { return sentence + '.'; }); sentences.push(extraLeftOutNote(extra, option.relation.b, kind)); return sentences.join(' '); }; function extraLeftOutNote(extra, tableName, kind) { var isOne = extra.length === 1; return extra.join(' and ') + (isOne ? ' is' : ' are') + ' left out: under a ' + kind + ' JOIN, ' + tableName + ' keeps every row, so in the ON ' + (isOne ? 'it' : 'they') + ' would filter none of them. A condition in the ON filters only the side whose rows can go missing.'; }; function freeAlias(base, used) { var alias = base; var n = 2; while (used[alias.toLowerCase()]) { alias = base + n; n++; }; return alias; }; function deriveDeAlias(name) { var spaced = String(name || '') .replace(/([a-z0-9])([A-Z])/g, '$1 $2') .replace(/([A-Z]+)([A-Z][a-z])/g, '$1 $2'); var words = spaced.split(/[^A-Za-z0-9]+/).filter(function (w) { return /^[A-Za-z]/.test(w); }); if (!words.length) return 'de'; var firstWord = words[0].toLowerCase().slice(0, 10); if (words.length < 2) return firstWord; var initials = words.map(function (w) { return w.charAt(0).toLowerCase(); }).join(''); return RESERVED_SET[initials.toUpperCase()] ? firstWord : initials; }; function deJoinAlias(name, used) { var base = deriveDeAlias(name); var alias = base; var n = 2; while (used[alias.toLowerCase()] || TOKEN_KEYWORDS[alias.toUpperCase()]) { alias = base + n; n++; }; return alias; }; function usedReferences(sql, tokens, schema, entries) { var used = {}; function mark(f) { var ref = f.alias || (f.name ? stripEnt(f.name).replace(/^\[|\]$/g, '') : ''); if (ref) used[ref.toLowerCase()] = true; }; entries.forEach(mark); try { parse(sql, schema).from.forEach(mark); } catch (e) { /* ignore */ } try { nestedScopeTables(tokens, sql, ensureIndex(schema)).forEach(mark); } catch (e) { /* ignore */ } return used; }; function alignedMember(sql, tokens, idx, column) { var chain = parseNameChain(tokens, idx, tokens.length); var after = tokens[chain.end]; if (after && after.type === 'punct' && after.value === '(') return null; var asTok = isKw(after, 'AS') ? after : null; var aliasTok = asTok ? tokens[chain.end + 1] : after; if (!aliasTok || !isName(aliasTok)) return asTok ? null : false; var lineStart = lineStartOf(sql, tokens[idx].start); var prefix = sql.slice(lineStart, tokens[idx].start); if (!FROM_ITEM_PREFIX.test(prefix) || textColumns(/^[ \t]*/.exec(prefix)[0]) !== column) return null; var nameEnd = tokens[chain.end - 1].end; var gapEnd = asTok ? asTok.start : aliasTok.start; var gap = sql.slice(nameEnd, gapEnd); if (/[\r\n]/.test(gap)) return null; return { headWidth: textColumns(sql.slice(lineStart, nameEnd)), hasAs: !!asTok, isPlainGap: /^[ \t]*$/.test(gap), gapStart: nameEnd, gapEnd: gapEnd, gap: gap }; }; function laterMembers(sql, tokens, from, depth, column) { var members = []; var isExpectingTable = false; for (var i = from; i < tokens.length; i++) { var t = tokens[i]; if (t.depth < depth) break; if (t.depth > depth) continue; if ((t.type === 'punct' && t.value === ';') || isKwAny(t, ['WHERE', 'GROUP', 'ORDER', 'HAVING', 'UNION', 'EXCEPT', 'INTERSECT', 'FOR', 'OPTION', 'SELECT'])) break; if (isKwAny(t, ['JOIN', 'APPLY']) || (t.type === 'punct' && t.value === ',')) { isExpectingTable = true; continue; }; if (!isExpectingTable) continue; isExpectingTable = false; if (t.type === 'punct' && t.value === '(') continue; if (!isName(t)) return null; var member = alignedMember(sql, tokens, i, column); if (member === null) return null; if (member) members.push(member); }; return members; }; function joinLayoutBase(sql, tokens, entries, wordStart, offset, joinIdx) { var linePrefix = sql.slice(lineStartOf(sql, wordStart), wordStart); var base = { linePrefix: linePrefix, onIndent: conditionIndent(sql, tokens[joinRunStart(tokens, joinIdx)].start), members: null, later: null }; if (!JOIN_LINE_PREFIX.test(linePrefix) || !entries.length) return base; var fromTok = tokens[tokenIndexAtOrAfter(tokens, entries[0].start) - 1]; if (!isKw(fromTok, 'FROM')) return base; var fromPrefix = sql.slice(lineStartOf(sql, fromTok.start), fromTok.start); if (!/^[ \t]*$/.test(fromPrefix)) return base; var fromCol = textColumns(fromPrefix); if (textColumns(/^[ \t]*/.exec(linePrefix)[0]) !== fromCol + 4) return base; var members = []; for (var e = 0; e < entries.length; e++) { var f = entries[e]; if (f.kind === 'derived' || f.isInGroup || (f.kind === 'table' && !f.isFunction && !f.alias)) continue; if (f.kind !== 'table' || f.isFunction) return base; var tokenIdx = tokenIndexAtOrAfter(tokens, f.start); if (!tokens[tokenIdx] || tokens[tokenIdx].start !== f.start) return base; var member = alignedMember(sql, tokens, tokenIdx, e === 0 ? fromCol : fromCol + 4); if (!member) return base; members.push(member); }; var later = laterMembers(sql, tokens, tokenIndexAtOrAfter(tokens, offset), tokens[joinIdx].depth, fromCol + 4); if (!later) return base; base.members = members; base.later = later; return base; }; function dataViewTable(view, data, isChildBusinessUnit) { var name = formatIdentifier(view); if (!isChildBusinessUnit) return name; var entry = (data.dataViews || []).filter(function (dv) { return dv.name === view; })[0]; return entry && entry.isEnterpriseLevel ? 'Ent.' + name : name; }; function placeJoinText(base, name, alias, conditions) { var padding = ' '; var edits = []; var aliasCol = 0; function wantedGap(m) { return fmtSpaces(aliasCol - m.headWidth) + (m.hasAs ? '' : 'AS '); }; if (base.members && base.members.length + base.later.length) { var newWidth = textColumns(base.linePrefix + name); var widths = base.members.concat(base.later).map(function (m) { return m.headWidth; }).concat([newWidth]); aliasCol = nextTabStop(Math.max.apply(null, widths)); if (base.later.every(function (m) { return m.isPlainGap && m.gap === wantedGap(m); })) { padding = fmtSpaces(aliasCol - newWidth); base.members.forEach(function (m) { if (!m.isPlainGap || m.gap === wantedGap(m)) return; edits.push({ start: m.gapStart, end: m.gapEnd, text: wantedGap(m) }); }); }; }; var lines = [name + padding + 'AS ' + alias]; conditions.forEach(function (condition, i) { lines.push(fmtSpaces(base.onIndent) + (i === 0 ? 'ON ' : 'AND ') + condition); }); return { text: lines.join('\n'), edits: edits }; }; function placeOnText(andIndent, conditions) { return conditions.map(function (condition, i) { return i === 0 ? condition : '\n' + fmtSpaces(andIndent) + 'AND ' + condition; }).join(''); }; function laterJoinedViews(tokens, from, depth, viewByLower) { var views = {}; var isExpectingTable = false; for (var i = from; i < tokens.length; i++) { var t = tokens[i]; if (t.depth < depth) break; if (t.depth > depth) continue; if ((t.type === 'punct' && t.value === ';') || isKwAny(t, ['WHERE', 'GROUP', 'ORDER', 'HAVING', 'UNION', 'EXCEPT', 'INTERSECT', 'FOR', 'OPTION', 'SELECT'])) break; if (isKwAny(t, ['JOIN', 'APPLY']) || (t.type === 'punct' && t.value === ',')) { isExpectingTable = true; continue; }; if (!isExpectingTable) continue; isExpectingTable = false; if (!isName(t)) continue; var view = viewByLower[stripEnt(nameChainText(parseNameChain(tokens, i, tokens.length))).toLowerCase()]; if (view) views[view] = true; }; return views; }; var DE_JOIN_EXTRA_KEYS = ['SubscriberKey']; var DE_IDENTITY_FIELDS = ['ContactKey', 'EmailAddress']; var DE_JOIN_SUBSCRIBER_KEY = 'SubscriberID'; var DE_SUBSCRIBER_STAND_INS = [ { de: 'SubscriberID', view: null, isExtraKey: false }, { de: 'SubscriberKey', view: 'SubscriberKey', isExtraKey: true }, { de: 'SubID', view: null, isExtraKey: false } ]; var SYNCED_SUBSCRIBER_NAME = /^(contact|lead)_salesforce(?:_\d+)?$/i; var SYNCED_ID_COLUMN = 'Id'; var DE_TEXT_TYPES = ['text', 'email', 'emailaddress', 'phone']; var DE_NUMBER_TYPES = ['number', 'decimal']; function subscriberStandIns() { var extra = DE_JOIN_EXTRA_KEYS.map(function (name) { return name.toLowerCase(); }); return DE_SUBSCRIBER_STAND_INS.filter(function (choice) { return !choice.isExtraKey || extra.indexOf(choice.de.toLowerCase()) !== -1; }); }; function fieldTypeFamily(type) { var lower = String(type == null ? '' : type).toLowerCase(); if (DE_TEXT_TYPES.indexOf(lower) !== -1) return 'text'; if (DE_NUMBER_TYPES.indexOf(lower) !== -1) return 'number'; if (lower === 'date' || lower === 'boolean' || lower === 'locale') return lower; return null; }; function joinKeyColumns(data) { var names = []; var seen = Object.create(null); function add(name) { var lower = String(name).toLowerCase(); if (seen[lower]) return; seen[lower] = true; names.push(String(name)); }; ((data && Array.isArray(data.dataViewJoins)) ? data.dataViewJoins : []).forEach(function (relation) { (relation.on || []).forEach(function (pair) { add(pair[0]); add(pair[1]); }); }); DE_JOIN_EXTRA_KEYS.forEach(add); return names; }; function deJoinKeys(data) { var keys = { names: joinKeyColumns(data), lower: Object.create(null), extra: Object.create(null), alone: Object.create(null), sets: [], sendKey: Object.create(null) }; keys.names.forEach(function (name) { keys.lower[name.toLowerCase()] = true; }); DE_JOIN_EXTRA_KEYS.forEach(function (name) { keys.extra[name.toLowerCase()] = true; keys.alone[name.toLowerCase()] = true; }); ((data && Array.isArray(data.dataViewJoins)) ? data.dataViewJoins : []).forEach(function (relation) { var on = relation.on || []; if (on.length === 1) { keys.alone[String(on[0][0]).toLowerCase()] = true; keys.alone[String(on[0][1]).toLowerCase()] = true; return; }; var isOneName = on.length > 1 && on.every(function (pair) { return String(pair[0]).toLowerCase() === String(pair[1]).toLowerCase(); }); if (isOneName) keys.sets.push(on.map(function (pair) { return String(pair[0]).toLowerCase(); })); }); keys.sets.forEach(function (set) { if (set.indexOf(DE_JOIN_SUBSCRIBER_KEY.toLowerCase()) === -1) return; set.forEach(function (lower) { keys.sendKey[lower] = true; }); }); return keys; }; function fieldMap(fields) { var map = Object.create(null); (fields || []).forEach(function (field) { if (!field || typeof field.name !== 'string') return; var lower = field.name.toLowerCase(); if (map[lower]) return; map[lower] = { name: field.name, family: fieldTypeFamily(field.type) }; }); return map; }; function subscriberFieldKind(name) { var plain = String(name === null || name === undefined ? '' : name).toLowerCase().split(/[^a-z0-9]+/).join(''); if (plain === 'subscriberkey' || plain === 'contactkey') return 'subscriberKey'; if (plain === 'emailaddress') return 'emailAddress'; return null; }; function isSyncedSubscriberTable(table) { if (!isSynchronizedTable(table)) return false; return SYNCED_SUBSCRIBER_NAME.test(String(table.name == null ? '' : table.name).replace(/^ent\.\s*/i, '')); }; function isSynchronizedTable(table) { return !!table && (table.isSynchronized === true || table.sharedKind === 'synchronized'); }; function deFieldMap(table) { var map = fieldMap(table.fields); var names = subscriberColumnNames(); if (names.key === null) return map; var isSendableByKey = table.isSendable === true && !!table.sendableField && subscriberFieldKind(table.sendableSubscriberField) === 'subscriberKey'; var field = isSendableByKey ? map[String(table.sendableField).toLowerCase()] : null; var kind = 'sendable'; if (!field) { if (map[names.key] || names.ids.some(function (lower) { return !!map[lower]; }) || !isSyncedSubscriberTable(table)) return map; field = map[SYNCED_ID_COLUMN.toLowerCase()]; kind = 'syncedId'; }; if (!field) return map; map[names.key] = { name: field.name, family: field.family, kind: kind }; return map; }; function isSameFamily(a, b) { return !!a && !!b && !!a.family && a.family === b.family; }; function subscriberColumnNames() { var ids = []; var key = null; subscriberStandIns().forEach(function (choice) { if (choice.isExtraKey) key = choice.de.toLowerCase(); else ids.push(choice.de.toLowerCase()); }); return { ids: ids, key: key }; }; function hasSubscriberColumn(map) { var names = subscriberColumnNames(); return names.ids.some(function (lower) { return !!map[lower]; }) || (names.key !== null && !!map[names.key]); }; function sharedKeyColumns(joinedMap, anchorMap, keys) { var subscriberId = DE_JOIN_SUBSCRIBER_KEY.toLowerCase(); var names = subscriberColumnNames(); var idPair = null; names.ids.forEach(function (joinedName) { names.ids.forEach(function (anchorName) { if (!idPair && isSameFamily(joinedMap[joinedName], anchorMap[anchorName])) idPair = { joined: joinedMap[joinedName], anchor: anchorMap[anchorName] }; }); }); var isKeyPaired = names.key !== null && isSameFamily(joinedMap[names.key], anchorMap[names.key]); var isBlocked = !idPair && !isKeyPaired && hasSubscriberColumn(joinedMap) && hasSubscriberColumn(anchorMap); var columns = []; var leftOut = []; keys.names.forEach(function (name) { var lower = name.toLowerCase(); if (isBlocked && keys.sendKey[lower]) return; if (lower === subscriberId) { if (!idPair) return; var isAlike = idPair.joined.name.toLowerCase() === idPair.anchor.name.toLowerCase(); columns.push({ key: name, joined: idPair.joined.name, anchor: idPair.anchor.name, joinedKind: null, anchorKind: null, label: !isAlike ? null : (idPair.joined.name.toLowerCase() === lower ? name : idPair.joined.name) }); return; }; if (!isSameFamily(joinedMap[lower], anchorMap[lower])) return; if (keys.extra[lower] && idPair) { leftOut.push(name); return; }; columns.push({ key: name, joined: joinedMap[lower].name, anchor: anchorMap[lower].name, joinedKind: joinedMap[lower].kind || null, anchorKind: anchorMap[lower].kind || null, label: (joinedMap[lower].name.toLowerCase() === lower && anchorMap[lower].name.toLowerCase() === lower) ? name : null }); }); var has = Object.create(null); columns.forEach(function (column) { has[column.key.toLowerCase()] = true; }); var isJoin = columns.some(function (column) { return keys.alone[column.key.toLowerCase()]; }) || keys.sets.some(function (set) { return set.every(function (lower) { return has[lower]; }); }); return { columns: isJoin ? columns : [], leftOut: isJoin ? leftOut : [] }; }; function identityMatch(aMap, bMap) { var found = null; var leftOut = []; DE_IDENTITY_FIELDS.forEach(function (name) { var lower = name.toLowerCase(); if (!isSameFamily(aMap[lower], bMap[lower])) return; if (found) { leftOut.push(name); return; }; found = { key: name, a: aMap[lower].name, b: bMap[lower].name }; }); if (!found) return null; found.leftOut = leftOut; return found; }; function identityNotes(key, leftOut) { var sentences = []; if (leftOut.length) sentences.push(listWords(leftOut) + (leftOut.length === 1 ? ' is' : ' are') + ' left out, since both tables have ' + key + '.'); if (key.toLowerCase() === 'emailaddress') sentences.push('Two contacts can share an email address, so a join on it can repeat rows.'); return sentences; }; function viewRelations(data) { var byView = Object.create(null); ((data && Array.isArray(data.dataViewJoins)) ? data.dataViewJoins : []).forEach(function (relation) { [false, true].forEach(function (isViewB) { var view = isViewB ? relation.b : relation.a; if (!byView[view]) byView[view] = []; byView[view].push({ partner: isViewB ? relation.a : relation.b, isViewB: isViewB, relation: relation, pairs: (relation.on || []).map(function (pair) { return { view: String(isViewB ? pair[1] : pair[0]), partner: String(isViewB ? pair[0] : pair[1]) }; }) }); }); }); return byView; }; function pairSignature(pair) { return pair.view.toLowerCase() + '=' + pair.partner.toLowerCase(); }; function isSendPendingByList(table) { return !!table && table.kind === 'de' && table.isSendable === true && table.hasDetails === false; }; function joinTableContext(data, schemaIndex, viewByLower, schema) { var isDetailsPending = schema && typeof schema.isDetailsPending === 'function' ? schema.isDetailsPending : null; var context = { data: data, schemaIndex: schemaIndex, viewByLower: viewByLower, keys: deJoinKeys(data), relations: viewRelations(data), viewFields: Object.create(null), viewMaps: Object.create(null), isSendPending: function (table) { if (!table || table.kind !== 'de') return false; return isDetailsPending ? !!isDetailsPending(table) : isSendPendingByList(table); } }; (data.dataViews || []).forEach(function (dv) { if (dv && dv.name) context.viewFields[dv.name] = Array.isArray(dv.fields) ? dv.fields : []; }); return context; }; function viewFieldMap(view, context) { if (!context.viewMaps[view]) context.viewMaps[view] = fieldMap(context.viewFields[view]); return context.viewMaps[view]; }; function joinSide(entry, context) { var ref = entryReference(entry); var view = viewOfEntry(entry, context.viewByLower); if (view) return { entry: entry, ref: ref, text: formatIdentifier(ref), name: view, view: view, table: null, fields: context.viewFields[view] || [], map: viewFieldMap(view, context), isDataExtension: false }; var table = fieldCheckTable(context.schemaIndex, entry); if (!table || table.kind === 'dataview') return null; var fields = (Array.isArray(table.fields) && !context.isSendPending(table)) ? table.fields : null; return { entry: entry, ref: ref, text: formatIdentifier(ref), name: entry.name, view: null, table: table, fields: fields, map: fields ? deFieldMap(table) : null, isDataExtension: true }; }; function listWords(names, conjunction) { if (names.length < 2) return names.join(''); return names.slice(0, -1).join(', ') + ' ' + (conjunction || 'and') + ' ' + names[names.length - 1]; }; function subscriberChoices(deMap, isAlone) { var choices = subscriberStandIns(); var key = choices.filter(function (choice) { return choice.isExtraKey; })[0]; var field = key ? deMap[key.de.toLowerCase()] : null; if (!field || field.kind !== 'sendable') return choices; var rest = choices.filter(function (choice) { return choice !== key; }); return isAlone ? [key].concat(rest) : rest.concat([key]); }; function pairColumns(pair, viewMap, deMap, isAlone) { var choices = pair.partner.toLowerCase() === DE_JOIN_SUBSCRIBER_KEY.toLowerCase() ? subscriberChoices(deMap, isAlone) : [{ de: pair.partner, view: null }]; for (var i = 0; i < choices.length; i++) { var viewField = viewMap[(choices[i].view || pair.view).toLowerCase()]; var deField = deMap[choices[i].de.toLowerCase()]; if (!isSameFamily(viewField, deField)) continue; return { view: viewField.name, de: deField.name, partner: pair.partner, standIn: deField.name.toLowerCase() === pair.partner.toLowerCase() ? null : deField.name, standInKind: deField.kind || null, signature: pairSignature(pair) }; }; return null; }; function standInMatch(view, deMap, context) { var viewMap = viewFieldMap(view, context); var best = null; (context.relations[view] || []).forEach(function (rel) { if (!rel.pairs.length) return; var hasAlone = rel.pairs.some(function (pair) { return context.keys.alone[pair.view.toLowerCase()]; }); var columns = []; var missing = []; var isMatch = rel.pairs.every(function (pair) { var found = pairColumns(pair, viewMap, deMap, rel.pairs.length === 1); if (found) { columns.push(found); return true; }; if (hasAlone && !context.keys.alone[pair.view.toLowerCase()]) { missing.push(pair.partner); return true; }; return false; }); if (!isMatch) return; var signature = columns.map(function (column) { return column.signature; }).sort().join(','); if (best && best.signature === signature) { if (best.partners.indexOf(rel.partner) === -1) best.partners.push(rel.partner); best.rels.push(rel); return; }; if (!best || columns.length > best.columns.length) best = { columns: columns, signature: signature, partners: [rel.partner], rels: [rel], missing: missing, leftOut: [] }; }); if (best && best.partners.length && hasSubscriberColumn(deMap) && hasSubscriberColumn(viewMap) && !best.columns.some(function (column) { return column.partner.toLowerCase() === DE_JOIN_SUBSCRIBER_KEY.toLowerCase(); })) best = null; DE_JOIN_EXTRA_KEYS.forEach(function (key) { var lower = key.toLowerCase(); if (!isSameFamily(viewMap[lower], deMap[lower])) return; var deField = deMap[lower]; var deColumn = deField.name.toLowerCase(); var standIn = (deField.kind && deColumn !== lower) ? deField.name : null; if (best && best.columns.some(function (column) { return column.de.toLowerCase() === deColumn; })) return; if (best) { best.leftOut.push(standIn ? standInName(standIn, deField.kind) : key); return; }; best = { columns: [{ view: viewMap[lower].name, de: deField.name, partner: key, standIn: standIn, standInKind: deField.kind || null, signature: lower + '=' + lower }], signature: lower + '=' + lower, partners: [], rels: [], missing: [], leftOut: [] }; }); if (!best) { var identity = identityMatch(viewMap, deMap); if (identity) best = { columns: [{ view: identity.a, de: identity.b, partner: identity.key, standIn: null, standInKind: null, signature: identity.key.toLowerCase() + '=' + identity.key.toLowerCase() }], signature: identity.key.toLowerCase() + '=' + identity.key.toLowerCase(), partners: [], rels: [], missing: [], leftOut: [], identity: identity }; }; return best; }; function standInExtras(view, deMap, match, context) { var extras = []; if (!match.partners.length) return extras; var chosen = Object.create(null); match.columns.forEach(function (column) { chosen[column.signature] = true; }); (context.relations[view] || []).forEach(function (rel) { var conditions = rel.relation.extra || []; if (!conditions.length || !rel.pairs.length) return; if (match.rels.indexOf(rel) === -1 && !rel.pairs.every(function (pair) { return chosen[pairSignature(pair)]; })) return; conditions.forEach(function (condition) { var column = condition.split(' ')[0]; var text = condition; if (!rel.isViewB) { var deField = deMap[column.toLowerCase()]; if (!isSameFamily(deField, viewFieldMap(rel.partner, context)[column.toLowerCase()])) return; text = deField.name + condition.slice(column.length); }; var known = extras.filter(function (extra) { return extra.condition === text && extra.isOnView === rel.isViewB; })[0]; if (known) { if (known.partners.indexOf(rel.partner) === -1) known.partners.push(rel.partner); return; }; extras.push({ condition: text, isOnView: rel.isViewB, partners: [rel.partner] }); }); }); return extras; }; function standInName(name, kind) { if (kind === 'sendable') return name + ', the sendable field for Subscriber Key,'; if (kind === 'syncedId') return name + ', the Salesforce record ID that Marketing Cloud Connect uses as the Subscriber Key,'; return name; }; function standInClause(name, kind, table) { if (kind === 'syncedId') return name + ' is ' + table + '\'s Salesforce record ID, which Marketing Cloud Connect uses as the Subscriber Key'; return name + ' is ' + table + '\'s sendable field for Subscriber Key'; }; function viewDataExtensionJoin(viewSide, deSide, isViewJoined, kind, context, match) { var view = viewSide.view; match = match || standInMatch(view, deSide.map, context); if (!match) return null; var joined = isViewJoined ? viewSide : deSide; var anchor = isViewJoined ? deSide : viewSide; var conditions = match.columns.map(function (column) { return joined.text + '.' + formatIdentifier(isViewJoined ? column.view : column.de) + ' = ' + anchor.text + '.' + formatIdentifier(isViewJoined ? column.de : column.view); }); var onPair = []; var declared = []; var alike = []; var renamed = []; match.columns.forEach(function (column) { if (column.standInKind) { declared.push(column); return; }; var isPairView = column.signature.split('=')[0] === column.view.toLowerCase(); if (match.partners.length && isPairView) { onPair.push(column); if (column.standIn) renamed.push(column); return; }; alike.push(column); }); var sentences = []; if (onPair.length) { var pairColumnsUsed = onPair.map(function (column) { return column.view; }); sentences.push('Matched by name: ' + view + ' joins ' + match.partners[0] + ' on ' + listWords(pairColumnsUsed) + ', and ' + deSide.name + ' has ' + listWords(onPair.map(function (column) { return column.de; })) + ', with the same type' + (onPair.length === 1 ? '' : 's') + '.'); }; if (alike.length) { var alikeNames = alike.map(function (column) { return column.de; }); sentences.push((onPair.length ? '' : 'Matched by name: ') + listWords(alikeNames) + (alikeNames.length === 1 ? ' is' : ' are') + ' in both tables with the same type.'); }; if (onPair.length || alike.length) sentences.push('Two columns can share a name and mean different things, so check that these do.'); renamed.forEach(function (column) { sentences.push(column.standIn + ' stands in for ' + column.partner + '.'); }); declared.forEach(function (column) { sentences.push(standInClause(column.de, column.standInKind, deSide.name) + ', so it matches ' + column.view + ' in ' + view + '.'); }); if (match.missing.length) sentences.push('The join leaves out ' + listWords(match.missing) + ', which ' + deSide.name + ' does not have with the same type.'); if (match.leftOut.length) { var hasSubscriber = match.columns.some(function (column) { return column.view.toLowerCase() === DE_JOIN_SUBSCRIBER_KEY.toLowerCase() || column.de.toLowerCase() === DE_JOIN_SUBSCRIBER_KEY.toLowerCase(); }); var leftOut = listWords(match.leftOut) + (match.leftOut.length === 1 ? ' is' : ' are') + ' left out'; sentences.push(hasSubscriber ? leftOut + ', since both tables have ' + DE_JOIN_SUBSCRIBER_KEY + '.' : leftOut + ': the join is the one ' + view + ' has with ' + match.partners[0] + '.'); }; if (match.identity) identityNotes(match.identity.key, match.identity.leftOut).forEach(function (sentence) { sentences.push(sentence); }); standInExtras(view, deSide.map, match, context).forEach(function (extra) { var isCarrierJoined = extra.isOnView === isViewJoined; var carrier = isCarrierJoined ? joined : anchor; var partners = listWords(extra.partners, 'or'); if (isExtraAdded({ isJoinedB: isCarrierJoined }, kind)) { conditions.push(carrier.text + '.' + extra.condition); sentences.push(extra.isOnView ? extra.condition + ' goes in as it does when ' + view + ' is joined to ' + partners + ', as if ' + deSide.name + ' were ' + partners + '.' : extra.condition + ' goes in as it does when ' + partners + ' is joined to ' + view + ', as if ' + deSide.name + ' were ' + partners + '.'); } else { sentences.push(extraLeftOutNote([extra.condition], extra.isOnView ? view : deSide.name, kind)); }; }); return { conditions: conditions, columns: match.columns.map(function (column) { return isViewJoined ? column.view : column.de; }), pairs: match.columns.map(function (column) { return { joined: isViewJoined ? column.view : column.de, anchor: isViewJoined ? column.de : column.view }; }), note: sentences.join(' '), match: match }; }; function isSendLogFanOut(view, match, viewsInQuery, anchor, queryTables) { if (TRACKING_VIEWS.indexOf(view) === -1) return false; if (match.columns.some(function (column) { return column.view.toLowerCase() === 'jobid'; })) return isJobIdFanOut(anchor, queryTables); return TRACKING_VIEWS.some(function (other) { return !!viewsInQuery[other]; }); }; function isRelationSendLogFanOut(view, relation, viewsInQuery, anchor, queryTables) { if (TRACKING_VIEWS.indexOf(view) === -1) return false; var hasJobId = (relation.on || []).some(function (pair) { return String(pair[0]).toLowerCase() === 'jobid' || String(pair[1]).toLowerCase() === 'jobid'; }); if (hasJobId) return isJobIdFanOut(anchor, queryTables); return TRACKING_VIEWS.some(function (other) { return !!viewsInQuery[other]; }); }; function isJobIdFanOut(anchor, queryTables) { if (!anchor || !queryTables || sendLogViewName(anchor)) return false; var tokens = queryTables.tokens; var parent = {}; var find = function (ref) { while (parent[ref] !== undefined && parent[ref] !== ref) ref = parent[ref]; return ref; }; var isJobIdAt = function (i) { return isName(tokens[i]) && isPunctToken(tokens[i + 1], '.') && !!tokens[i + 2] && tokens[i + 2].upper === 'JOBID'; }; for (var i = 0; i + 6 < tokens.length; i++) { var op = tokens[i + 3]; if (!isJobIdAt(i) || !op || op.type !== 'operator' || op.value !== '=' || !isJobIdAt(i + 4)) continue; var rootA = find(tokens[i].upper); var rootB = find(tokens[i + 4].upper); if (rootA !== rootB) parent[rootA] = rootB; }; var anchorRoot = find(entryReference(anchor).toUpperCase()); return queryTables.entries.some(function (entry) { if (entry === anchor || !sendLogViewName(entry)) return false; return find(entryReference(entry).toUpperCase()) !== anchorRoot; }); }; function dataExtensionsJoin(joined, anchor, context) { var isBothSynchronized = isSynchronizedTable(joined.table) && isSynchronizedTable(anchor.table); var joinedMap = isBothSynchronized ? fieldMap(joined.fields) : joined.map; var anchorMap = isBothSynchronized ? fieldMap(anchor.fields) : anchor.map; var shared = sharedKeyColumns(joinedMap, anchorMap, context.keys); var identity = null; if (!shared.columns.length) { identity = identityMatch(fieldMap(joined.fields), fieldMap(anchor.fields)); if (!identity) return null; shared = { columns: [{ key: identity.key, joined: identity.a, anchor: identity.b, joinedKind: null, anchorKind: null, label: identity.key }], leftOut: [] }; }; var named = shared.columns.filter(function (column) { return column.label !== null; }); var sentences = []; if (named.length) { var labels = named.map(function (column) { return column.label; }); sentences.push('Matched by name: ' + listWords(labels) + (labels.length === 1 ? ' is' : ' are') + ' in both tables with the same type. Two columns can share a name and mean different things, so check that these do.'); }; shared.columns.filter(function (column) { return column.label === null; }).forEach(function (column) { var clauses = []; [[column.joined, column.joinedKind, joined.name], [column.anchor, column.anchorKind, anchor.name]].forEach(function (side) { if (side[0].toLowerCase() === column.key.toLowerCase()) return; if (side[1]) { clauses.push(standInClause(side[0], side[1], side[2])); } else { sentences.push(side[0] + ' in ' + side[2] + ' stands in for ' + column.key + '.'); }; }); if (clauses.length === 2) sentences.push(clauses.join(', and ') + ', so the two match.'); if (clauses.length === 1) { var isJoinedClause = column.joinedKind && column.joined.toLowerCase() !== column.key.toLowerCase(); sentences.push(clauses[0] + ', so it matches ' + (isJoinedClause ? column.anchor + ' in ' + anchor.name : column.joined + ' in ' + joined.name) + '.'); }; }); if (shared.leftOut.length) sentences.push(listWords(shared.leftOut) + (shared.leftOut.length === 1 ? ' is' : ' are') + ' left out, since both tables have ' + DE_JOIN_SUBSCRIBER_KEY + '.'); if (identity) identityNotes(identity.key, identity.leftOut).forEach(function (sentence) { sentences.push(sentence); }); return { conditions: shared.columns.map(function (column) { return joined.text + '.' + formatIdentifier(column.joined) + ' = ' + anchor.text + '.' + formatIdentifier(column.anchor); }), columns: shared.columns.map(function (column) { return column.joined; }), pairs: shared.columns.map(function (column) { return { joined: column.joined, anchor: column.anchor }; }), note: sentences.join(' ') }; }; function sideJoin(joinedSide, anchorSide, kind, context, viewsInQuery, queryTables) { var made; if (joinedSide.isDataExtension && anchorSide.isDataExtension) made = dataExtensionsJoin(joinedSide, anchorSide, context); else if (joinedSide.isDataExtension) made = viewDataExtensionJoin(anchorSide, joinedSide, false, kind, context); else made = viewDataExtensionJoin(joinedSide, anchorSide, true, kind, context); if (!made) return null; if (joinedSide.view && isSendLogFanOut(joinedSide.view, made.match, viewsInQuery, anchorSide.entry, queryTables)) return null; return made; }; function knownJoinFields(table) { if (Array.isArray(table.fields)) return table.fields; var isSendableByKey = table.isSendable === true && !!table.sendableField && subscriberFieldKind(table.sendableSubscriberField) === 'subscriberKey'; return isSendableByKey ? [{ name: String(table.sendableField), type: 'Text' }] : null; }; function wholeJoinSide(table, fields, alias) { var known = table; if (table.fields !== fields) { known = {}; Object.keys(table).forEach(function (key) { known[key] = table[key]; }); known.fields = fields; }; return { entry: null, ref: alias, text: formatIdentifier(alias), name: (table.isShared ? 'ENT.' : '') + table.name, view: null, table: known, fields: fields, map: deFieldMap(known), isDataExtension: true }; }; function compareJoinViews(a, b, isInQuery) { var ia = isInQuery[a.view] ? 1 : 0; var ib = isInQuery[b.view] ? 1 : 0; if (ia !== ib) return ia - ib; var va = a.view.toLowerCase(); var vb = b.view.toLowerCase(); if (va !== vb) return va < vb ? -1 : 1; return a.order - b.order; }; function joinCompletions(sql, offset, schema, data, options) { sql = sql == null ? '' : String(sql); offset = Math.max(0, Math.min(offset | 0, sql.length)); var scope = cursorScope(sql, offset); if (!scope) return joinCompletionsOf(sql, offset, schema, data, options); var found = joinCompletionsOf(sql.slice(scope.start, scope.end), offset - scope.start, schema, data, options); found.items = found.items.map(function (item) { return Object.assign({}, item, { start: item.start + scope.start, end: item.end + scope.start, additionalEdits: shiftEditList(item.additionalEdits, scope.start) }); }); return found; }; function shiftEditList(edits, offset) { return (edits || []).map(function (edit) { return Object.assign({}, edit, { start: edit.start + offset, end: edit.end + offset }); }); }; function joinCompletionsOf(sql, offset, schema, data, options) { var result = { mode: null, items: [], pendingTables: [] }; var isChildBusinessUnit = !!options && options.isChildBusinessUnit === true; data = data || (typeof window !== 'undefined' ? window.SQLStudioSQL : null); if (!data || !Array.isArray(data.dataViewJoins) || !data.dataViewAliases) return result; sql = sql == null ? '' : String(sql); offset = Math.max(0, Math.min(offset | 0, sql.length)); var tokens; try { tokens = withoutComments(tokenize(sql)); } catch (e) { return result; }; var wordStart = offset; while (wordStart > 0 && COMPLETION_WORD_CHAR.test(sql.charAt(wordStart - 1))) wordStart--; var lastIdx = -1; for (var i = 0; i < tokens.length; i++) { if (tokens[i].start >= wordStart) break; lastIdx = i; }; var mode = joinPosition(tokens, lastIdx); var operand = mode ? null : onOperandBefore(tokens, lastIdx); if (operand) mode = 'side'; if (!mode) return result; result.mode = mode; var schemaIndex = ensureIndex(schema); var onIdx = operand ? operand.onIdx : lastIdx; var entries = joinScopeEntries(tokens, sql, onIdx, schemaIndex); var viewByLower = {}; Object.keys(data.dataViewAliases).forEach(function (name) { viewByLower[name.toLowerCase()] = name; }); var context = joinTableContext(data, schemaIndex, viewByLower, schema); var queryTables = { tokens: tokens, entries: entries }; function hasFields(side) { if (side.fields) return true; if (result.pendingTables.indexOf(side.table) === -1) result.pendingTables.push(side.table); return false; }; function viewsInQuery() { var views = laterJoinedViews(tokens, tokenIndexAtOrAfter(tokens, offset), tokens[lastIdx].depth, viewByLower); entries.forEach(function (entry) { var view = viewOfEntry(entry, viewByLower); if (view) views[view] = true; }); return views; }; function isSideTable(anchor, joined) { if (!operand) return true; var left = operand.alias.toLowerCase(); return entryReference(joined).toLowerCase() === left || entryReference(anchor).toLowerCase() === left; }; if (mode === 'on' || mode === 'side') { var joined = entries.pop(); var joinedSide = joined ? joinSide(joined, context) : null; if (!joinedSide) return result; var joinedView = joinedSide.view; var joinedRef = entryReference(joined); var joinIdx = tokenIndexAtOrAfter(tokens, joined.start) - 1; var onJoinKind = isKw(tokens[joinIdx], 'JOIN') ? joinKind(tokens, joinIdx) : 'INNER'; var andIndent = conditionIndent(sql, tokens[onIdx].start); var onViewsInQuery = viewsInQuery(); if (joinedView) relatedJoinOptions(entries, data, viewByLower).filter(function (o) { return o.view === joinedView && !isRelationSendLogFanOut(joinedView, o.relation, onViewsInQuery, o.anchor, queryTables); }).forEach(function (option) { var anchorRef = entryReference(option.anchor); var isExtraIn = isExtraAdded(option, onJoinKind); var conditions = joinConditions(option, formatIdentifier(joinedRef), formatIdentifier(anchorRef), isExtraIn); result.items.push({ mode: 'on', label: 'Match ' + joinedRef + ' to ' + (option.anchor.alias ? anchorRef + ' (' + option.anchorView + ')' : anchorRef), filterText: conditions[0], detail: option.relation.on.map(function (pair) { return option.isJoinedB ? pair[1] : pair[0]; }).join(', '), note: joinNote(option, isExtraIn, onJoinKind), view: joinedView, table: joined.name, alias: joinedRef, anchorAlias: anchorRef, anchorView: option.anchorView, conditions: conditions, pairs: option.relation.on.map(function (pair) { return { joined: String(option.isJoinedB ? pair[1] : pair[0]), anchor: String(option.isJoinedB ? pair[0] : pair[1]) }; }), insertText: placeOnText(andIndent, conditions), start: wordStart, end: offset, additionalEdits: [] }); }); var isJoinedReady = !joinedSide.isDataExtension || hasFields(joinedSide); entries.forEach(function (anchor) { if (!isSideTable(anchor, joined)) return; var anchorSide = joinSide(anchor, context); if (!anchorSide || !(joinedSide.isDataExtension || anchorSide.isDataExtension)) return; var isAnchorReady = !anchorSide.isDataExtension || hasFields(anchorSide); if (!isJoinedReady || !isAnchorReady) return; var made = sideJoin(joinedSide, anchorSide, onJoinKind, context, onViewsInQuery, queryTables); if (!made) return; result.items.push({ mode: 'on', label: 'Match ' + joinedRef + ' to ' + (anchor.alias ? anchorSide.ref + ' (' + anchorSide.name + ')' : anchorSide.ref), filterText: made.conditions[0], detail: made.columns.join(', '), note: made.note, view: joinedView, table: joined.name, alias: joinedRef, anchorAlias: anchorSide.ref, anchorView: anchorSide.name, conditions: made.conditions, pairs: made.pairs, insertText: placeOnText(andIndent, made.conditions), start: wordStart, end: offset, additionalEdits: [] }); }); if (mode === 'side') result.items = otherSideItems(result.items, operand, sql, wordStart, offset); return result; }; var bestByView = {}; var listedViews = []; function consider(option) { var best = bestByView[option.view]; if (!best) { listedViews.push(option.view); bestByView[option.view] = option; } else if (option.count > best.count) { bestByView[option.view] = option; }; }; var isInQuery = viewsInQuery(); var related = relatedJoinOptions(entries, data, viewByLower); entries.forEach(function (entry, anchorIndex) { related.forEach(function (option) { if (option.anchorIndex === anchorIndex && !isRelationSendLogFanOut(option.view, option.relation, isInQuery, option.anchor, queryTables)) consider({ view: option.view, count: option.relation.on.length, related: option }); }); var side = joinSide(entry, context); if (!side || !side.isDataExtension || !hasFields(side)) return; (data.dataViews || []).forEach(function (dv) { if (!dv || !data.dataViewAliases[dv.name]) return; var match = standInMatch(dv.name, side.map, context); if (match && !isSendLogFanOut(dv.name, match, isInQuery, entry, queryTables)) consider({ view: dv.name, count: match.columns.length, deSide: side, match: match }); }); }); var joinOptions = listedViews.map(function (view, order) { bestByView[view].order = order; return bestByView[view]; }); joinOptions.sort(function (a, b) { return compareJoinViews(a, b, isInQuery); }); var used = usedReferences(sql, tokens, schema, entries); var base = joinLayoutBase(sql, tokens, entries, wordStart, offset, lastIdx); var kind = joinKind(tokens, lastIdx); joinOptions.forEach(function (option) { var alias = freeAlias(data.dataViewAliases[option.view], used); var table = dataViewTable(option.view, data, isChildBusinessUnit); var made; if (option.related) { var anchorRef = entryReference(option.related.anchor); var isExtraIn = isExtraAdded(option.related, kind); made = { label: option.view + ', joined to ' + (option.related.anchor.alias ? anchorRef + ' (' + option.related.anchorView + ')' : anchorRef), detail: option.related.relation.on.map(function (pair) { return option.related.isJoinedB ? pair[1] : pair[0]; }).join(', '), note: joinNote(option.related, isExtraIn, kind), anchorAlias: anchorRef, anchorView: option.related.anchorView, conditions: joinConditions(option.related, alias, formatIdentifier(anchorRef), isExtraIn) }; } else { var viewSide = { ref: alias, text: alias, name: option.view, view: option.view, isDataExtension: false }; var written = viewDataExtensionJoin(viewSide, option.deSide, true, kind, context, option.match); made = { label: option.view + ', joined to ' + (option.deSide.entry.alias ? option.deSide.ref + ' (' + option.deSide.name + ')' : option.deSide.ref), detail: written.columns.join(', '), note: written.note, anchorAlias: option.deSide.ref, anchorView: option.deSide.name, conditions: written.conditions }; }; var placed = placeJoinText(base, table, alias, made.conditions); result.items.push({ mode: 'join', label: made.label, filterText: option.view, detail: made.detail, note: made.note, view: option.view, table: table, alias: alias, anchorAlias: made.anchorAlias, anchorView: made.anchorView, conditions: made.conditions, insertText: placed.text, start: wordStart, end: offset, additionalEdits: placed.edits }); }); var readTables = []; var anchorSides = []; entries.forEach(function (entry) { var anchorSide = joinSide(entry, context); if (!anchorSide) return; if (anchorSide.table) readTables.push(anchorSide.table); if (!anchorSide.isDataExtension || anchorSide.fields) anchorSides.push(anchorSide); }); var wholeJoins = []; if (anchorSides.length) (Array.isArray(schema && schema.tables) ? schema.tables : []).forEach(function (table, order) { if (!table || table.kind !== 'de' || !table.name) return; if (schemaIndex[(table.isShared ? 'ent.' : '') + String(table.name).toLowerCase()] !== table) return; if (readTables.indexOf(table) !== -1) return; if (context.isSendPending(table)) return; var fields = knownJoinFields(table); if (!fields) return; var deSide = wholeJoinSide(table, fields, deJoinAlias(table.name, used)); var best = null; anchorSides.forEach(function (anchorSide) { var made = sideJoin(deSide, anchorSide, kind, context, isInQuery, queryTables); if (made && (!best || made.columns.length > best.made.columns.length)) best = { made: made, anchor: anchorSide }; }); if (best) wholeJoins.push({ table: table, side: deSide, made: best.made, anchor: best.anchor, order: order }); }); wholeJoins.sort(function (a, b) { var na = a.table.name.toLowerCase(); var nb = b.table.name.toLowerCase(); if (na !== nb) return na < nb ? -1 : 1; return a.order - b.order; }); wholeJoins.forEach(function (join) { var tableText = (join.table.isShared ? 'ENT.' : '') + formatIdentifier(join.table.name); var placed = placeJoinText(base, tableText, join.side.ref, join.made.conditions); result.items.push({ mode: 'join', label: join.side.name + ', joined to ' + (join.anchor.entry.alias ? join.anchor.ref + ' (' + join.anchor.name + ')' : join.anchor.ref), filterText: join.table.name, detail: join.made.columns.join(', '), note: join.made.note, view: null, table: tableText, alias: join.side.ref, anchorAlias: join.anchor.ref, anchorView: join.anchor.name, conditions: join.made.conditions, insertText: placed.text, start: wordStart, end: offset, additionalEdits: placed.edits, dataExtension: join.table }); }); return result; }; function cteBodyAt(tokens, cursor) { for (var i = 0; i < tokens.length; i++) { if (tokens[i].depth !== 0 || !isKw(tokens[i], 'WITH')) continue; var j = i + 1; while (isName(tokens[j])) { j++; if (isPunctToken(tokens[j], '(')) j = matchParen(tokens, j) + 1; if (isKw(tokens[j], 'AS')) j++; if (!isPunctToken(tokens[j], '(')) break; var close = matchParen(tokens, j); var isClosed = close > j && isPunctToken(tokens[close], ')') && tokens[close].depth === tokens[j].depth; var bodyEnd = isClosed ? close : tokens.length; if (cursor >= tokens[j].end && (!isClosed || cursor <= tokens[close].start)) return { start: j + 1, end: bodyEnd, depth: tokens[j].depth + 1 }; j = bodyEnd + 1; if (!isPunctToken(tokens[j], ',')) break; j++; }; }; return null; }; function isFromClauseWhole(tokens, start, end, sql) { var parser = new StrictParser(tokens, start, end, null, buildLineIndex(sql)); try { parser.parseFromClause(); return parser.pos >= parser.end; } catch (e) { return !(e instanceof StrictError); }; }; function joinTarget(sql, allTokens, tokens, cursor, schemaIndex) { var scope = cteBodyAt(tokens, cursor) || { start: 0, end: tokens.length, depth: 0 }; var selectIdx = -1; for (var i = scope.start; i < scope.end; i++) { if (tokens[i].depth !== scope.depth || !isKw(tokens[i], 'SELECT')) continue; if (selectIdx === -1 || tokens[i].start <= cursor) selectIdx = i; }; if (selectIdx === -1) return null; var fromIdx = -1; for (var j = selectIdx + 1; j < scope.end && fromIdx === -1; j++) { var t = tokens[j]; if (t.depth !== scope.depth) continue; if (isKw(t, 'FROM')) { fromIdx = j; } else if ((t.type === 'punct' && t.value === ';') || isKwAny(t, ['UNION', 'EXCEPT', 'INTERSECT', 'WHERE', 'HAVING', 'SELECT'])) { break; }; }; if (fromIdx === -1) return null; var end = fromIdx + 1; while (end < scope.end) { var u = tokens[end]; if (u.depth === scope.depth && ((u.type === 'punct' && u.value === ';') || (isBoundaryKeyword(u, tokens[end + 1]) && u.upper !== 'FROM') || isKwAny(u, ['FOR', 'OPTION', 'SELECT']))) break; end++; }; if (end - 1 === fromIdx) return null; var entries; try { entries = parseSelect(tokens, selectIdx, end, { schemaIndex: schemaIndex, ctes: {} }, sql).from; } catch (e) { return null; }; var lastTok = tokens[end - 1]; var isUnfinished = !isFromClauseWhole(tokens, fromIdx + 1, end, sql); var pos = lastTok.end; var k = tokenIndexAtOrAfter(allTokens, pos); var next = allTokens[k]; while (next && next.type === 'comment' && sql.slice(pos, next.start).indexOf('\n') === -1) { pos = next.end; next = allTokens[++k]; }; var isMoreOnLine = !!next && sql.slice(pos, next.start).indexOf('\n') === -1 && !(next.type === 'punct' && next.value === ';'); var fromTok = tokens[fromIdx]; var fromLead = /^[ \t]*/.exec(sql.slice(lineStartOf(sql, fromTok.start), fromTok.start))[0]; return { entries: entries, isUnfinished: isUnfinished, insertAt: pos, replaceEnd: isMoreOnLine ? next.start : pos, trailer: isMoreOnLine ? '\n' + fromLead : '', indent: textColumns(fromLead) + 4 }; }; function joinToQuery(sql, cursor, schema, data, options) { sql = sql == null ? '' : String(sql); cursor = Math.max(0, Math.min(cursor | 0, sql.length)); var scope = cursorScope(sql, cursor); if (!scope) return joinToQueryOf(sql, cursor, schema, data, options); var found = joinToQueryOf(sql.slice(scope.start, scope.end), cursor - scope.start, schema, data, options); found.items = found.items.map(function (item) { return Object.assign({}, item, { edits: shiftEditList(item.edits, scope.start), cursor: item.cursor + scope.start }); }); return found; }; function joinToQueryOf(sql, cursor, schema, data, options) { var result = { hasTable: false, isUnfinished: false, readTables: [], readViews: [], pendingTables: [], items: [] }; data = data || (typeof window !== 'undefined' ? window.SQLStudioSQL : null); if (!data || !Array.isArray(data.dataViewJoins) || !data.dataViewAliases) return result; sql = sql == null ? '' : String(sql); cursor = Math.max(0, Math.min(cursor | 0, sql.length)); var allTokens; try { allTokens = tokenize(sql); } catch (e) { return result; }; var tokens = withoutComments(allTokens); var schemaIndex = ensureIndex(schema); var target = joinTarget(sql, allTokens, tokens, cursor, schemaIndex); if (!target || !target.entries.length) return result; result.hasTable = true; if (target.isUnfinished) { result.isUnfinished = true; return result; }; var viewByLower = {}; Object.keys(data.dataViewAliases).forEach(function (name) { viewByLower[name.toLowerCase()] = name; }); var context = joinTableContext(data, schemaIndex, viewByLower, schema); target.entries.forEach(function (entry) { var side = joinSide(entry, context); if (!side) return; if (side.view) { if (result.readViews.indexOf(side.view) === -1) result.readViews.push(side.view); return; }; if (result.readTables.indexOf(side.table) === -1) result.readTables.push(side.table); }); var scaffold = '\n' + fmtSpaces(target.indent) + 'LEFT JOIN '; var cursorAt = target.insertAt + scaffold.length; var scratch = sql.slice(0, target.insertAt) + scaffold + target.trailer + sql.slice(target.replaceEnd); var found = joinCompletionsOf(scratch, cursorAt, schema, data, options); result.pendingTables = found.pendingTables; if (found.mode !== 'join') return result; found.items.forEach(function (item) { if (item.mode !== 'join' || item.start !== cursorAt || item.end !== cursorAt) return; if (item.view && result.readViews.indexOf(item.view) !== -1) return; var gaps = item.additionalEdits.filter(function (edit) { return edit.end <= target.insertAt; }); if (gaps.length !== item.additionalEdits.length) return; var edits = gaps.map(function (edit) { return { start: edit.start, end: edit.end, text: edit.text }; }); edits.push({ start: target.insertAt, end: target.replaceEnd, text: scaffold + item.insertText + target.trailer }); edits.sort(function (a, b) { return a.start - b.start; }); var shift = 0; gaps.forEach(function (edit) { shift += edit.text.length - (edit.end - edit.start); }); result.items.push({ view: item.view, dataExtension: item.dataExtension, label: item.label, table: item.table, alias: item.alias, anchorAlias: item.anchorAlias, anchorView: item.anchorView, conditions: item.conditions, note: item.note, edits: edits, cursor: target.insertAt + shift + scaffold.length + item.insertText.length }); }); return result; }; var JOIN_KEYWORD_PHRASES = ['INNER JOIN', 'LEFT JOIN', 'LEFT OUTER JOIN', 'RIGHT JOIN', 'RIGHT OUTER JOIN', 'FULL JOIN', 'FULL OUTER JOIN', 'CROSS JOIN', 'CROSS APPLY', 'OUTER APPLY']; function joinKeywordCompletions(sql, offset) { sql = sql == null ? '' : String(sql); offset = Math.max(0, Math.min(offset | 0, sql.length)); var scope = cursorScope(sql, offset); if (!scope) return joinKeywordCompletionsOf(sql, offset); var found = joinKeywordCompletionsOf(sql.slice(scope.start, scope.end), offset - scope.start); found.items = found.items.map(function (item) { return Object.assign({}, item, { start: item.start + scope.start, end: item.end + scope.start }); }); return found; }; function joinKeywordCompletionsOf(sql, offset) { var result = { items: [] }; sql = sql == null ? '' : String(sql); offset = Math.max(0, Math.min(offset | 0, sql.length)); var tokens; try { tokens = withoutComments(tokenize(sql)); } catch (e) { return result; }; var wordStart = offset; while (wordStart > 0 && COMPLETION_WORD_CHAR.test(sql.charAt(wordStart - 1))) wordStart--; var lastIdx = -1; for (var i = 0; i < tokens.length; i++) { if (tokens[i].start >= wordStart) break; lastIdx = i; }; var runStart = lastIdx + 1; while (runStart > 0 && tokens[runStart - 1].type === 'keyword' && JOIN_QUALIFIER_WORDS.indexOf(tokens[runStart - 1].upper) !== -1) runStart--; if (runStart > lastIdx) return result; var lineStart = lineStartOf(sql, wordStart); var words = []; var offLine = 0; for (var w = runStart; w <= lastIdx; w++) { words.push(tokens[w].upper); if (tokens[w].start < lineStart) offLine = words.length; }; var start = offLine < words.length ? tokens[runStart + offLine].start : wordStart; var typedOnLine = sql.slice(start, wordStart); var prefix = sql.slice(wordStart, offset).toUpperCase(); JOIN_KEYWORD_PHRASES.forEach(function (phrase) { var parts = phrase.split(' '); if (parts.length <= words.length || parts[words.length].indexOf(prefix) !== 0) return; for (var p = 0; p < words.length; p++) { if (parts[p] !== words[p]) return; }; result.items.push({ label: phrase, keyword: phrase.replace(' OUTER JOIN', ' JOIN'), insertText: parts.slice(offLine).join(' ') + ' ', filterText: typedOnLine + parts.slice(words.length).join(' '), start: start, end: offset }); }); return result; }; function makeFinding(id, severity, message, docs, startTok, endTok, sql, lineIndexCache, fixes, action) { var s = typeof startTok === 'number' ? startTok : startTok.start; var e = typeof endTok === 'number' ? endTok : endTok.end; if (e < s) e = s; var starts = lineIndexCache.starts; var a = lineColAt(starts, s); var b = lineColAt(starts, e); var finding = { id: id, severity: severity, message: message, docs: docs, start: s, end: e, line: a.line, col: a.col, endLine: b.line, endCol: b.col }; if (fixes && fixes.length) { finding.fixes = fixes; finding.fix = fixes[0]; }; if (action) finding.action = action; return finding; }; function editDistance(a, b, isSwapOne) { var la = a.length, lb = b.length; var rows = []; for (var i = 0; i <= la; i++) { rows.push([i]); }; for (var j = 0; j <= lb; j++) { rows[0][j] = j; }; for (i = 1; i <= la; i++) { for (j = 1; j <= lb; j++) { var cost = a.charAt(i - 1) === b.charAt(j - 1) ? 0 : 1; rows[i][j] = Math.min(rows[i - 1][j] + 1, rows[i][j - 1] + 1, rows[i - 1][j - 1] + cost); if (isSwapOne && i > 1 && j > 1 && a.charAt(i - 1) === b.charAt(j - 2) && a.charAt(i - 2) === b.charAt(j - 1)) { rows[i][j] = Math.min(rows[i][j], rows[i - 2][j - 2] + 1); }; }; }; return rows[la][lb]; }; function uniqueOutputName(baseName, usedLower) { var candidate = baseName; var n = 2; while (usedLower[candidate.toLowerCase()]) { candidate = baseName + '_' + n; n++; }; usedLower[candidate.toLowerCase()] = true; return candidate; }; var FUNCTION_PASCAL_NAMES = { DATEDIFF: 'DateDiff', DATEADD: 'DateAdd', DATEPART: 'DatePart', DATENAME: 'DateName', DATETRUNC: 'DateTrunc', GETDATE: 'GetDate', GETUTCDATE: 'GetUtcDate', ISNULL: 'IsNull', ROW_NUMBER: 'RowNumber', DENSE_RANK: 'DenseRank', STRING_AGG: 'StringAgg', TRY_CAST: 'TryCast', TRY_CONVERT: 'TryConvert', TRY_PARSE: 'TryParse' }; function pascalFunctionName(name) { var upper = String(name).toUpperCase(); if (FUNCTION_PASCAL_NAMES[upper]) return FUNCTION_PASCAL_NAMES[upper]; return upper.charAt(0) + upper.slice(1).toLowerCase(); }; function isFullyParenWrapped(list) { if (list.length < 2) return false; if (!(list[0].type === 'punct' && list[0].value === '(')) return false; if (!(list[list.length - 1].type === 'punct' && list[list.length - 1].value === ')')) return false; var depth = 0; for (var i = 0; i < list.length; i++) { var t = list[i]; if (t.type === 'punct' && t.value === '(') depth++; else if (t.type === 'punct' && t.value === ')') { depth--; if (depth === 0) return i === list.length - 1; }; }; return false; }; function splitTopLevelCommas(tokensList) { var groups = []; var current = []; var depth = 0; tokensList.forEach(function (t) { if (t.type === 'punct' && t.value === '(') { depth++; current.push(t); return; }; if (t.type === 'punct' && t.value === ')') { depth--; current.push(t); return; }; if (depth === 0 && t.type === 'punct' && t.value === ',') { groups.push(current); current = []; return; }; current.push(t); }); groups.push(current); return groups; }; function deriveAliasBaseName(tokList) { var list = tokList || []; while (isFullyParenWrapped(list)) { list = list.slice(1, -1); }; if (!list.length) return 'Expression'; if (list.length === 1 && (list[0].type === 'string' || list[0].type === 'number' || isKw(list[0], 'NULL'))) { return 'Value'; }; if (list[0].type === 'identifier' && !list[0].quote && list[1] && list[1].type === 'punct' && list[1].value === '(' && list[list.length - 1].type === 'punct' && list[list.length - 1].value === ')') { var depth = 0, closesAt = -1; for (var i = 1; i < list.length; i++) { if (list[i].type === 'punct' && list[i].value === '(') depth++; else if (list[i].type === 'punct' && list[i].value === ')') { depth--; if (depth === 0) { closesAt = i; break; }; }; }; if (closesAt === list.length - 1) { var funcName = pascalFunctionName(list[0].value); var argTokens = list.slice(2, list.length - 1); if (argTokens.length && isKw(argTokens[0], 'DISTINCT')) argTokens = argTokens.slice(1); var groups = argTokens.length ? splitTopLevelCommas(argTokens) : []; if (groups.length === 1) { var g = groups[0]; var isChain = g.length > 0 && g.every(function (t, idx) { return idx % 2 === 0 ? isName(t) : (t.type === 'punct' && t.value === '.'); }); if (isChain) { var lastName = g[g.length - 1].value; return funcName + lastName.charAt(0).toUpperCase() + lastName.slice(1); }; }; return funcName; }; }; if (isKw(list[0], 'CASE')) return 'CaseResult'; return 'Expression'; }; function buildLikeInFix(tokens, sql, operandEndIdx, inIdx) { var inTok = tokens[inIdx]; var chain = []; var j = operandEndIdx; while (j >= 0) { var t = tokens[j]; if (chain.length % 2 === 0) { if (!isName(t) || t.depth !== inTok.depth) break; } else if (!(t.type === 'punct' && t.value === '.')) { break; }; chain.unshift(t); j--; }; if (!chain.length || chain.length % 2 === 0) return null; var openIdx = inIdx + 1; if (!(tokens[openIdx] && tokens[openIdx].type === 'punct' && tokens[openIdx].value === '(')) return null; var closeIdx = matchParen(tokens, openIdx); var groups = splitTopLevelCommas(tokens.slice(openIdx + 1, closeIdx)); var literals = []; for (var g = 0; g < groups.length; g++) { if (groups[g].length !== 1 || groups[g][0].type !== 'string') return null; literals.push(groups[g][0]); }; if (!literals.length) return null; var nextIdx = closeIdx + 1; while (tokens[nextIdx] && tokens[nextIdx].type === 'comment') nextIdx++; var escapeText = ''; var endTok = tokens[closeIdx]; if (isKw(tokens[nextIdx], 'ESCAPE')) { var charIdx = nextIdx + 1; while (tokens[charIdx] && tokens[charIdx].type === 'comment') charIdx++; if (!tokens[charIdx] || tokens[charIdx].type !== 'string') return null; escapeText = ' ESCAPE ' + tokens[charIdx].raw; endTok = tokens[charIdx]; }; var colText = sql.slice(chain[0].start, chain[chain.length - 1].end); var replacement = '(' + literals.map(function (lit) { return colText + ' LIKE ' + lit.raw + escapeText; }).join(' OR ') + ')'; return { edits: [{ start: chain[0].start, end: endTok.end, text: replacement }] }; }; function aliasItemExpressionTokens(item) { var list = item.tokens || []; if (!list.length) return []; if (list.length >= 3 && list[1].type === 'operator' && list[1].value === '=' && list[0].start === item.nameStart) { return list.slice(2); }; if (list.length >= 2 && isKw(list[list.length - 2], 'AS')) { return list.slice(0, list.length - 2); }; return list.slice(0, list.length - 1); }; function collectAllSelects(tokens, sql, schema) { tokens = withoutComments(tokens); var selects = []; var schemaIndex = ensureIndex(schema); var ctx = { schemaIndex: schemaIndex, ctes: {} }; function walk(start, end, isTop) { var r; try { r = parseSelect(tokens, start, end, ctx, sql); } catch (e) { return; }; if (r.noSelect) return; pushSelect(r, isTop); r.ctes.forEach(function (c) { if (c.inner) walk.pushed = true; }); Object.keys(ctx.ctes).forEach(function (k) { var c = ctx.ctes[k]; if (c.inner && !c._walked) { c._walked = true; pushSelect(c.inner, false); }; }); r.from.forEach(function (f) { if (f.kind === 'derived' && f.inner) pushSelect(f.inner, false); }); }; function hasTopInside(r) { if (r.clauses.top) return true; return (r.parenOperands || []).some(hasTopInside); }; function pushSelect(r, isTop) { var isOwnEntry = !!r.isParenLed; selects.push({ isTop: isTop, top: isOwnEntry ? null : r.clauses.top, orderBy: isOwnEntry ? r.trailingOrderBy : r.clauses.orderBy, hasOperandTop: isOwnEntry || !!r.parenOperands ? hasTopInside(r) : false, start: r.start, end: r.end, from: r.from, selectList: r.selectList, clauses: r.clauses }); (r.parenOperands || []).forEach(function (operand) { pushSelect(operand, false); }); }; walk(0, tokens.length, true); return selects; }; var MCE015_VALID_CHARS = /^[A-Za-z0-9 _.-]+$/; var MCE015_ALL_DIGITS = /^[0-9]+$/; function tokenIndexAtOrAfter(tokens, charPos) { var low = 0; var high = tokens.length; while (low < high) { var mid = (low + high) >> 1; if (tokens[mid].start >= charPos) high = mid; else low = mid + 1; }; return low; }; function whereConditionTokenSpan(tokens, whereClause) { return { start: tokenIndexAtOrAfter(tokens, whereClause.start) + 1, end: tokenIndexAtOrAfter(tokens, whereClause.end) }; }; function collectSelectScopes(sql, core, schema, queryCtes) { var scopes = []; var bySelect = {}; var parsedBySelect = {}; var selectIdxByStart = {}; var parseCtx = { schemaIndex: ensureIndex(schema), ctes: {}, parseMemo: Object.create(null) }; var cteKeys = queryCtes ? Object.keys(queryCtes).sort(function (a, b) { return queryCtes[a].end - queryCtes[b].end; }) : []; var nextCte = 0; var addCtesBefore = function (i) { while (nextCte < cteKeys.length && queryCtes[cteKeys[nextCte]].end <= i) { parseCtx.ctes[cteKeys[nextCte]] = queryCtes[cteKeys[nextCte]]; nextCte++; }; }; for (var s = 0; s < core.length; s++) { if (isKw(core[s], 'SELECT')) selectIdxByStart[core[s].start] = s; }; function keepDerived(parsed) { parsed.from.forEach(function (f) { if (f.kind !== 'derived' || !f.inner || f.inner.noSelect) return; var idx = selectIdxByStart[f.inner.start]; if (idx === undefined || parsedBySelect[idx]) return; parsedBySelect[idx] = f.inner; keepDerived(f.inner); }); }; for (var i = 0; i < core.length; i++) { if (!isKw(core[i], 'SELECT')) continue; var depth = core[i].depth; var parsed = parsedBySelect[i]; if (!parsed) { var selectEnd = i + 1; while (selectEnd < core.length && core[selectEnd].depth >= depth) { var t = core[selectEnd]; if (t.depth === depth && ((t.type === 'punct' && t.value === ';') || (t.type === 'keyword' && FORMATTER_SETOPS[t.upper]))) break; selectEnd++; }; addCtesBefore(i); try { parsed = parseSelect(core, i, selectEnd, parseCtx, sql); } catch (e) { continue; }; keepDerived(parsed); }; var scope = { selectIdx: i, depth: depth, from: parsed.from, selectList: parsed.selectList, joinGroups: parsed.joinGroups || [], where: parsed.clauses.where ? whereConditionTokenSpan(core, parsed.clauses.where) : null, having: parsed.clauses.having ? whereConditionTokenSpan(core, parsed.clauses.having) : null, clauses: parsed.clauses, ons: [] }; bySelect[i] = scope; scopes.push(scope); }; for (var k = 0; k < core.length; k++) { if (!isKw(core[k], 'ON')) continue; var onStart = core[k].start; var owner = bySelect[scopeSelectIndex(core, k)]; var ownGroup = null; if (!owner) { scopes.forEach(function (scope) { scope.joinGroups.forEach(function (g) { if (g.start < onStart && g.end > onStart && (!ownGroup || g.start > ownGroup.start)) { owner = scope; ownGroup = g; }; }); }); }; if (!owner) continue; var entries = owner.from.filter(function (f) { return f.start < onStart && (!ownGroup || f.start > ownGroup.start); }); var joinedGroup = owner.joinGroups.filter(function (g) { return !!core[k - 1] && g.end === core[k - 1].end; })[0]; var onEnd = onConditionEnd(core, k); var groupReferenced = []; if (joinedGroup) { var inGroup = owner.from.filter(function (f) { return f.start > joinedGroup.start && f.start < joinedGroup.end; }); groupReferenced = inGroup.filter(function (f) { var label = String(f.alias || f.name || '').toUpperCase(); if (!label) return false; for (var q = k + 1; q < onEnd; q++) { if (isName(core[q]) && core[q].upper === label && isPunctToken(core[q + 1], '.')) return true; }; return false; }); if (groupReferenced.length === 1) entries = entries.filter(function (f) { return f.start < joinedGroup.start; }).concat([groupReferenced[0]]); }; if (joinedGroup && groupReferenced.length !== 1) { entries = entries.filter(function (f) { return f.start < joinedGroup.start; }); entries.push({ name: null, alias: null, kind: 'derived', start: joinedGroup.start, end: joinedGroup.end, columns: [] }); }; owner.ons.push({ onIdx: k, start: k + 1, end: onEnd, depth: core[k].depth, entries: entries }); }; return scopes; }; function nestingFrame(open, isOperand, isAfterOp, owner, opHead) { return { open: open, isOperand: isOperand, isAfterOp: isAfterOp, owner: owner, opHead: opHead, headSel: -1, lastSelect: -1, isPrevSetOp: false }; }; function collectSelectNesting(core, scopes) { var scopeBySelect = {}; scopes.forEach(function (scope) { scopeBySelect[scope.selectIdx] = scope; }); var nesting = {}; var frames = [nestingFrame(-1, false, false, -1, -1)]; var endSelect = function (frame, idx) { var place = nesting[frame.lastSelect]; if (place && place.end === -1) place.end = idx; }; for (var i = 0; i < core.length; i++) { var t = core[i]; var frame = frames[frames.length - 1]; if (isPunctToken(t, '(')) { var before = core[i - 1]; var isAfterOp = !!before && ((before.type === 'keyword' && !!SET_OPERATOR_WORDS[before.upper]) || (isKw(before, 'ALL') && isKw(core[i - 2], 'UNION'))); var isOperand = !before || isPunctToken(before, ';') || isPunctToken(before, '(') || isAfterOp; frames.push(nestingFrame(i, isOperand, isAfterOp, frame.lastSelect !== -1 ? frame.lastSelect : frame.owner, isAfterOp ? frame.headSel : -1)); frame.isPrevSetOp = false; } else if (isPunctToken(t, ')')) { if (frames.length > 1) endSelect(frames.pop(), i); } else if (isPunctToken(t, ';')) { endSelect(frame, i); frame.headSel = -1; frame.lastSelect = -1; frame.isPrevSetOp = false; } else if (t.type === 'keyword' && SET_OPERATOR_WORDS[t.upper]) { endSelect(frame, i); frame.isPrevSetOp = true; } else if (isKw(t, 'SELECT')) { var head = i; if (frame.isPrevSetOp && frame.headSel !== -1) head = frame.headSel; else if (frame.lastSelect === -1 && frame.opHead !== -1) head = frame.opHead; if (head === i) { if (frame.headSel === -1) frame.headSel = i; if (frame.lastSelect === -1) { for (var k = frames.length - 1; k > 0; k--) { if (!frames[k].isOperand || frames[k].isAfterOp) break; if (frames[k - 1].headSel === -1) frames[k - 1].headSel = i; }; }; }; var holder = frames.length - 1; while (holder > 0 && frames[holder].isOperand) holder--; nesting[i] = { parent: holder === 0 ? -1 : frames[holder].owner, kind: 'top', open: holder === 0 ? -1 : frames[holder].open, head: head, end: -1, entry: null }; frame.lastSelect = i; frame.isPrevSetOp = false; }; }; frames.forEach(function (frame) { endSelect(frame, core.length); }); Object.keys(nesting).forEach(function (key) { var place = nesting[key]; if (place.open === -1) return; if (place.parent === -1) { if (isKw(core[place.open - 1], 'AS')) place.kind = 'cte'; return; }; var owner = scopeBySelect[place.parent]; var entry = owner ? owner.from.filter(function (f) { return f.kind === 'derived' && f.start === core[place.open].start; })[0] : null; place.entry = entry || null; place.kind = !entry ? 'subquery' : (isKw(core[place.open - 1], 'APPLY') ? 'apply' : 'derived'); }); return nesting; }; function visibleScopes(core, nesting, scopeBySelect, selectIdx) { var chain = []; if (scopeBySelect[selectIdx]) chain.push({ scope: scopeBySelect[selectIdx], limit: Infinity }); var place = nesting[selectIdx]; while (place && place.parent !== -1 && (place.kind === 'derived' || place.kind === 'apply' || place.kind === 'subquery')) { var outer = scopeBySelect[place.parent]; if (!outer) break; if (place.kind !== 'derived') chain.push({ scope: outer, limit: place.kind === 'apply' ? core[place.open].start : Infinity }); place = nesting[place.parent]; }; return chain; }; var GROUP_AGGREGATES = { APPROX_COUNT_DISTINCT: 1, APPROX_PERCENTILE_CONT: 1, APPROX_PERCENTILE_DISC: 1, AVG: 1, CHECKSUM_AGG: 1, COUNT: 1, COUNT_BIG: 1, GROUPING: 1, GROUPING_ID: 1, MAX: 1, MIN: 1, STDEV: 1, STDEVP: 1, STRING_AGG: 1, SUM: 1, VAR: 1, VARP: 1 }; var OTHER_DIALECT_AGGREGATES = { ARRAY_AGG: 1, GROUP_CONCAT: 1, LISTAGG: 1, STDDEV: 1, STDDEV_POP: 1, STDDEV_SAMP: 1, VARIANCE: 1, VAR_POP: 1, VAR_SAMP: 1 }; var DATE_PART_CALLS = { DATEADD: 1, DATEDIFF: 1, DATEDIFF_BIG: 1, DATENAME: 1, DATEPART: 1, DATETRUNC: 1, DATE_BUCKET: 1 }; var TYPE_FIRST_CALLS = { CONVERT: 1, TRY_CONVERT: 1 }; var TYPE_AFTER_AS_CALLS = { CAST: 1, TRY_CAST: 1, PARSE: 1, TRY_PARSE: 1 }; var TRIM_WORDS = { LEADING: 1, TRAILING: 1, BOTH: 1 }; function overAfterCall(core, close) { var k = close + 1; if (isKw(core[k], 'WITHIN') && isKw(core[k + 1], 'GROUP') && isPunctToken(core[k + 2], '(')) k = matchParen(core, k + 2) + 1; if (core[k] && (core[k].upper === 'IGNORE' || core[k].upper === 'RESPECT') && core[k + 1] && core[k + 1].upper === 'NULLS') k += 2; return isKw(core[k], 'OVER') ? k : -1; }; function collectScopeIndex(core, scopes, nesting) { var bySelect = {}; scopes.forEach(function (scope) { bySelect[scope.selectIdx] = scope; }); var owner = new Array(core.length); var open = []; for (var k = 0; k < core.length; k++) { while (open.length && k >= open[open.length - 1].end) open.pop(); if (isKw(core[k], 'SELECT') && nesting[k]) { open.push({ idx: k, end: nesting[k].end }); }; owner[k] = open.length ? open[open.length - 1].idx : -1; }; return { bySelect: bySelect, owner: owner, isComplete: Object.keys(nesting).length === scopes.length }; }; function isNonColumnPlace(frame) { if ((DATE_PART_CALLS[frame.name] || TYPE_FIRST_CALLS[frame.name]) && frame.argIndex === 0) return true; return !!TYPE_AFTER_AS_CALLS[frame.name] && frame.isAfterAs; }; function readingFrame(core, open, parent) { var before = core[open - 1]; var name = callNameAt(core, open); var isAggregate = false; var isWindow = false; if (isKw(before, 'OVER')) { isWindow = true; } else if (isKw(before, 'GROUP') && isKw(core[open - 2], 'WITHIN') && isPunctToken(core[open - 3], ')')) { var hasOver = overAfterCall(core, open - 3) !== -1; isWindow = hasOver; isAggregate = !hasOver; } else if (name) { var isOver = overAfterCall(core, matchParen(core, open)) !== -1; isWindow = isOver; isAggregate = !isOver && (!!GROUP_AGGREGATES[name] || !!OTHER_DIALECT_AGGREGATES[name]); }; var isInParent = !!parent && core[open].depth === parent.depth; return { open: open, depth: core[open].depth + 1, name: name, argIndex: 0, isAfterAs: false, isAggregate: (!!parent && parent.isAggregate) || isAggregate, isWindow: (!!parent && parent.isWindow) || isWindow, isNonColumn: !!parent && (parent.isNonColumn || (isInParent && isNonColumnPlace(parent))) }; }; function isNonColumnName(core, q, top) { var t = core[q]; var prev = core[q - 1]; var next = core[q + 1]; if (top && t.depth === top.depth) { if (top.isNonColumn || isNonColumnPlace(top)) return true; if (top.name === 'ISJSON' && top.argIndex === 1) return true; if (top.name === 'TRIM' && q === top.open + 1 && TRIM_WORDS[t.upper]) return true; if ((top.name === 'JSON_OBJECT' || top.name === 'JSON_ARRAY') && t.upper === 'ABSENT') return true; } else if (top && top.isNonColumn) { return true; }; if (isKw(prev, 'COLLATE')) return true; if (t.upper === 'ROW' && isKw(prev, 'CURRENT')) return true; if ((t.upper === 'IGNORE' || t.upper === 'RESPECT') && next && next.upper === 'NULLS') return true; if (t.upper === 'NULLS' && prev && (prev.upper === 'IGNORE' || prev.upper === 'RESPECT')) return true; if (t.upper === 'GROUPING' && next && next.upper === 'SETS') return true; return t.upper === 'NEXT' && !!next && next.upper === 'VALUE'; }; function isClauseTail(core, q, isOrderBy) { var t = core[q]; if (isKw(t, 'FOR') && core[q + 1] && ['JSON', 'XML', 'BROWSE'].indexOf(core[q + 1].upper) !== -1) return true; if (isKw(t, 'OPTION') && isPunctToken(core[q + 1], '(')) return true; if (!isBareName(t)) return false; if (t.upper === 'WINDOW' && isName(core[q + 1]) && isKw(core[q + 2], 'AS')) return true; return isOrderBy && t.upper === 'OFFSET'; }; function referenceChain(core, q, to, isTableName) { var parts = []; var j = q; while (j < to) { var t = core[j]; var isKeywordName = isUnreservedKeyword(t) && (j > q || !!isTableName || isPunctToken(core[j + 1], '.')); if (!(isName(t) || t.type === 'number' || isKeywordName)) break; parts.push(t); j++; if (!isPunctToken(core[j], '.')) break; j++; }; return { parts: parts, end: j }; }; function readOwnTokens(core, nesting, owner, selectIdx, from, to, depth, isOrderBy) { var reading = { from: from, to: to, refs: [], aggregates: [], commas: [], stoppedAt: -1 }; var frames = []; for (var q = from; q < to; q++) { var t = core[q]; if (owner[q] !== selectIdx) { if (isKw(t, 'SELECT') && nesting[q] && nesting[q].end > q + 1) q = nesting[q].end - 1; continue; }; if (t.depth === depth && !frames.length && isClauseTail(core, q, isOrderBy)) { reading.to = q; reading.stoppedAt = q; break; }; var top = frames.length ? frames[frames.length - 1] : null; if (t.type === 'punct') { if (t.value === '(') { frames.push(readingFrame(core, q, top)); } else if (t.value === ')') { frames.pop(); } else if (t.value === ',') { if (top && t.depth === top.depth) top.argIndex++; else if (!top && t.depth === depth) reading.commas.push(q); }; continue; }; if (isKw(t, 'AS') && top && t.depth === top.depth) { top.isAfterAs = true; continue; }; var isKeywordQualifier = isUnreservedKeyword(t) && isPunctToken(core[q + 1], '.'); if ((!isName(t) && !isKeywordQualifier) || isPunctToken(core[q - 1], '.') || (core[q - 1] && core[q - 1].value === ':' && core[q - 2] && core[q - 2].value === ':')) continue; if (!t.quote && GROUP_AGGREGATES[t.upper] && isPunctToken(core[q + 1], '(')) { if (overAfterCall(core, matchParen(core, q + 1)) === -1) { reading.aggregates.push({ idx: q, isNested: !!top && top.isAggregate }); }; continue; }; var chain = referenceChain(core, q, to); var isSkipped = isPunctToken(core[chain.end], '(') || isPunctToken(core[chain.end - 1], '.') || isNonColumnName(core, q, top) || chain.parts.some(function (p) { return (p.type !== 'identifier' && !isUnreservedKeyword(p)) || p.unterminated; }) || (chain.parts.length === 1 && quotedTextPlaceAt(core, q)); if (!isSkipped) { reading.refs.push({ first: q, end: chain.end, parts: chain.parts, isAggregated: !!top && top.isAggregate, isWindowed: !!top && top.isWindow }); }; q = Math.max(q, chain.end - 1); }; return reading; }; function collectScopeReadings(core, scopes, nesting) { var index = collectScopeIndex(core, scopes, nesting); var list = []; var bySelect = {}; var at = function (pos) { return tokenIndexAtOrAfter(core, pos); }; scopes.forEach(function (scope) { var idx = scope.selectIdx; var read = function (from, to, depth, isOrderBy) { return readOwnTokens(core, nesting, index.owner, idx, from, to, depth, isOrderBy); }; var clauses = scope.clauses; var reading = { scope: scope, place: nesting[idx] || null, isCorrelatable: canSeeOuterQuery(nesting, nesting[idx] || null), select: read(at(scope.selectList.start), at(scope.selectList.end), scope.depth, false), where: scope.where ? read(scope.where.start, scope.where.end, scope.depth, false) : null, groupBy: clauses.groupBy ? read(at(clauses.groupBy.start) + 2, at(clauses.groupBy.end), scope.depth, false) : null, having: scope.having ? read(scope.having.start, scope.having.end, scope.depth, false) : null, orderBy: clauses.orderBy ? read(at(clauses.orderBy.start) + 2, at(clauses.orderBy.end), scope.depth, true) : null, ons: scope.ons.map(function (on) { var onReading = read(on.start, on.end, on.depth, false); onReading.on = on; return onReading; }) }; var expressions = []; scope.selectList.items.forEach(function (item) { var span = selectItemExpression(core, item); if (span) { expressions.push({ item: item, from: span.from, to: span.to }); }; }); var e = 0; reading.select.refs = reading.select.refs.filter(function (ref) { while (e < expressions.length && expressions[e].to <= ref.first) e++; var expression = expressions[e]; if (!expression || expression.from > ref.first || ref.end > expression.to) return false; ref.item = expression.item; ref.expression = expression; return true; }); list.push(reading); bySelect[idx] = reading; }); return { index: index, list: list, bySelect: bySelect }; }; function refSpan(core, ref) { return { start: core[ref.first].start, end: core[ref.end - 1].end }; }; function entryBinds(f, lower) { if (f.alias) return String(f.alias).replace(/^\[|\]$/g, '').toLowerCase() === lower; if (!f.name) return false; var name = stripEnt(f.name).replace(/^\[|\]$/g, '').toLowerCase(); return name === lower || name.split('.').pop() === lower; }; function selectItemExpression(core, item) { if (item.kind === 'column' || item.kind === 'unnamed') { return { from: tokenIndexAtOrAfter(core, item.start), to: tokenIndexAtOrAfter(core, item.end) }; }; if (item.kind !== 'alias') return null; var list = aliasItemExpressionTokens(item); if (!list.length) return null; return { from: tokenIndexAtOrAfter(core, list[0].start), to: tokenIndexAtOrAfter(core, list[list.length - 1].end) }; }; function groupTokenKeys(core, from, to) { var keys = []; var at = []; for (var k = from; k < to; k++) { var t = core[k]; if (isName(t) && isPunctToken(core[k + 1], '.') && isName(core[k + 2]) && k + 2 < to) { k++; continue; }; if (t.type === 'identifier' || t.type === 'keyword') keys.push('W:' + t.upper); else if (t.type === 'operator' || t.type === 'punct') keys.push('P:' + t.value); else keys.push(t.type.charAt(0) + ':' + t.raw); at.push(k); }; while (keys.length >= 2 && keys[0] === 'P:(' && matchParen(core, at[0]) === at[at.length - 1]) { keys = keys.slice(1, -1); at = at.slice(1, -1); }; return { keys: keys, at: at }; }; function groupByElements(core, reading) { var leaves = []; var expand = function (from, to, isTop) { if (isKw(core[from], 'ALL')) from++; if (to - from >= 3 && isKw(core[to - 2], 'WITH') && core[to - 1] && (core[to - 1].upper === 'ROLLUP' || core[to - 1].upper === 'CUBE')) to -= 2; if (from >= to) return; var t = core[from]; var open = -1; if (isBareName(t) && (t.upper === 'ROLLUP' || t.upper === 'CUBE') && isPunctToken(core[from + 1], '(')) open = from + 1; else if (isBareName(t) && t.upper === 'GROUPING' && core[from + 1] && core[from + 1].upper === 'SETS' && isPunctToken(core[from + 2], '(')) open = from + 2; else if (isPunctToken(t, '(') && to - from === 2) return; else if (isPunctToken(t, '(') && callArguments(core, from).length > 1) open = from; if (open !== -1 && matchParen(core, open) === to - 1) { if (open + 1 === to - 1) return; callArguments(core, open).forEach(function (arg) { expand(arg.start, arg.end, false); }); return; }; leaves.push({ from: from, to: to, isTop: isTop }); }; var start = reading.from; reading.commas.concat([reading.to]).forEach(function (end) { expand(start, end, true); start = end + 1; }); return leaves; }; function clauseItems(reading) { var items = []; var start = reading.from; reading.commas.concat([reading.to]).forEach(function (end) { if (end > start) { items.push({ from: start, to: end }); }; start = end + 1; }); return items; }; function groupingOf(core, reading, known) { var grouping = { isGrouped: false, hasGroupBy: !!reading.groupBy, keys: [], names: {}, constants: [], aggregateIdx: -1 }; var ownOf = function (clause) { if (!reading.isCorrelatable) return clause.aggregates; return clause.aggregates.filter(function (agg) { var refs = refsWithin(clause.refs, agg.idx + 1, matchParen(core, agg.idx + 1)); return !refs.length || refs.some(function (ref) { return localBinding(core, reading, ref, known) === 'local'; }); }); }; var aggregates = ownOf(reading.select).concat(reading.having ? ownOf(reading.having) : []); if (aggregates.length) grouping.aggregateIdx = aggregates[0].idx; if (reading.groupBy) { reading.groupBy.refs.forEach(function (ref) { grouping.names[ref.parts[ref.parts.length - 1].upper] = true; }); groupByElements(core, reading.groupBy).forEach(function (el) { var keys = groupTokenKeys(core, el.from, el.to).keys; grouping.keys.push(keys.join(' ')); if (el.isTop && keys.length === 1 && /^[ns]:/.test(keys[0])) grouping.constants.push(el); }); }; grouping.isGrouped = grouping.hasGroupBy || !!reading.scope.having || aggregates.length > 0; return grouping; }; function isGroupedRef(core, grouping, ref, expression) { var own = groupTokenKeys(core, ref.first, ref.end).keys.join(' '); if (grouping.keys.indexOf(own) !== -1) return true; if (!expression) return false; var span = expression.keys || (expression.keys = groupTokenKeys(core, expression.from, expression.to)); if (!span.positionOf) { span.positionOf = {}; span.at.forEach(function (k, p) { span.positionOf[k] = p; }); }; var position = Object.prototype.hasOwnProperty.call(span.positionOf, ref.end - 1) ? span.positionOf[ref.end - 1] : -1; if (position === -1) return false; return grouping.keys.some(function (key) { var parts = key.split(' '); for (var w = Math.max(0, position - parts.length + 1); w <= position && w + parts.length <= span.keys.length; w++) { if (span.keys.slice(w, w + parts.length).join(' ') === key) return true; }; return false; }); }; function entryColumnNames(byName, f, ctes, cteNames) { var isNamed = function (names) { return !!names && names.length > 0 && names.every(function (n) { return typeof n === 'string' && n.length > 0; }); }; var certain = function (owner) { var names = owner.inner && owner.columns === owner.inner.selectColumnNames ? owner.inner.certainColumnNames : owner.columns; return isNamed(names) ? names : null; }; if (f.kind === 'table' && !f.isFunction && f.name) { var key = f.nameParts === 1 ? f.name.replace(/^\[|\]$/g, '').toUpperCase() : null; if (key && cteNames[key]) return ctes[key] ? certain(ctes[key]) : null; var table = byName ? fieldsTableOf(byName, f, cteNames) : null; return table ? table.fields.map(function (field) { return field.name; }) : null; }; if (f.kind === 'derived' && f.alias && f.inner && !f.inner.noSelect) return certain(f); return null; }; function scopeBinder(core, reading) { if (reading.binder) return reading.binder; var entries = tableEntriesOf(core, reading.scope.from); var exposed = {}; var named = {}; var add = function (map, key, k) { if (!key) return; var list = map[key] = Object.prototype.hasOwnProperty.call(map, key) ? map[key] : []; if (list[list.length - 1] !== k) list.push(k); }; var isMisread = false; entries.forEach(function (f, k) { var name = f.name ? stripEnt(f.name).replace(/^\[|\]$/g, '').toLowerCase() : ''; if (f.alias) { add(exposed, String(f.alias).replace(/^\[|\]$/g, '').toLowerCase(), k); add(named, name, k); add(named, name.split('.').pop(), k); } else { add(exposed, name, k); add(exposed, name.split('.').pop(), k); }; if (f.kind === 'derived' ? isMisreadEntry(f) : isBareName(core[tokenIndexAtOrAfter(core, f.end)])) isMisread = true; }); var known = null; function columnsOfEntries(knownColumns) { if (known) return known; known = { owners: {}, firstUnknown: entries.length }; entries.forEach(function (f, k) { var names = entryColumnNames(knownColumns.byName, f, knownColumns.ctes, knownColumns.cteNames); if (!names) { if (known.firstUnknown === entries.length) known.firstUnknown = k; return; }; names.forEach(function (n) { add(known.owners, String(n).toLowerCase(), k); }); }); return known; }; reading.binder = { entries: entries, starts: entries.map(function (f) { return f.start; }), exposed: exposed, named: named, isMisread: isMisread, columns: columnsOfEntries }; return reading.binder; }; function entriesBefore(binder, offset) { if (offset === Infinity) return binder.entries.length; var low = 0; var high = binder.starts.length; while (low < high) { var mid = (low + high) >> 1; if (binder.starts[mid] < offset) low = mid + 1; else high = mid; }; return low; }; function firstBound(map, lower, count) { var list = Object.prototype.hasOwnProperty.call(map, lower) ? map[lower] : null; return list && list[0] < count ? list[0] : -1; }; function refsWithin(refs, from, to) { var low = 0; var high = refs.length; while (low < high) { var mid = (low + high) >> 1; if (refs[mid].first < from) low = mid + 1; else high = mid; }; var found = []; for (var k = low; k < refs.length && refs[k].first < to; k++) { if (refs[k].end <= to) found.push(refs[k]); }; return found; }; function isSelfComparison(core, refs, ref, alias, qualifier) { var column = ref.parts[ref.parts.length - 1].value.toLowerCase(); var isComparison = function (t) { return !!t && t.type === 'operator' && DATE_COMPARISON_OPERATORS.indexOf(t.value) !== -1; }; var isSame = function (other) { if (!other || other.parts.length > 2 || other.parts[other.parts.length - 1].value.toLowerCase() !== column) return false; if (other.parts.length === 1) return true; var name = other.parts[0].value.replace(/^\[|\]$/g, '').toLowerCase(); return name === alias || name === qualifier; }; if (isComparison(core[ref.end]) && startsComparison(core[ref.first - 1])) { var after = refsWithin(refs, ref.end + 1, ref.end + 8)[0]; if (after && after.first === ref.end + 1 && endsComparison(core[after.end]) && isSame(after)) return true; }; if (isComparison(core[ref.first - 1]) && endsComparison(core[ref.end])) { var k = refs.indexOf(ref); var before = k > 0 ? refs[k - 1] : null; if (before && before.end === ref.first - 1 && startsComparison(core[before.first - 1]) && isSame(before)) return true; }; var refDepth = core[ref.first].depth; return refs.some(function (other) { if (other === ref || !isSame(other)) return false; var depth = Math.min(refDepth, core[other.first].depth); var from = Math.min(ref.end, other.end); var to = Math.max(ref.first, other.first); for (var j = from; j < to; j++) { var t = core[j]; if (t.depth <= depth && (isKw(t, 'OR') || (isKw(t, 'AND') && !isBetweenAnd(core, j)))) return false; }; return true; }); }; function placeKindOf(place) { if (!place) return 'top'; return place.kind === 'derived' && isMisreadEntry(place.entry) ? 'subquery' : place.kind; }; function isSetOperationOrder(core, reading) { var place = reading.place; if (!place || place.head === reading.scope.selectIdx) return false; var operandDepth = place.open !== -1 && core[place.open] ? core[place.open].depth + 1 : 0; return core[reading.scope.selectIdx].depth === operandDepth; }; function canSeeOuterQuery(nesting, place) { while (place && place.parent !== -1) { var kind = placeKindOf(place); if (kind === 'subquery' || kind === 'apply') return true; if (kind !== 'derived') return false; place = nesting[place.parent]; }; return false; }; function localBinding(core, reading, ref, known) { if (ref.parts.length > 2) return null; var binder = scopeBinder(core, reading); if (ref.parts.length === 2) return firstBound(binder.exposed, ref.parts[0].value.toLowerCase(), binder.entries.length) !== -1 ? 'local' : 'outer'; if (!reading.isCorrelatable) return 'local'; var columns = binder.columns(known); if (firstBound(columns.owners, ref.parts[0].value.toLowerCase(), binder.entries.length) !== -1) return 'local'; return columns.firstUnknown === binder.entries.length && binder.entries.length ? 'outer' : null; }; function knownColumnsOf(ctx) { var parsed = ctx.getParsed(); var cteNames = {}; ((parsed && parsed.ctes) || []).forEach(function (c) { cteNames[String(c.name).toUpperCase()] = true; }); return { byName: ctx.schema && ctx.schema.byName ? ctx.schema.byName : null, ctes: parsed && parsed._ctx ? parsed._ctx.ctes : {}, cteNames: cteNames }; }; function groupByFixes(sql, core, reading, text) { var clauses = reading.scope.clauses; if (clauses.groupBy) { var groupEnd = clauses.groupBy.end; var last = core[tokenIndexAtOrAfter(core, groupEnd) - 1]; if (last && (last.upper === 'ROLLUP' || last.upper === 'CUBE') && isKw(core[tokenIndexAtOrAfter(core, groupEnd) - 2], 'WITH')) return []; if (!last || isKw(last, 'GROUP') || isKw(last, 'BY')) return []; return [{ title: 'Add to GROUP BY', edits: [{ start: groupEnd, end: groupEnd, text: (isPunctToken(last, ',') ? ' ' : ', ') + text }], isSafe: false, isInFixAll: false }]; }; var endIdx = reading.place ? reading.place.end : core.length; var isTailed = clauses.forClause || [reading.select, reading.where, reading.having].some(function (r) { return !!r && r.stoppedAt !== -1; }) || (isPunctToken(core[endIdx - 1], ')') && isKw(core[parenOpenOf(core, endIdx - 1) - 1], 'OPTION')); if (isTailed) return []; var stops = [endIdx < core.length ? core[endIdx].start : sql.length]; if (clauses.having) stops.push(clauses.having.start); if (clauses.orderBy) stops.push(clauses.orderBy.start); var insertAt = Math.min.apply(Math, stops); var before = tokenIndexAtOrAfter(core, insertAt) - 1; if (before >= 0 && core[before].end <= insertAt) insertAt = core[before].end; return [{ title: 'Add GROUP BY ' + text, edits: [{ start: insertAt, end: insertAt, text: ' GROUP BY ' + text }], isSafe: false, isInFixAll: false }]; }; function branchSelectIndex(core, charStart) { var k = tokenIndexAtOrAfter(core, charStart); while (core[k] && ((core[k].type === 'keyword' && (SET_OPERATOR_WORDS[core[k].upper] || core[k].upper === 'ALL')) || isPunctToken(core[k], '('))) k++; return isKw(core[k], 'SELECT') ? k : -1; }; function selectWidth(items) { var width = 0; for (var i = 0; i < items.length; i++) { if (items[i].kind !== 'star') { width++; } else if (items[i]._certainColumns) { width += items[i]._certainColumns.length; } else { return null; }; }; return width; }; function laterBranches(core, parsed, readings) { var list = []; (parsed.clauses.union || []).slice(1).forEach(function (branch) { (branch.parts && branch.parts.length ? branch.parts : [branch]).forEach(function (part, k) { var reading = readings.bySelect[branchSelectIndex(core, part.start)] || null; var hasStar = !!part.hasStar || (!!reading && reading.scope.selectList.items.some(function (item) { return item.kind === 'star'; })); list.push({ span: k === 0 ? branch : part, count: hasStar ? (reading ? selectWidth(reading.scope.selectList.items) : null) : part.itemCount, hasStar: hasStar, reading: reading }); }); }); return list; }; function branchSpanOf(core, reading) { var k = reading.scope.selectIdx - 1; while (k > 0 && (isPunctToken(core[k], '(') || isKw(core[k], 'ALL'))) k--; var first = core[k] && core[k].type === 'keyword' && SET_OPERATOR_WORDS[core[k].upper] ? k : reading.scope.selectIdx; var last = reading.place && reading.place.end > first ? reading.place.end - 1 : first; return { start: core[first].start, end: core[last].end }; }; function columnListOwners(core, readings) { var owners = []; readings.list.forEach(function (reading) { var place = reading.place; if (!place || place.head !== reading.scope.selectIdx || place.open === -1 || reading.scope.clauses.forClause) return; var label = null; if ((place.kind === 'derived' || place.kind === 'apply') && place.entry && place.entry.alias) { var k = matchParen(core, place.open) + 1; if (isKw(core[k], 'AS')) k++; if (isPunctToken(core[k + 1], '(')) return; label = place.entry.alias; } else if (place.kind === 'cte' && isName(core[place.open - 2])) { label = core[place.open - 2].value; }; if (label) { owners.push({ reading: reading, label: label }); }; }); return owners; }; function parenOpenOf(core, close) { var matches = parenMatchesOf(core); for (var k = close - 1; k >= 0; k--) { if (matches[k] === close) return k; if (core[k].depth < core[close].depth) return -1; }; return -1; }; function extractSimpleComparison(tokens, start, end) { var leftChain = parseNameChain(tokens, start, end); if (leftChain.parts.length < 2 || leftChain.end >= end) return null; var opTok = tokens[leftChain.end]; if (!opTok || opTok.type !== 'operator' || (opTok.value !== '=' && opTok.value !== '<>' && opTok.value !== '!=')) return null; var rightChain = parseNameChain(tokens, leftChain.end + 1, end); if (rightChain.parts.length < 2 || rightChain.end !== end) return null; return { left: { alias: leftChain.parts[leftChain.parts.length - 2].value, column: leftChain.parts[leftChain.parts.length - 1].value, start: leftChain.parts[0].start, end: leftChain.parts[leftChain.parts.length - 1].end }, right: { alias: rightChain.parts[rightChain.parts.length - 2].value, column: rightChain.parts[rightChain.parts.length - 1].value, start: rightChain.parts[0].start, end: rightChain.parts[rightChain.parts.length - 1].end }, opToken: opTok }; }; function collectSimpleComparisons(tokens, start, end, baseDepth) { var found = []; formatterSplitAndOr(tokens, start, end, baseDepth).forEach(function (seg) { var first = tokens[seg.start]; if (seg.end - seg.start >= 2 && first.type === 'punct' && first.value === '(' && matchParen(tokens, seg.start) === seg.end - 1) { found = found.concat(collectSimpleComparisons(tokens, seg.start + 1, seg.end - 1, baseDepth + 1)); return; }; var cmp = extractSimpleComparison(tokens, seg.start, seg.end); if (cmp) found.push(cmp); }); return found; }; function collectColumnComparisons(tokens, start, end, baseDepth) { var found = []; var sideOf = function (chain) { var parts = chain.parts; return { alias: parts.length > 1 ? parts[parts.length - 2].value : null, column: parts[parts.length - 1].value, start: parts[0].start, end: parts[parts.length - 1].end }; }; formatterSplitAndOr(tokens, start, end, baseDepth).forEach(function (seg) { var first = tokens[seg.start]; if (seg.end - seg.start >= 2 && first.type === 'punct' && first.value === '(' && matchParen(tokens, seg.start) === seg.end - 1) { found = found.concat(collectColumnComparisons(tokens, seg.start + 1, seg.end - 1, baseDepth + 1)); return; }; var left = parseNameChain(tokens, seg.start, seg.end); if (!left.parts.length || left.end >= seg.end || !left.parts.every(isName)) return; var op = tokens[left.end]; if (!op || op.type !== 'operator' || (op.value !== '=' && op.value !== '<>' && op.value !== '!=')) return; var right = parseNameChain(tokens, left.end + 1, seg.end); if (!right.parts.length || right.end !== seg.end || !right.parts.every(isName)) return; found.push({ left: sideOf(left), right: sideOf(right), opToken: op }); }); return found; }; function appendConditionEdits(tokens, start, end, baseDepth, conditionText) { var endPos = tokens[end - 1].end; var hasOr = formatterSplitAndOr(tokens, start, end, baseDepth).some(function (seg) { return seg.connector === 'OR'; }); if (!hasOr) return [{ start: endPos, end: endPos, text: ' AND ' + conditionText }]; return [ { start: tokens[start].start, end: tokens[start].start, text: '(' }, { start: endPos, end: endPos, text: ') AND ' + conditionText } ]; }; var FORMAT_LAYOUT_CHARS = 100000; function formatLaidOutEdits(sql, edits, ctx) { if (!ctx || !ctx.isFormatLaidOut || !ctx.isFormatLaidOut()) return edits; try { var fixed = applyEdits(sql, edits); if (ctx.formatLayoutChars + fixed.length > FORMAT_LAYOUT_CHARS) return edits; ctx.formatLayoutChars += fixed.length; var lead = /^\s*/.exec(fixed)[0]; var tail = /\s*$/.exec(fixed.slice(lead.length))[0]; var body = fixed.slice(lead.length, fixed.length - tail.length); var laidOut = formatSql(body); if (laidOut.reason || laidOut.skipped || laidOut.sql === body) return edits; var relaid = lead + laidOut.sql + tail; var wordsOf = function (text) { return tokenize(text).filter(function (t) { return t.type !== 'whitespace'; }); }; var before = wordsOf(sql); var after = wordsOf(relaid); var sorted = edits.slice().sort(function (a, b) { return a.start - b.start; }); var expected = []; var e = 0; var expect = function (raw, at) { expected.push({ raw: raw, at: at }); }; for (var i = 0; i <= before.length; i++) { var t = before[i]; while (e < sorted.length && (!t || sorted[e].start <= t.start)) { wordsOf(sorted[e].text).forEach(function (w) { expect(w.raw, -1); }); e++; }; if (!t) break; var isCut = sorted.some(function (x) { return x.start < t.end && x.end > t.start; }); if (!isCut) expect(t.raw, i); }; if (expected.length !== after.length || expected.some(function (x, k) { return x.raw !== after[k].raw; })) return edits; var anchors = [[-1, -1]]; expected.forEach(function (x, k) { if (x.at !== -1) anchors.push([x.at, k]); }); anchors.push([before.length, after.length]); var out = []; for (var n = 1; n < anchors.length; n++) { var p = anchors[n - 1]; var q = anchors[n]; var from = p[0] === -1 ? 0 : before[p[0]].end; var to = q[0] === before.length ? sql.length : before[q[0]].start; var text = relaid.slice(p[1] === -1 ? 0 : after[p[1]].end, q[1] === after.length ? relaid.length : after[q[1]].start); if (sql.slice(from, to) !== text) { out.push({ start: from, end: to, text: text }); }; }; return applyEdits(sql, out) === relaid ? out : edits; } catch (layoutError) { return edits; }; }; var DATE_COMPARISON_OPERATORS = ['=', '<>', '!=', '<', '>', '<=', '>=', '!<', '!>']; var SERVER_TIME_CAST_TYPES = ['DATE', 'DATETIME', 'DATETIME2', 'SMALLDATETIME']; var COMPARISON_START_KEYWORDS = ['WHERE', 'AND', 'OR', 'NOT', 'ON', 'WHEN', 'HAVING']; var COMPARISON_END_KEYWORDS = ['AND', 'OR', 'THEN', 'WHERE', 'GROUP', 'ORDER', 'HAVING', 'UNION', 'EXCEPT', 'INTERSECT', 'FOR', 'OPTION', 'INNER', 'LEFT', 'RIGHT', 'FULL', 'CROSS', 'OUTER', 'JOIN']; function isPunctToken(t, value) { return !!t && t.type === 'punct' && t.value === value; }; var ORDER_BY_ITEM_END_KEYWORDS = ['ASC', 'DESC', 'OFFSET', 'OPTION', 'FOR', 'UNION', 'EXCEPT', 'INTERSECT']; var ORDER_BY_LIST_STOP_KEYWORDS = ['SELECT', 'FROM', 'WHERE', 'GROUP', 'HAVING', 'ON', 'WHEN', 'THEN', 'ELSE', 'AND', 'OR']; function isOrderByItemWord(core, k) { var t = core[k]; if (!t || OPERAND_END_KEYWORDS.indexOf(t.upper) !== -1) return false; var next = core[k + 1]; var isEnd = !next || isPunctToken(next, ',') || isPunctToken(next, ')') || isPunctToken(next, ';') || (next.type === 'keyword' && ORDER_BY_ITEM_END_KEYWORDS.indexOf(next.upper) !== -1); if (!isEnd) return false; var before = core[k - 1]; if (isKw(before, 'BY')) return isKw(core[k - 2], 'ORDER'); if (!isPunctToken(before, ',')) return false; for (var j = k - 2; j >= 0; j--) { var p = core[j]; if (p.depth > t.depth) continue; if (p.depth < t.depth) return false; if (isKw(p, 'BY')) return isKw(core[j - 1], 'ORDER'); if (p.type === 'keyword' && ORDER_BY_LIST_STOP_KEYWORDS.indexOf(p.upper) !== -1) return false; }; return false; }; function callNameAt(core, openIdx) { var t = core[openIdx - 1]; if (!t || t.quote) return ''; if (t.type === 'identifier' || isKwAny(t, ['CONVERT', 'TRY_CONVERT'])) return t.upper; return ''; }; function callArguments(core, openIdx) { var close = matchParen(core, openIdx); var depth = core[openIdx].depth + 1; var args = []; var start = openIdx + 1; for (var i = openIdx + 1; i < close; i++) { if (core[i].depth === depth && isPunctToken(core[i], ',')) { args.push({ start: start, end: i }); start = i + 1; }; }; args.push({ start: start, end: close }); return args; }; function isServerTimeCastType(core, start, end) { var t = core[start]; if (!t || t.quote || (t.type !== 'identifier' && t.type !== 'keyword') || SERVER_TIME_CAST_TYPES.indexOf(t.upper) === -1) return false; return end === start + 1 || (isPunctToken(core[start + 1], '(') && matchParen(core, start + 1) === end - 1); }; function castOperandEnd(core, openIdx, close) { for (var k = openIdx + 1; k < close; k++) { if (core[k].depth === core[openIdx].depth + 1 && isKw(core[k], 'AS')) return k; }; return -1; }; function numericFactorEnd(core, i) { var t = core[i]; if (t && t.type === 'operator' && (t.value === '-' || t.value === '+')) return numericFactorEnd(core, i + 1); if (!t) return -1; if (t.type === 'number') return i + 1; if (!isPunctToken(t, '(')) return -1; var close = matchParen(core, i); return isPunctToken(core[close], ')') && numericExpressionEnd(core, i + 1) === close ? close + 1 : -1; }; function numericTermEnd(core, i) { var end = numericFactorEnd(core, i); while (end !== -1 && core[end] && core[end].type === 'operator' && (core[end].value === '*' || core[end].value === '/')) { var next = numericFactorEnd(core, end + 1); if (next === -1) return -1; end = next; }; return end; }; function numericExpressionEnd(core, i) { var end = numericTermEnd(core, i); while (end !== -1 && core[end] && core[end].type === 'operator' && (core[end].value === '+' || core[end].value === '-')) { var next = numericTermEnd(core, end + 1); if (next === -1) return end; end = next; }; return end; }; function dateArithmeticSpan(core, start, end) { var plus = core[start - 1]; var number = core[start - 2]; if (plus && plus.type === 'operator' && plus.value === '+' && number && number.type === 'number') { var before = core[start - 3]; var isOperand = !!before && (before.type === 'number' || before.type === 'identifier' || before.type === 'string' || isPunctToken(before, ')') || (before.type === 'operator' && ['+', '-', '*', '/', '%'].indexOf(before.value) !== -1)); if (!isOperand) start -= 2; }; while (core[end] && core[end].type === 'operator' && (core[end].value === '+' || core[end].value === '-')) { var termEnd = numericTermEnd(core, end + 1); if (termEnd === -1) break; end = termEnd; }; return { start: start, end: end }; }; function serverTimeSpan(core, start, end) { var moved = dateArithmeticSpan(core, start, end); start = moved.start; end = moved.end; while (start > 0) { var open = findEnclosingOpenParen(core, start); if (open === -1) break; var close = matchParen(core, open); if (!isPunctToken(core[close], ')') || close < end) break; var name = callNameAt(core, open); var args = callArguments(core, open); var index = -1; args.forEach(function (arg, k) { if (arg.start === start && arg.end === end) index = k; }); var isKept = false; if (!name) isKept = index === 0 && args.length === 1; else if (name === 'DATEADD') isKept = index === 2 && args.length === 3; else if (name === 'DATETRUNC') isKept = index === 1 && args.length === 2; else if (name === 'EOMONTH') isKept = index === 0; else if (name === 'CONVERT' || name === 'TRY_CONVERT') isKept = index === 1 && isServerTimeCastType(core, args[0].start, args[0].end); else if (name === 'CAST' || name === 'TRY_CAST') isKept = start === open + 1 && castOperandEnd(core, open, close) === end && isServerTimeCastType(core, end + 1, close); if (!isKept) { return { start: start, end: end, call: name ? { name: name, args: args, index: index } : null }; }; start = name ? open - 1 : open; end = close + 1; moved = dateArithmeticSpan(core, start, end); start = moved.start; end = moved.end; }; return { start: start, end: end, call: null }; }; function operandBefore(core, endIdx) { var last = core[endIdx - 1]; if (isPunctToken(last, ')')) { var open = findEnclosingOpenParen(core, endIdx - 1); return open > 0 ? { start: open - 1, end: endIdx } : null; }; if (!isName(last)) return null; var start = endIdx - 1; while (start >= 2 && isPunctToken(core[start - 1], '.') && isName(core[start - 2])) start -= 2; return { start: start, end: endIdx }; }; function operandAfter(core, startIdx) { var t = core[startIdx]; if (!t) return null; if (isPunctToken(core[startIdx + 1], '(') && (isName(t) || t.type === 'keyword')) { var close = matchParen(core, startIdx + 1); return isPunctToken(core[close], ')') ? { start: startIdx, end: close + 1 } : null; }; if (!isName(t)) return null; var chain = parseNameChain(core, startIdx, core.length); if (isPunctToken(core[chain.end - 1], '.') || isPunctToken(core[chain.end], '(')) return null; return { start: startIdx, end: chain.end }; }; function startsComparison(t) { return !!t && (isPunctToken(t, '(') || isPunctToken(t, ',') || isKwAny(t, COMPARISON_START_KEYWORDS)); }; function endsComparison(t) { return !t || isPunctToken(t, ')') || isPunctToken(t, ',') || isPunctToken(t, ';') || isKwAny(t, COMPARISON_END_KEYWORDS); }; function isServerDateExpression(core, start, end, resolveField) { var open = start + 1; if (end - start >= 3 && isPunctToken(core[open], '(') && matchParen(core, open) === end - 1) { var name = callNameAt(core, open); var args = callArguments(core, open); if (name === 'MIN' || name === 'MAX') return args.length === 1 && isServerDateExpression(core, args[0].start, args[0].end, resolveField); if (name === 'CONVERT' || name === 'TRY_CONVERT') { return args.length >= 2 && isServerTimeCastType(core, args[0].start, args[0].end) && isServerDateExpression(core, args[1].start, args[1].end, resolveField); }; if (name === 'CAST' || name === 'TRY_CAST') { var asIdx = castOperandEnd(core, open, end - 1); return asIdx > open + 1 && isServerTimeCastType(core, asIdx + 1, end - 1) && isServerDateExpression(core, open + 1, asIdx, resolveField); }; return false; }; var chain = parseNameChain(core, start, end); if (chain.end !== end || !chain.parts.length || !chain.parts.every(isName)) return false; var parts = chain.parts; return !!resolveField(parts.length > 1 ? parts[parts.length - 2].value : null, parts[parts.length - 1].value); }; function betweenTestedIsServerDate(core, betweenIdx, resolveField) { var end = isKw(core[betweenIdx - 1], 'NOT') ? betweenIdx - 1 : betweenIdx; var tested = operandBefore(core, end); return !!tested && startsComparison(core[tested.start - 1]) && isServerDateExpression(core, tested.start, tested.end, resolveField); }; function betweenOfAnd(core, andIdx) { var depth = core[andIdx].depth; for (var k = andIdx - 1; k >= 0; k--) { var t = core[k]; if (t.depth > depth) continue; if (t.depth < depth || isPunctToken(t, ',') || isKwAny(t, COMPARISON_START_KEYWORDS)) return -1; if (t.type === 'operator' && DATE_COMPARISON_OPERATORS.indexOf(t.value) !== -1) return -1; if (isKw(t, 'BETWEEN')) return k; }; return -1; }; function isComparedWithServerDate(core, span, resolveField) { var a = span.start; var b = span.end; if (span.call) { if ((span.call.name !== 'DATEDIFF' && span.call.name !== 'DATEDIFF_BIG') || span.call.args.length !== 3 || (span.call.index !== 1 && span.call.index !== 2)) return false; var other = span.call.args[3 - span.call.index]; return isServerDateExpression(core, other.start, other.end, resolveField); }; var before = core[a - 1]; var after = core[b]; if (before && before.type === 'operator' && DATE_COMPARISON_OPERATORS.indexOf(before.value) !== -1 && endsComparison(after)) { var left = operandBefore(core, a - 1); return !!left && startsComparison(core[left.start - 1]) && isServerDateExpression(core, left.start, left.end, resolveField); }; if (after && after.type === 'operator' && DATE_COMPARISON_OPERATORS.indexOf(after.value) !== -1 && startsComparison(before)) { var right = operandAfter(core, b + 1); return !!right && endsComparison(core[right.end]) && isServerDateExpression(core, right.start, right.end, resolveField); }; if (isKw(before, 'BETWEEN') && isKw(after, 'AND')) return betweenTestedIsServerDate(core, a - 1, resolveField); if (isKw(before, 'AND') && endsComparison(after)) { var betweenIdx = betweenOfAnd(core, a - 1); return betweenIdx !== -1 && betweenTestedIsServerDate(core, betweenIdx, resolveField); }; return false; }; var selectLevelMemo = { core: null, nodes: null }; function selectLevelNodes(core) { if (selectLevelMemo.core === core) return selectLevelMemo.nodes; var nodes = new Array(core.length); var levels = [{ selectIdx: -1, outer: null }]; for (var j = 0; j < core.length; j++) { var depth = core[j].depth || 0; while (levels.length - 1 < depth) { levels.push({ selectIdx: -1, outer: levels[levels.length - 1] }); }; if (levels.length - 1 > depth) levels.length = depth + 1; if (isKw(core[j], 'SELECT')) { levels[depth] = { selectIdx: j, outer: levels[depth].outer }; }; nodes[j] = levels[depth]; }; selectLevelMemo.core = core; selectLevelMemo.nodes = nodes; return nodes; }; function enclosingSelectScopes(core, idx, scopeBySelect) { var chain = []; for (var node = selectLevelNodes(core)[idx]; node; node = node.outer) { if (node.selectIdx !== -1 && scopeBySelect[node.selectIdx]) chain.push(scopeBySelect[node.selectIdx]); }; return chain; }; function serverTimeField(table, column) { if (!table || table.kind !== 'dataview' || !Array.isArray(table.fields)) return null; var field = table.fields.filter(function (f) { return f.name && f.name.toLowerCase() === String(column).toLowerCase(); })[0]; return field && field.type === 'Date' && !field.timeZone ? field : null; }; function serverTimeFieldResolver(byName, scopes, ctes) { var env = { ctx: { ctes: ctes || {} }, byName: byName, active: [], cache: [], record: null }; return function (qualifier, column) { if (qualifier) { var upper = String(qualifier).toUpperCase(); for (var s = 0; s < scopes.length; s++) { var entry = scopes[s].from.filter(function (f) { return entryReference(f).toUpperCase() === upper; })[0]; if (entry) return serverTimeFieldOfEntry(entry, column, env, 0); }; return null; }; if (!scopes.length || !scopes[0].from.length) return null; var owners = []; var isAllKnown = scopes[0].from.every(function (f) { var inner = runInnerOfEntry(f, env); if (inner) { if (!inner.names) return false; if (inner.names.some(function (n) { return n !== null && n !== undefined && String(n).toLowerCase() === String(column).toLowerCase(); })) owners.push(f); return true; }; var table = fieldCheckTable(byName, f); if (!table || !Array.isArray(table.fields)) return false; if (table.fields.some(function (x) { return x.name && x.name.toLowerCase() === String(column).toLowerCase(); })) owners.push(f); return true; }); return isAllKnown && owners.length === 1 ? serverTimeFieldOfEntry(owners[0], column, env, 0) : null; }; }; function serverTimeFieldOfEntry(entry, column, env, depth) { if (!entry || depth > TYPING_MAX_NESTING) return null; var inner = runInnerOfEntry(entry, env); if (!inner) return serverTimeField(fieldCheckTable(env.byName, entry), column); if (!inner.names || !runIsReadable(inner.sel) || inner.names.length !== inner.sel.selectList.items.length) return null; var wanted = String(column).toLowerCase(); var places = []; inner.names.forEach(function (n, k) { if (n !== null && n !== undefined && String(n).toLowerCase() === wanted) places.push(k); }); if (places.length !== 1) return null; var item = inner.sel.selectList.items[places[0]]; var source = null; if ((item.kind === 'column' || item.kind === 'alias') && item.sourceColumn) { source = { sourceTable: item.sourceTable, sourceColumn: item.sourceColumn }; } else if (item.kind === 'alias') { var call = passThroughCall(aliasItemExpressionTokens(item)); source = call && call.args.length < 3 ? plainColumnSource(call.args[0]) : null; }; if (!source) return null; var from = inner.sel.from || []; if (source.sourceTable) return serverTimeFieldOfEntry(findFromEntry(from, source.sourceTable), source.sourceColumn, env, depth + 1); var name = String(source.sourceColumn).toLowerCase(); var owners = []; var isAllKnown = from.every(function (f) { var deeper = runInnerOfEntry(f, env); if (deeper) { if (!deeper.names) return false; if (deeper.names.some(function (n) { return n !== null && n !== undefined && String(n).toLowerCase() === name; })) owners.push(f); return true; }; var table = fieldCheckTable(env.byName, f); if (!table || !Array.isArray(table.fields)) return false; if (findFieldNamed(table, source.sourceColumn)) owners.push(f); return true; }); return isAllKnown && owners.length === 1 ? serverTimeFieldOfEntry(owners[0], source.sourceColumn, env, depth + 1) : null; }; function sendLogViewName(f) { if (!f || f.kind !== 'table' || !f.name || f.isFunction) return ''; var clean = stripEnt(f.name).replace(/^\[|\]$/g, ''); return TRACKING_VIEWS.some(function (v) { return v.toUpperCase() === clean.toUpperCase(); }) ? clean : ''; }; function onConditionEnd(tokens, onIdx) { var depth = tokens[onIdx].depth; var i = onIdx + 1; while (i < tokens.length && tokens[i].depth >= depth) { if (tokens[i].depth === depth && formatterFindClauseBoundary(tokens, i, i + 1, depth) === i) return i; i++; }; return i; }; function collectSendLogJoins(core, scopes) { var joins = []; scopes.forEach(function (scope) { scope.ons.forEach(function (on) { if (on.end <= on.start) return; var joined = on.entries[on.entries.length - 1]; var joinedName = sendLogViewName(joined); if (on.entries.length < 2 || !joinedName) return; var found = sendLogJoinAnchor(core, on.start, on.end, on.depth, joined.alias || joinedName, joinedName, on.entries.slice(0, -1)); if (!found.isLinked) return; joins.push({ onIdx: on.onIdx, start: on.start, end: on.end, depth: on.depth, joined: joined, joinedName: joinedName, joinedRef: joined.alias || joinedName, best: found.best }); }); }); return joins.sort(function (a, b) { return a.onIdx - b.onIdx; }); }; function unlinkedTrackingView(ctx) { var parsed = ctx.getParsed(); if (!parsed) return null; var core = ctx.getCoreTokens(); var onJoinedStarts = {}; ctx.getSendLogJoins().forEach(function (join) { onJoinedStarts[join.joined.start] = true; }); var trackingEntries = parsed.from.filter(function (f) { if (!f.name || onJoinedStarts[f.start]) return false; var clean = stripEnt(f.name).replace(/^\[|\]$/g, ''); return TRACKING_VIEWS.indexOf(clean) !== -1; }); if (trackingEntries.length < 2) return null; var scope = ctx.getSelectScopes().filter(function (s) { return s.selectList.start === parsed.selectList.start; })[0]; if (!scope) return null; var links = jobIdLinkedViews(core, scope); var unlinked = trackingEntries.filter(function (f) { return !links.isLinked[f.start]; }); if (!unlinked.length) return null; return { entry: unlinked[unlinked.length - 1], isThroughOther: trackingEntries.every(function (f) { return links.isJoinedToOther[f.start] && !links.isComparedWithView[f.start]; }) }; }; function sendLogJoinAnchor(core, start, end, depth, joinedRef, joinedName, anchors) { var comparisons = collectSimpleComparisons(core, start, end, depth).filter(function (cmp) { return cmp.opToken.value === '='; }); var isLinked = false; var best = null; anchors.forEach(function (anchor) { var anchorName = sendLogViewName(anchor); if (!anchorName) return; var anchorRef = anchor.alias || anchorName; var pair = [joinedRef.toUpperCase(), anchorRef.toUpperCase()]; var isPairLinked = false; var matched = {}; comparisons.forEach(function (cmp) { var left = cmp.left.alias.toUpperCase(); var right = cmp.right.alias.toUpperCase(); if (!((left === pair[0] && right === pair[1]) || (left === pair[1] && right === pair[0]))) return; isPairLinked = true; var column = cmp.left.column.toUpperCase(); if (column !== cmp.right.column.toUpperCase()) return; SEND_LOG_KEYS.forEach(function (key) { if (column === key.toUpperCase() || (key === 'SubscriberID' && column === 'SUBSCRIBERKEY')) matched[key] = true; }); }); if (!isPairLinked) return; isLinked = true; if (anchorName.toUpperCase() === joinedName.toUpperCase()) return; var count = SEND_LOG_KEYS.filter(function (key) { return matched[key]; }).length; if (!best || count > best.count) best = { anchorName: anchorName, anchorRef: anchorRef, matched: matched, count: count }; }); return { isLinked: isLinked, best: best }; }; function jobIdLinkedViews(core, scope) { var parent = {}; var find = function (key) { while (parent[key] !== undefined && parent[key] !== key) key = parent[key]; return key; }; var join = function (a, b) { var rootA = find(a); var rootB = find(b); if (rootA !== rootB) parent[rootA] = rootB; }; var result = { isLinked: {}, isComparedWithView: {}, isJoinedToOther: {} }; var spans = scope.ons.map(function (on) { return { start: on.start, end: on.end, depth: on.depth }; }); if (scope.where) { spans.push({ start: scope.where.start, end: scope.where.end, depth: scope.depth }); }; var sideOf = function (chain) { var parts = chain.parts; return { entry: findFromEntry(scope.from, parts[parts.length - 2].value), isJobId: parts[parts.length - 1].upper === 'JOBID' }; }; spans.forEach(function (span) { conditionAtoms(core, span.start, span.end, span.depth).forEach(function (atom) { var cmp = columnComparison(core, atom.start, atom.end); if (cmp) { var left = sideOf(cmp.left); var right = sideOf(cmp.right); if (!left.entry || !right.entry || left.entry === right.entry) return; var isLeftView = !!sendLogViewName(left.entry); var isRightView = !!sendLogViewName(right.entry); if (isLeftView && isRightView) { result.isComparedWithView[left.entry.start] = true; result.isComparedWithView[right.entry.start] = true; } else if (isLeftView || isRightView) { result.isJoinedToOther[(isLeftView ? left : right).entry.start] = true; }; if (cmp.operator === '=' && left.isJobId && right.isJobId) join('e' + left.entry.start, 'e' + right.entry.start); return; }; var isValue = function (t) { return !!t && (t.type === 'number' || t.type === 'string'); }; var isEquals = function (t) { return !!t && t.type === 'operator' && t.value === '='; }; var isValueFirst = isValue(core[atom.start]) && isEquals(core[atom.start + 1]); var chain = parseNameChain(core, isValueFirst ? atom.start + 2 : atom.start, atom.end); if (chain.parts.length < 2 || !chain.parts.every(isName)) return; var valueTok = isValueFirst ? core[atom.start] : core[chain.end + 1]; var isWhole = isValueFirst ? chain.end === atom.end : (isEquals(core[chain.end]) && chain.end + 2 === atom.end); if (!isWhole || !isValue(valueTok)) return; var side = sideOf(chain); if (side.entry && side.isJobId) join('e' + side.entry.start, 'v' + valueTok.value); }); }); var views = scope.from.filter(function (f) { return !!sendLogViewName(f); }); views.forEach(function (view) { var root = find('e' + view.start); result.isLinked[view.start] = views.some(function (other) { return other !== view && find('e' + other.start) === root; }); }); return result; }; var TRACKING_FILTER_COLUMNS = { EVENTDATE: true, JOBID: true }; function joinKindOf(core, entry) { var idx = tokenIndexAtOrAfter(core, entry.start); if (!isKw(core[idx - 1], 'JOIN')) return 'inner'; for (var k = idx - 2; k >= Math.max(0, idx - 5); k--) { var t = core[k]; if ((t.type !== 'keyword' && t.type !== 'identifier') || t.quote) break; if (t.upper === 'LEFT' || t.upper === 'RIGHT' || t.upper === 'FULL') return t.upper.toLowerCase(); if (t.upper === 'INNER' || t.upper === 'CROSS') return 'inner'; if (['OUTER', 'HASH', 'LOOP', 'MERGE', 'REMOTE'].indexOf(t.upper) === -1) break; }; return 'inner'; }; function conditionAtoms(core, start, end, baseDepth) { var atoms = []; formatterSplitAndOr(core, start, end, baseDepth).forEach(function (seg) { if (seg.end <= seg.start) return; if (seg.end - seg.start >= 3 && isPunctToken(core[seg.start], '(') && matchParen(core, seg.start) === seg.end - 1 && !isKw(core[seg.start + 1], 'SELECT')) { atoms = atoms.concat(conditionAtoms(core, seg.start + 1, seg.end - 1, baseDepth + 1)); return; }; atoms.push(seg); }); return atoms; }; function columnComparison(core, start, end) { var left = parseNameChain(core, start, end); if (left.parts.length < 2 || left.end >= end || !left.parts.every(isName)) return null; var op = core[left.end]; if (!op || op.type !== 'operator' || DATE_COMPARISON_OPERATORS.indexOf(op.value) === -1) return null; var right = parseNameChain(core, left.end + 1, end); if (right.parts.length < 2 || right.end !== end || !right.parts.every(isName)) return null; return { left: left, operator: op.value, right: right }; }; function conditionReferences(core, nesting, start, end) { var refs = []; var q = start; while (q < end) { var t = core[q]; if (isKw(t, 'SELECT') && nesting[q]) { q = Math.max(nesting[q].end, q + 1); continue; }; if (!isName(t) || t.quote === 'double' || isPunctToken(core[q - 1], '.')) { q++; continue; }; var chain = parseNameChain(core, q, end); var isCall = isPunctToken(core[chain.end], '('); var isStar = isPunctToken(core[chain.end - 1], '.'); if (!isCall && !isStar && chain.parts.every(isName)) { refs.push({ qualifier: chain.parts.length > 1 ? chain.parts[chain.parts.length - 2].value.toUpperCase() : null, column: chain.parts[chain.parts.length - 1].value.toUpperCase() }); }; q = Math.max(chain.end, q + 1); }; return refs; }; function passedColumns(branch, head, entry, names, declared) { var pairs = []; var ref = entryReference(entry).replace(/^\[|\]$/g, '').toUpperCase(); var isStar = function (item) { return item.kind === 'star'; }; var hasStar = branch.selectList.items.some(isStar) || head.selectList.items.some(isStar); if ((branch !== head || declared) && hasStar) return pairs; branch.selectList.items.forEach(function (item, k) { if (item.kind === 'star') { if (item.starTable && String(item.starTable).toUpperCase() !== ref) return; Object.keys(names).forEach(function (name) { pairs.push([name, name]); }); return; }; if ((item.kind !== 'column' && item.kind !== 'alias') || !item.sourceColumn) return; var column = item.sourceColumn.toUpperCase(); if (!names[column]) return; if (item.sourceTable && String(item.sourceTable).toUpperCase() !== ref) return; var headItem = head.selectList.items[k]; var outName = declared ? declared[k] : (headItem ? headItem.name : null); if (outName) pairs.push([column, String(outName).toUpperCase()]); }); return pairs; }; function collectTrackingFilters(core, scopes, nesting, ctes) { var scopeBySelect = {}; var nodes = {}; var keyOf = function (scope, entry) { return scope.selectIdx + ':' + entry.start; }; var headOfEntry = {}; var branchesOf = {}; var cteHeads = {}; Object.keys(nesting).forEach(function (key) { var place = nesting[key]; var idx = Number(key); (branchesOf[place.head] = branchesOf[place.head] || []).push(idx); if (place.head !== idx) return; if (place.entry) headOfEntry[place.parent + ':' + place.entry.start] = idx; if (place.kind === 'cte') { Object.keys(ctes).forEach(function (name) { if (ctes[name].inner && ctes[name].inner.start === core[idx].start) cteHeads[name] = idx; }); }; }); scopes.forEach(function (scope) { scopeBySelect[scope.selectIdx] = scope; scope.from.forEach(function (entry) { var isTracking = !!sendLogViewName(entry); var source = null; var declared = null; if (entry.kind === 'derived' && entry.inner && !entry.inner.noSelect) { var derivedHead = headOfEntry[scope.selectIdx + ':' + entry.start]; if (derivedHead !== undefined) source = derivedHead; if (entry.inner.selectColumnNames && entry.columns !== entry.inner.selectColumnNames) declared = entry.columns; } else if (entry.kind === 'table' && entry.nameParts === 1 && !entry.isFunction) { var cteKey = String(entry.name).replace(/^\[|\]$/g, '').toUpperCase(); if (cteHeads[cteKey] !== undefined) { source = cteHeads[cteKey]; if (ctes[cteKey].inner.selectColumnNames && ctes[cteKey].columns !== ctes[cteKey].inner.selectColumnNames) declared = ctes[cteKey].columns; }; }; nodes[keyOf(scope, entry)] = { scope: scope, entry: entry, isTracking: isTracking, names: isTracking ? Object.assign({}, TRACKING_FILTER_COLUMNS) : null, source: source, declared: declared, cols: {}, isFiltered: false, ties: [], passes: [] }; }); }); var namesOf = function (node) { if (node.names) return node.names; node.names = {}; if (node.source === null) return node.names; var head = scopeBySelect[node.source]; if (!head) return node.names; (branchesOf[node.source] || []).forEach(function (branchIdx) { var branch = scopeBySelect[branchIdx]; if (!branch) return; branch.from.forEach(function (entry) { var inner = nodes[keyOf(branch, entry)]; var innerNames = inner ? namesOf(inner) : null; if (!innerNames || !Object.keys(innerNames).length) return; passedColumns(branch, head, entry, innerNames, node.declared).forEach(function (pair) { node.names[pair[1]] = true; node.passes.push({ to: inner, outCol: pair[1], innerCol: pair[0] }); }); }); }); return node.names; }; var queue = []; var add = function (node, col) { if (node.cols[col]) return; node.cols[col] = true; queue.push({ node: node, col: col }); }; var allNodes = Object.keys(nodes).map(function (key) { return nodes[key]; }); allNodes.forEach(namesOf); allNodes.forEach(function (node) { if (node.source === null || Object.keys(node.names).length) return; var isLimited = (branchesOf[node.source] || []).some(function (branchIdx) { var branch = scopeBySelect[branchIdx]; return !!branch && [branch.where, branch.having].some(function (span) { return !!span && span.end > span.start; }); }); if (isLimited) add(node, '*'); }); var name = function (node, column) { var names = namesOf(node); if (node.isTracking || Object.keys(names).length) { if (names[column]) add(node, column); } else { add(node, '*'); }; }; var referencesByScope = {}; var referencesOf = function (scope) { if (!referencesByScope[scope.selectIdx]) { var index = Object.create(null); scope.from.forEach(function (f) { var ref = entryReference(f).replace(/^\[|\]$/g, '').toUpperCase(); if (ref) (index[ref] = index[ref] || []).push(f); }); referencesByScope[scope.selectIdx] = index; }; return referencesByScope[scope.selectIdx]; }; scopes.forEach(function (scope) { var spans = []; [scope.where, scope.having].forEach(function (span) { if (span && span.end > span.start) spans.push({ start: span.start, end: span.end, depth: core[span.start].depth, on: null }); }); scope.ons.forEach(function (on) { if (on.end > on.start) spans.push({ start: on.start, end: on.end, depth: on.depth, on: on }); }); if (!spans.length) return; var visible = visibleScopes(core, nesting, scopeBySelect, scope.selectIdx); var resolve = function (qualifier) { for (var v = 0; v < visible.length; v++) { var found = (referencesOf(visible[v].scope)[qualifier] || []).filter(function (f) { return f.start < visible[v].limit; })[0]; if (found) return { node: nodes[keyOf(visible[v].scope, found)], isOuter: v > 0 }; }; return null; }; var rejecting = {}; spans.forEach(function (span) { if (span.on) return; conditionAtoms(core, span.start, span.end, span.depth).forEach(function (atom) { if (isKw(core[atom.end - 1], 'NULL') && isKw(core[atom.end - 2], 'IS')) return; conditionReferences(core, nesting, atom.start, atom.end).forEach(function (ref) { var named = ref.qualifier ? resolve(ref.qualifier) : null; if (named && named.node && !named.isOuter) rejecting[keyOf(named.node.scope, named.node.entry)] = true; }); }); }); spans.forEach(function (span) { var joined = span.on ? span.on.entries[span.on.entries.length - 1] : null; var kind = joined ? joinKindOf(core, joined) : 'inner'; if (kind === 'left' && rejecting[keyOf(scope, joined)]) kind = 'inner'; if (kind === 'right' && span.on.entries.slice(0, -1).some(function (f) { return rejecting[keyOf(scope, f)]; })) kind = 'inner'; conditionAtoms(core, span.start, span.end, span.depth).forEach(function (atom) { var cmp = columnComparison(core, atom.start, atom.end); if (cmp && (cmp.operator === '<>' || cmp.operator === '!=')) return; var named = []; var namedByKey = {}; var note = function (resolved, column) { if (!resolved || !resolved.node) return; var key = keyOf(resolved.node.scope, resolved.node.entry); if (!namedByKey[key]) { namedByKey[key] = { node: resolved.node, isOuter: resolved.isOuter, columns: {} }; named.push(namedByKey[key]); }; namedByKey[key].columns[column] = true; }; conditionReferences(core, nesting, atom.start, atom.end).forEach(function (ref) { if (ref.qualifier) { note(resolve(ref.qualifier), ref.column); return; }; scope.from.forEach(function (f) { var node = nodes[keyOf(scope, f)]; if (node && namesOf(node)[ref.column]) note({ node: node, isOuter: false }, ref.column); }); }); if (named.length === 1) { var isNullTest = isKw(core[atom.end - 1], 'NULL') && (isKw(core[atom.end - 2], 'IS') || (isKw(core[atom.end - 2], 'NOT') && isKw(core[atom.end - 3], 'IS'))); if (!isNullTest) Object.keys(named[0].columns).forEach(function (column) { name(named[0].node, column); }); return; }; named.forEach(function (limited) { if (limited.isOuter) return; var names = namesOf(limited.node); Object.keys(limited.columns).forEach(function (column) { if (!names[column]) return; named.forEach(function (limiting) { if (limiting === limited) return; if (!limiting.isOuter && joined) { if (limited.node.entry === joined && kind !== 'inner' && kind !== 'left') return; if (limiting.node.entry === joined && kind !== 'inner' && kind !== 'right') return; }; limiting.node.ties.push({ to: limited.node, col: column }); }); }); }); }); }); }); while (queue.length) { var item = queue.pop(); var node = item.node; if (!node.isFiltered) { node.isFiltered = true; node.ties.forEach(function (tie) { add(tie.to, tie.col); }); }; node.passes.forEach(function (pass) { if (pass.outCol === item.col) add(pass.to, pass.innerCol); }); }; return function (scope, entry) { var node = nodes[keyOf(scope, entry)]; return !!node && node.isFiltered; }; }; function trackingFilterFixes(sql, core, scope, nesting, condition) { var where = scope.where; if (where && where.end > where.start) { return [{ title: 'Filter to the last 30 days', edits: appendConditionEdits(core, where.start, where.end, core[where.start].depth, condition), isSafe: false, isInFixAll: false }]; }; if (where) return []; var stops = []; ['groupBy', 'having', 'orderBy'].forEach(function (k) { if (scope.clauses[k]) stops.push(scope.clauses[k].start); }); var place = nesting[scope.selectIdx]; var endIdx = place ? place.end : core.length; stops.push(endIdx < core.length ? core[endIdx].start : sql.length); var insertAt = Math.min.apply(Math, stops); var last = tokenIndexAtOrAfter(core, insertAt) - 1; if (last >= 0 && core[last].end <= insertAt) insertAt = core[last].end; return [{ title: 'Filter to the last 30 days', edits: [{ start: insertAt, end: insertAt, text: ' WHERE ' + condition }], isSafe: false, isInFixAll: false }]; }; function numericCastType(field) { if (!field || field.type !== 'Decimal') return 'BIGINT'; var precision = field.precision != null ? field.precision : field.length; var scale = field.scale; if (precision == null) { precision = 18; if (scale == null) scale = 2; }; if (scale == null) scale = 0; return 'DECIMAL(' + precision + ', ' + scale + ')'; }; function starHasUnderscoreField(item) { return !!(item._certainColumns && item._certainColumns.some(function (n) { return typeof n === 'string' && n.charAt(0) === '_'; })); }; function starTablesOf(items, from) { var keys = []; for (var i = 0; i < items.length; i++) { var item = items[i]; if (item.kind !== 'star') return null; var entry = item.starTable ? findFromEntry(from, item.starTable) : (from.length === 1 ? from[0] : null); if (!entry || entry.kind !== 'table' || entry.isFunction || !entry.name) return null; keys.push(stripEnt(entry.name).replace(/^\[|\]$/g, '').toLowerCase()); }; return keys.length ? keys.join(',') : null; }; function isUnfinishedItem(itemToks) { var last = itemToks[itemToks.length - 1]; if (!last) return false; if (isKw(last, 'AS') || last.type === 'operator' || isPunctToken(last, '.')) return true; var open = 0; itemToks.forEach(function (t) { if (isPunctToken(t, '(')) open++; else if (isPunctToken(t, ')')) open--; }); return open > 0; }; function listableStarColumns(item) { var names = item._certainColumns; if (!names || !names.length) return null; return names.every(function (n) { return typeof n === 'string' && n.length > 0; }) ? names : null; }; function bareStarColumns(parsed) { var names = []; var isKnown = parsed.from.length > 0 && parsed.from.every(function (entry) { var columns = resolveEntryColumns(entry, parsed._ctx, true); if (!columns || columns.some(function (n) { return typeof n !== 'string' || !n.length; })) return false; names = names.concat(columns); return true; }); return isKnown ? names : null; }; function underscoreFieldOfOpenStar(item, from, schema) { var byName = ensureIndex(schema); var entry = item.starTable ? findFromEntry(from, item.starTable) : (from.length === 1 ? from[0] : null); var table = entry ? fieldCheckTable(byName, entry) : null; if (!table || !hasOpenFieldList(table)) return null; var field = table.fields.filter(function (f) { return !!f && typeof f.name === 'string' && f.name.charAt(0) === '_'; })[0]; return field ? field.name : null; }; function classifyBadColumnName(name) { if (name.length > 128) return 'toolong'; if (!MCE015_VALID_CHARS.test(name)) return 'invalid'; if (name.charAt(0) === '_') return 'underscore'; if (MCE015_ALL_DIGITS.test(name)) return 'digits'; return null; }; var ASCII_LETTER_FORMS = { 'ß': 'ss', 'ł': 'l', 'Ł': 'L', 'đ': 'd', 'Đ': 'D', 'ø': 'o', 'Ø': 'O', 'æ': 'ae', 'Æ': 'AE', 'œ': 'oe', 'Œ': 'OE' }; function asciiFieldName(name) { var text = String(name); if (typeof text.normalize === 'function') text = text.normalize('NFD').replace(/[̀-ͯ]/g, ''); return text.replace(/[ßłŁđĐøØæÆœŒ]/g, function (c) { return ASCII_LETTER_FORMS[c]; }); }; function orderByAliasEdits(core, oldName, newName) { var edits = []; var lower = String(oldName).toLowerCase(); core.forEach(function (t, k) { if (t.depth !== 0 || t.type !== 'identifier' || String(t.value).toLowerCase() !== lower) return; if (isPunctToken(core[k - 1], '.') || isPunctToken(core[k + 1], '.') || !isOrderByItemWord(core, k)) return; edits.push({ start: t.start, end: t.end, text: formatIdentifier(newName) }); }); return edits; }; function bareColumnName(name) { return String(name).toLowerCase().replace(/^_+/, ''); }; function outputAliasFor(target, ownName, columns) { var usedLower = {}; var isOwnSkipped = false; (columns || []).forEach(function (name) { if (!name) return; var lower = String(name).toLowerCase(); if (!isOwnSkipped && ownName && lower === String(ownName).toLowerCase()) { isOwnSkipped = true; return; }; usedLower[lower] = true; }); return uniqueOutputName(String(target).replace(/^_+/, ''), usedLower); }; function fieldsTableOf(byName, f, cteNames) { var table = fieldCheckTable(byName, f); if (!table || !Array.isArray(table.fields) || !table.fields.length || hasOpenFieldList(table)) return null; if (f.nameParts === 1 && cteNames[String(f.name).replace(/^\[|\]$/g, '').toUpperCase()]) return null; return table; }; function tableEntriesOf(core, from) { return from.filter(function (f) { if (f.kind !== 'derived' || !f.inner) return true; var before = core[tokenIndexAtOrAfter(core, f.start) - 1]; if (isMisreadEntry(f)) return !before || isKwAny(before, ['FROM', 'JOIN', 'APPLY']) || isPunctToken(before, ',') || isPunctToken(before, '('); if (!f.inner.noSelect) return true; return !isKw(before, 'WITH'); }); }; function isPivotEntry(core, f) { if (!f || f.kind !== 'derived' || !f.inner || !f.inner.noSelect) return false; var before = core[tokenIndexAtOrAfter(core, f.start) - 1]; return isKw(before, 'PIVOT') || isKw(before, 'UNPIVOT'); }; function expressionCallKind(list) { var kind = { hasWindow: false, hasAggregate: false }; for (var k = 0; k < list.length; k++) { var t = list[k]; if (isPunctToken(t, '(') && isKw(list[k + 1], 'SELECT')) { k = closingParenOf(list, k); if (k === -1) break; continue; }; if (isKw(t, 'OVER')) kind.hasWindow = true; if (isName(t) && !t.quote && GROUP_AGGREGATES[t.upper] && isPunctToken(list[k + 1], '(')) { var close = closingParenOf(list, k + 1); if (close === -1 || !isKw(list[close + 1], 'OVER')) kind.hasAggregate = true; }; }; return kind; }; function pivotColumnsOf(core, f) { var open = tokenIndexAtOrAfter(core, f.start); if (!isPunctToken(core[open], '(')) return null; var close = matchParen(core, open); var depth = core[open].depth + 1; var forAt = -1; for (var k = open + 1; k < close && forAt === -1; k++) { if (core[k].depth === depth && isKw(core[k], 'FOR')) forAt = k; }; if (forAt === -1 || !isName(core[forAt + 1]) || !isKw(core[forAt + 2], 'IN') || !isPunctToken(core[forAt + 3], '(')) return null; var list = []; var listClose = matchParen(core, forAt + 3); for (var m = forAt + 4; m < listClose; m++) { if (isName(core[m])) list.push(core[m].value); else if (!isPunctToken(core[m], ',')) return null; }; if (isKw(core[open - 1], 'UNPIVOT')) { if (forAt !== open + 2 || !isName(core[open + 1])) return null; return { added: [core[open + 1].value, core[forAt + 1].value], taken: list }; }; var taken = [core[forAt + 1].value]; for (var a = open + 1; a < forAt; a++) { if (isName(core[a]) && !isPunctToken(core[a + 1], '(') && !isPunctToken(core[a + 1], '.')) taken.push(core[a].value); }; return { added: list, taken: taken }; }; function singleTableOf(core, byName, from, cteNames) { var entries = tableEntriesOf(core, from); return entries.length === 1 ? fieldsTableOf(byName, entries[0], cteNames) : null; }; function qualifiedColumnRef(item) { if ((item.kind !== 'column' && item.kind !== 'alias') || !item.sourceTable || !item.sourceColumn) return null; return { item: item, name: item.sourceColumn, start: item.kind === 'alias' ? item.sourceStart : item.nameStart, end: item.kind === 'alias' ? item.sourceEnd : item.nameEnd, qualifier: item.sourceTable }; }; function bareColumnRef(item, core) { var token = null; if ((item.kind === 'column' || item.kind === 'alias') && !item.sourceTable && item.sourceColumn) { var columnStart = item.kind === 'alias' ? item.sourceStart : item.nameStart; token = core[tokenIndexAtOrAfter(core, columnStart)]; if (!token || token.start !== columnStart) return null; } else if (item.kind === 'alias' && !item.sourceColumn && item.tokens && item.tokens.length === 2 && isName(item.tokens[0]) && (isName(item.tokens[1]) || isUnreservedKeyword(item.tokens[1]))) { token = item.tokens[0]; }; if (!token || token.type !== 'identifier' || token.unterminated || !token.value) return null; return { item: item, name: token.value, start: token.start, end: token.end }; }; function unknownColumnOf(ref, table, outputColumns) { var item = ref.item; var wantLower = ref.name.toLowerCase(); var isFound = table.fields.some(function (f) { return f.name && f.name.toLowerCase() === wantLower; }); if (isFound) return null; var fixes = []; var wantBare = bareColumnName(ref.name); var sameBare = table.fields.filter(function (f) { return f.name && bareColumnName(f.name) === wantBare; }); var nearest = []; var nearestDistance = 3; if (sameBare.length !== 1) { table.fields.forEach(function (f) { if (!f.name) return; var distance = editDistance(f.name.toLowerCase(), wantLower); if (distance > 2 || distance > nearestDistance) return; if (distance < nearestDistance) { nearest = []; nearestDistance = distance; }; nearest.push(f); }); }; var candidates = sameBare.length === 1 ? sameBare : nearest; if (candidates.length === 1 && ref.start !== undefined) { var isSafe = sameBare.length === 1; var target = candidates[0].name; var edits = [{ start: ref.start, end: ref.end, text: formatIdentifier(target) }]; var title = 'Change to ' + target; if (item.kind === 'column' && /^_/.test(target) && outputColumns) { var aliasName = outputAliasFor(target, item.name, outputColumns); var aliasText = ' AS ' + formatIdentifier(aliasName); if (item.end === ref.end) edits[0].text += aliasText; else edits.push({ start: item.end, end: item.end, text: aliasText }); title += ' AS ' + aliasName; }; fixes.push({ title: title, edits: edits, isSafe: isSafe, isInFixAll: isSafe }); }; return { item: item, column: ref.name, table: table, fixes: fixes, target: fixes.length ? candidates[0].name : null }; }; function missingColumnStarts(ctx) { var starts = {}; if (((ctx.options && ctx.options.disabled) || []).indexOf('MCE062') !== -1) return starts; ctx.getUnknownColumns().forEach(function (unknown) { starts[unknown.item.start] = true; }); return starts; }; var REFUSED_TYPO_TAILS = { UNGROUPED: 'Even if it exists, SQL Server refuses it, as it is in no aggregate and not in the GROUP BY.', UNLISTED: 'Even if it exists, SQL Server refuses it, as this ORDER BY reads only the columns of the select list.', GROUP: 'Even if it exists, SQL Server refuses the query, as the select list has a column this GROUP BY leaves out.', NAME: 'Even if it exists, MCE cannot create a Data Extension field of its name.' }; function noteRefusedTypo(ctx, start, ruleId, reason) { if (((ctx.options && ctx.options.disabled) || []).indexOf(ruleId) !== -1) return; if (!ctx.refusedTypos) ctx.refusedTypos = {}; if (!Object.prototype.hasOwnProperty.call(ctx.refusedTypos, start)) ctx.refusedTypos[start] = reason; }; function collectUnknownColumns(ctx) { var unknown = []; if (!ctx.schema || !ctx.schema.byName) return unknown; var parsed = ctx.getParsed(); if (!parsed) return unknown; var byName = ctx.schema.byName; var core = ctx.getCoreTokens(); var cteNames = {}; (parsed.ctes || []).forEach(function (c) { cteNames[String(c.name).toUpperCase()] = true; }); var outputColumns = ctx.getExtracted().columns; var nesting = ctx.getSelectNesting(); var scopeBySelect = {}; var scopes = ctx.getSelectScopes(); scopes.forEach(function (scope) { scopeBySelect[scope.selectIdx] = scope; }); var isItemRead = {}; function keep(found, entryKey) { if (!found) return; found.key = entryKey + ':' + found.column.toLowerCase(); unknown.push(found); }; var firstItemAt = {}; var firstIdx = -1; scopes.forEach(function (scope) { if (scope.selectList && scope.selectList.start === parsed.selectList.start) firstIdx = scope.selectIdx; }); var firstTable = singleTableOf(core, byName, parsed.from, cteNames); parsed.selectList.items.forEach(function (item) { var qualified = qualifiedColumnRef(item); if (qualified) { firstItemAt[qualified.start] = item; return; }; var ref = firstTable ? bareColumnRef(item, core) : null; if (!ref) return; isItemRead[ref.start] = true; keep(unknownColumnOf(ref, firstTable, outputColumns), firstIdx + ':0'); }); var bareTables = {}; function bareTableOf(scope) { if (Object.prototype.hasOwnProperty.call(bareTables, scope.selectIdx)) return bareTables[scope.selectIdx]; var table = scope.selectIdx === firstIdx ? firstTable : singleTableOf(core, byName, scope.from, cteNames); var outerTables = []; var isAllKnown = !!table && visibleScopes(core, nesting, scopeBySelect, scope.selectIdx).slice(1).every(function (outer) { return tableEntriesOf(core, outer.scope.from).filter(function (f) { return f.start < outer.limit; }).every(function (f) { var known = fieldsTableOf(byName, f, cteNames); if (known) outerTables.push(known); return !!known; }); }); bareTables[scope.selectIdx] = isAllKnown ? { table: table, outerTables: outerTables } : null; return bareTables[scope.selectIdx]; }; function isOuterName(bare, lower) { return bare.outerTables.some(function (outer) { return outer.fields.some(function (field) { return field.name && field.name.toLowerCase() === lower; }); }); }; scopes.forEach(function (scope) { if (!scope.selectList || scope.selectIdx === firstIdx) return; var place = nesting[scope.selectIdx]; if (!place) return; var bare = bareTableOf(scope); if (!bare) return; var isNamed = place.head === scope.selectIdx && (place.kind === 'derived' || place.kind === 'apply' || place.kind === 'cte'); var ownColumns = isNamed ? scope.selectList.items.map(function (item) { return item.name; }) : null; scope.selectList.items.forEach(function (item) { var ref = bareColumnRef(item, core); if (!ref) return; isItemRead[ref.start] = true; if (!isOuterName(bare, ref.name.toLowerCase())) keep(unknownColumnOf(ref, bare.table, ownColumns), scope.selectIdx + ':0'); }); }); var readings = ctx.getScopeReadings(); var setHeads = setHeadsOf(nesting); var isParsed = !ctx.getStrict().errors.length; readings.list.forEach(function (reading) { var scope = reading.scope; var place = reading.place; var isFirst = scope.selectIdx === firstIdx; var isNamed = !!place && place.head === scope.selectIdx && (place.kind === 'derived' || place.kind === 'apply' || place.kind === 'cte'); var ownColumns = isFirst ? outputColumns : (isNamed ? scope.selectList.items.map(function (i) { return i.name; }) : null); var chain = visibleScopes(core, nesting, scopeBySelect, scope.selectIdx); var bare = null; function readQualified(ref, qualifier, item, column) { var entry = qualifiedEntryOf(core, byName, cteNames, readings, chain, qualifier); if (!entry) return; keep(unknownColumnOf({ item: item || { start: core[ref.first].start, end: column.end, kind: 'reference' }, name: column.value, start: column.start, end: column.end }, entry.table, item ? ownColumns : null), entry.key); }; [reading.select, reading.where, reading.groupBy, reading.having, reading.orderBy].concat(reading.ons).forEach(function (clause) { if (!clause) return; clause.refs.forEach(function (ref) { if (ref.parts.length === 2) { var item = null; if (clause === reading.select && ref.expression.from === ref.first && ref.expression.to === ref.end) item = isFirst ? firstItemAt[ref.parts[1].start] || null : ref.item; readQualified(ref, ref.parts[0].value.toLowerCase(), item, ref.parts[1]); return; }; var name = ref.parts[0]; if (ref.parts.length !== 1 || !isParsed || isItemRead[name.start] || isOtherBareName(core, reading, clause, ref, setHeads)) return; var listed = clause === reading.orderBy ? listedColumnOf(scope, name.value) : null; if (listed && listed.sourceTable) { readQualified(ref, String(listed.sourceTable).replace(/^\[|\]$/g, '').toLowerCase(), null, name); return; }; bare = bare || bareTableOf(scope) || false; if (!bare || isOuterName(bare, name.value.toLowerCase())) return; keep(unknownColumnOf({ item: { start: name.start, end: name.end, kind: 'reference' }, name: name.value, start: name.start, end: name.end }, bare.table, null), scope.selectIdx + ':0'); }); }); }); unknown.sort(function (a, b) { return a.item.start - b.item.start; }); shareUnknownFixes(core, readings, unknown, nesting, scopeBySelect, knownColumnsOf(ctx), setHeads); return unknown; }; function setHeadsOf(nesting) { var heads = {}; Object.keys(nesting).forEach(function (key) { if (String(nesting[key].head) !== key) heads[nesting[key].head] = true; }); return heads; }; function listedColumnOf(scope, name) { var lower = String(name).toLowerCase(); var named = outputItemsOf(scope, lower); if (named.length !== 1 || !named[0].sourceColumn || String(named[0].sourceColumn).toLowerCase() !== lower) return null; return named[0]; }; function outputItemsOf(scope, lower) { if (!scope.outputItems) { var map = {}; scope.selectList.items.forEach(function (item) { if (!item.name) return; var key = String(item.name).toLowerCase(); if (!Object.prototype.hasOwnProperty.call(map, key)) map[key] = []; map[key].push(item); }); scope.outputItems = map; }; return Object.prototype.hasOwnProperty.call(scope.outputItems, lower) ? scope.outputItems[lower] : []; }; function isOtherBareName(core, reading, clause, ref, setHeads) { var before = core[ref.first - 1]; var word = ref.parts[0].upper; if (isKw(before, 'OVER') || (isPunctToken(before, '(') && isKw(core[ref.first - 2], 'OVER'))) return true; if ((word === 'ROLLUP' || word === 'CUBE') && isKw(before, 'WITH')) return true; if ((before && before.upper === 'RETURNING') || (word === 'RETURNING' && core[ref.end] && RETURNING_TYPES[core[ref.end].upper])) return true; if (word === 'MAX' && isPunctToken(before, '(') && core[ref.first - 2] && HIGHLIGHT_TYPES.indexOf(core[ref.first - 2].upper) !== -1) return true; if (ref.end >= core.length && ref.parts.length === 1 && (FORMATTER_BUILTIN_FUNCTIONS.indexOf(word) !== -1 || ['ROLLUP', 'CUBE', 'GROUPING'].indexOf(word) !== -1)) return true; var scope = reading.scope; var lower = ref.parts[0].value.toLowerCase(); var outputs = outputItemsOf(scope, lower); if (clause !== reading.orderBy) { return outputs.some(function (item) { return item.kind === 'alias' && (!item.sourceColumn || String(item.sourceColumn).toLowerCase() !== lower); }); }; if ((reading.place && reading.place.head !== scope.selectIdx) || setHeads[scope.selectIdx]) return true; return outputs.length > 0 && !listedColumnOf(scope, lower); }; function shareUnknownFixes(core, readings, unknown, nesting, scopeBySelect, known, setHeads) { var groups = {}; var at = {}; unknown.forEach(function (u) { if (!Object.prototype.hasOwnProperty.call(groups, u.key)) groups[u.key] = []; groups[u.key].push(u); (u.fixes[0] ? u.fixes[0].edits : []).forEach(function (edit) { at[edit.start] = true; }); at[u.item.start] = true; }); var byName = null; var chains = {}; var bareUses = function (lower) { if (!byName) { byName = {}; readings.list.forEach(function (reading) { [reading.select, reading.where, reading.groupBy, reading.having, reading.orderBy].concat(reading.ons).forEach(function (clause) { (clause ? clause.refs : []).forEach(function (ref) { if (ref.parts.length !== 1) return; var key = ref.parts[0].value.toLowerCase(); if (!Object.prototype.hasOwnProperty.call(byName, key)) byName[key] = []; byName[key].push({ reading: reading, clause: clause, ref: ref }); }); }); }); }; return Object.prototype.hasOwnProperty.call(byName, lower) ? byName[lower] : []; }; Object.keys(groups).forEach(function (key) { var members = groups[key]; var parts = key.split(':'); var selectIdx = Number(parts[0]); var entryIdx = Number(parts[1]); var lower = parts.slice(2).join(':'); var target = members[0].target; var isAgreed = members.every(function (u) { return u.fixes.length === 1 && u.target === target; }); var isUncovered = isAgreed && bareUses(lower).some(function (use) { var name = use.ref.parts[0]; if (at[name.start] || isOtherBareName(core, use.reading, use.clause, use.ref, setHeads)) return false; var chain = chains[use.reading.scope.selectIdx] || (chains[use.reading.scope.selectIdx] = visibleScopes(core, nesting, scopeBySelect, use.reading.scope.selectIdx)); var link = chain.filter(function (c) { return c.scope.selectIdx === selectIdx; })[0]; if (!link) return false; var reach = readings.bySelect[selectIdx] ? scopeBinder(core, readings.bySelect[selectIdx]) : null; if (!reach || !reach.entries[entryIdx] || reach.entries[entryIdx].start >= link.limit) return false; return !chain.some(function (c) { var binder = scopeBinder(core, readings.bySelect[c.scope.selectIdx]); var owners = binder.columns(known).owners; var count = entriesBefore(binder, c.limit); return (Object.prototype.hasOwnProperty.call(owners, lower) ? owners[lower] : []).some(function (k) { return k < count && (c.scope.selectIdx !== selectIdx || k !== entryIdx); }); }); }); if (!isAgreed || isUncovered) { members.forEach(function (u) { u.fixes = []; }); return; }; if (members.length === 1) return; var edits = []; members.forEach(function (u) { edits = edits.concat(u.fixes[0].edits); }); edits.sort(function (a, b) { return a.start - b.start; }); members.forEach(function (u) { u.fixes = [Object.assign({}, u.fixes[0], { edits: edits })]; }); }); }; function qualifiedEntryOf(core, byName, cteNames, readings, chain, lower) { for (var c = 0; c < chain.length; c++) { var binder = scopeBinder(core, readings.bySelect[chain[c].scope.selectIdx]); var count = entriesBefore(binder, chain[c].limit); var map = binder.exposed; var k = firstBound(map, lower, count); if (k === -1 && c === 0) { map = binder.named; k = firstBound(map, lower, count); }; if (k === -1) continue; if (map[lower].filter(function (n) { return n < count; }).length > 1) return null; for (var p = k + 1; p < binder.entries.length; p++) { if (isPivotEntry(core, binder.entries[p])) return null; }; var table = fieldsTableOf(byName, binder.entries[k], cteNames); return table ? { table: table, key: chain[c].scope.selectIdx + ':' + k } : null; }; return null; }; var UNDOCUMENTED_DATA_VIEWS = ['_MobileLineAddressContactSubscriptionView', '_MobileLineOrphanContactView']; function isUndocumentedDataView(name) { var lower = String(name).toLowerCase(); return UNDOCUMENTED_DATA_VIEWS.some(function (view) { return view.toLowerCase() === lower; }); }; function underscoreTableNames(schema, isEntPrefixed) { var names = []; var seen = {}; (schema.tables || []).forEach(function (t) { if (!t || !t.name || (t.isShared && !isEntPrefixed)) return; var name = String(t.name); if (name.charAt(0) !== '_' || seen[name.toLowerCase()]) return; seen[name.toLowerCase()] = true; names.push(name); }); UNDOCUMENTED_DATA_VIEWS.forEach(function (name) { if (seen[name.toLowerCase()]) return; seen[name.toLowerCase()] = true; names.push(name); }); return names; }; function enclosingSelectIndex(core, selectIdx) { var depth = core[selectIdx].depth; for (var i = selectIdx - 1; i >= 0; i--) { if (core[i].depth < depth) return scopeSelectIndex(core, i); }; return -1; }; function bindsName(scope, lower) { for (var i = 0; i < scope.from.length; i++) { var f = scope.from[i]; var ref = f.alias || (f.name ? stripEnt(f.name).replace(/^\[|\]$/g, '') : ''); if (ref && String(ref).replace(/^\[|\]$/g, '').toLowerCase() === lower) return f; }; return null; }; function qualifierTokens(core, entry, scope, scopeBySelect) { var found = []; if (entry.alias) return found; var lower = stripEnt(entry.name).replace(/^\[|\]$/g, '').toLowerCase(); for (var k = 0; k < core.length; k++) { var t = core[k]; if (!isName(t) || t.value.toLowerCase() !== lower) continue; var next = core[k + 1]; if (!next || next.type !== 'punct' || next.value !== '.') continue; var prev = core[k - 1]; if (prev && prev.type === 'punct' && prev.value === '.' && !(core[k - 2] && /^ENT$/i.test(core[k - 2].value) && !(core[k - 3] && core[k - 3].type === 'punct' && core[k - 3].value === '.'))) continue; var selectIdx = scopeSelectIndex(core, k); while (selectIdx !== -1) { var owner = scopeBySelect[selectIdx]; var bound = owner ? bindsName(owner, lower) : null; if (bound) { if (bound === entry && owner === scope) found.push(t); break; }; selectIdx = enclosingSelectIndex(core, selectIdx); }; }; return found; }; var MCE072_SHORT_NAME = 6; function maxNameDistance(name) { return String(name).length > MCE072_SHORT_NAME ? 2 : 1; }; var LIST_TIME_MONTHS = ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', 'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec']; var SQLSTUDIO_TEMP_NAME = /^SQLStudio_[0-9a-f]{8}_\d{8}_\d{6}(?:_\d+)?$/i; function listTimeText(savedAt) { if (typeof savedAt !== 'number' || !isFinite(savedAt)) return ''; var at = new Date(savedAt); var now = new Date(); var hours = at.getHours(); var minutes = at.getMinutes(); var clock = (hours % 12 || 12) + ':' + (minutes < 10 ? '0' : '') + minutes + (hours < 12 ? ' AM' : ' PM'); var isToday = at.getFullYear() === now.getFullYear() && at.getMonth() === now.getMonth() && at.getDate() === now.getDate(); return isToday ? 'at ' + clock : 'on ' + at.getDate() + ' ' + LIST_TIME_MONTHS[at.getMonth()] + ' at ' + clock; }; function reloadListAction() { return { id: 'reloadDataExtensionList', title: 'Reload the list' }; }; function isUnbracketedWordName(sql, core, first, schema) { var before = core[first - 1]; if (!before || before.type !== 'keyword' || (before.upper !== 'FROM' && before.upper !== 'JOIN')) return false; var run = []; var hasHyphen = false; for (var j = first; j < core.length; j++) { var x = core[j]; if (isBareName(x)) { run.push(x); } else if (x.type === 'operator' && x.value === '-' && run.length && isBareName(core[j + 1])) { hasHyphen = true; run.push(x); } else { break; }; }; if (hasHyphen || run.length >= 3) return true; for (var w = run.length; w >= 2; w--) { var candidate = sql.slice(run[0].start, run[w - 1].end).replace(/\s+/g, ' ').toLowerCase(); if (run[w - 1].type === 'identifier' && schema.byName[candidate]) return true; }; return false; }; var CONDITION_END_KEYWORDS = { AND: 1, OR: 1, WHERE: 1, GROUP: 1, ORDER: 1, HAVING: 1, UNION: 1, EXCEPT: 1, INTERSECT: 1, JOIN: 1, INNER: 1, FULL: 1, CROSS: 1, OUTER: 1, ON: 1, THEN: 1 }; var CONDITION_START_KEYWORDS = { WHERE: 1, ON: 1, HAVING: 1 }; function endsCondition(core, idx) { var t = core[idx]; if (!t) return true; if (t.type === 'punct') return t.value === ')' || t.value === ';' || t.value === ','; if (t.type !== 'keyword') return false; if (t.upper === 'LEFT' || t.upper === 'RIGHT') return isKw(core[idx + 1], 'JOIN') || isKw(core[idx + 1], 'OUTER'); return !!CONDITION_END_KEYWORDS[t.upper]; }; function isBetweenAnd(core, idx) { var depth = core[idx].depth; var openEnds = 0; for (var k = idx - 1; k >= 0; k--) { var t = core[k]; if (t.depth > depth) continue; if (t.depth < depth) return false; if (t.type !== 'keyword') continue; if (t.upper === 'END') { openEnds++; continue; }; if (t.upper === 'CASE') { if (openEnds > 0) openEnds--; continue; }; if (openEnds > 0) continue; if (t.upper === 'BETWEEN') return true; if (t.upper === 'AND' || t.upper === 'OR' || t.upper === 'NOT' || CONDITION_START_KEYWORDS[t.upper] || CLAUSE_COLUMN_CONTEXT[t.upper] || TABLE_CONTEXT_KEYWORDS[t.upper] || t.upper === 'WHEN' || t.upper === 'THEN' || t.upper === 'ELSE') return false; }; return false; }; function removeConnectorEdit(sql, tokens, connector) { var at = tokens.indexOf(connector); var prev = at > 0 ? tokens[at - 1] : null; var next = tokens[at + 1] || null; var rest = /^[ \t]*/.exec(sql.slice(connector.end))[0]; var afterRest = connector.end + rest.length; var isLineEnd = afterRest >= sql.length || sql.charAt(afterRest) === '\n' || sql.charAt(afterRest) === '\r'; var startsLine = !prev || sql.slice(prev.end, connector.start).indexOf('\n') !== -1; var start = connector.start; var end = next ? next.start : afterRest; if (isLineEnd && prev) { start = prev.end; end = afterRest; } else if (next && next.type === 'punct' && (next.value === ')' || next.value === ',') && !startsLine && next.start === afterRest) { start = prev.end; end = connector.end; }; return { start: start, end: end, text: '' }; }; function danglingConnectors(sql, tokens, core) { var found = []; for (var i = 0; i < core.length; i++) { var t = core[i]; if (t.type !== 'keyword' || (t.upper !== 'AND' && t.upper !== 'OR')) continue; var prev = core[i - 1]; var nextStart = core[i + 1] ? core[i + 1].start : sql.length; var isLeading = false; if (endsOperand(prev) && endsCondition(core, i + 1)) { if (t.upper === 'AND' && isBetweenAnd(core, i)) continue; } else if (prev && prev.type === 'keyword' && CONDITION_START_KEYWORDS[prev.upper] && core[i + 1] && !endsCondition(core, i + 1) && !isKw(core[i + 1], 'AND') && !isKw(core[i + 1], 'OR')) { isLeading = true; } else { continue; }; found.push({ token: t, isLeading: isLeading, nextStart: nextStart }); }; return found; }; var NO_2022_EQUIVALENT = 'There is no SQL Server 2022 equivalent in a Query Activity.'; var ROUGH_MATCH = 'For a rough match, SQL Server 2022 has SOUNDEX and DIFFERENCE.'; var SUBSTRING_WITH_PATINDEX = 'Use SUBSTRING with PATINDEX or CHARINDEX.'; var JSON_BY_STRING_AGG = 'Build the JSON with STRING_AGG.'; var NO_BASE64 = 'SQL Server 2022 has no Base64 function.'; var NEWER_FUNCTIONS = { REGEXP_LIKE: 'Use LIKE, or PATINDEX(pattern, text) > 0.', REGEXP_REPLACE: 'Use REPLACE, or TRANSLATE for single characters.', REGEXP_SUBSTR: SUBSTRING_WITH_PATINDEX, REGEXP_INSTR: SUBSTRING_WITH_PATINDEX, REGEXP_COUNT: 'For a fixed text, count with (LEN(x) - LEN(REPLACE(x, \'a\', \'\'))) / LEN(\'a\').', REGEXP_MATCHES: NO_2022_EQUIVALENT, REGEXP_SPLIT_TO_TABLE: NO_2022_EQUIVALENT, EDIT_DISTANCE: ROUGH_MATCH, EDIT_DISTANCE_SIMILARITY: ROUGH_MATCH, JARO_WINKLER_DISTANCE: ROUGH_MATCH, JARO_WINKLER_SIMILARITY: ROUGH_MATCH, JSON_ARRAYAGG: JSON_BY_STRING_AGG, JSON_OBJECTAGG: JSON_BY_STRING_AGG, JSON_CONTAINS: 'Use JSON_VALUE or JSON_PATH_EXISTS.', UNISTR: 'Use NCHAR(code) for a single character.', BASE64_ENCODE: NO_BASE64, BASE64_DECODE: NO_BASE64, PRODUCT: 'Use EXP(SUM(LOG(x))) for positive values.', VECTOR_DISTANCE: NO_2022_EQUIVALENT, VECTOR_NORM: NO_2022_EQUIVALENT, VECTOR_NORMALIZE: NO_2022_EQUIVALENT, VECTORPROPERTY: NO_2022_EQUIVALENT, VECTOR_SEARCH: NO_2022_EQUIVALENT, AI_GENERATE_EMBEDDINGS: NO_2022_EQUIVALENT, AI_GENERATE_CHUNKS: NO_2022_EQUIVALENT }; var REFUSED_AS_UNKNOWN_FUNCTION = { REGEXP_LIKE: 1, REGEXP_REPLACE: 1, REGEXP_COUNT: 1, REGEXP_SUBSTR: 1, JSON_ARRAYAGG: 1, JSON_CONTAINS: 1, PRODUCT: 1, EDIT_DISTANCE: 1, BASE64_ENCODE: 1 }; var JSON_RETURNING_FUNCTIONS = { JSON_VALUE: 1, JSON_QUERY: 1, JSON_OBJECT: 1, JSON_ARRAY: 1, JSON_ARRAYAGG: 1, JSON_OBJECTAGG: 1 }; var RETURNING_TYPES = { NVARCHAR: 1, VARCHAR: 1, NCHAR: 1, CHAR: 1, JSON: 1, VECTOR: 1, INT: 1, BIGINT: 1, SMALLINT: 1, TINYINT: 1, BIT: 1, DECIMAL: 1, NUMERIC: 1, FLOAT: 1, REAL: 1, MONEY: 1, SMALLMONEY: 1, DATE: 1, DATETIME: 1, DATETIME2: 1, SMALLDATETIME: 1, DATETIMEOFFSET: 1, TIME: 1, UNIQUEIDENTIFIER: 1, VARBINARY: 1, BINARY: 1, XML: 1, SQL_VARIANT: 1 }; var NEWER_TYPES = { JSON: 1, VECTOR: 1 }; var NEWER_THAN_2022 = { functions: Object.keys(NEWER_FUNCTIONS), keywords: ['CURRENT_DATE', 'RETURNING'], types: Object.keys(NEWER_TYPES) }; function callClose(core, i) { var open = core[i + 1]; if (!open || open.type !== 'punct' || open.value !== '(') return -1; var close = matchParen(core, i + 1); var closeTok = core[close]; return closeTok && closeTok.type === 'punct' && closeTok.value === ')' && closeTok.depth === open.depth ? close : -1; }; function removalStart(sql, tokens, pos) { var start = pos; while (start > 0 && /\s/.test(sql.charAt(start - 1))) start--; for (var i = 0; i < tokens.length; i++) { if (tokens[i].end !== start) continue; if (tokens[i].type === 'comment' && tokens[i].raw.indexOf('--') === 0) { var newline = sql.indexOf('\n', start); if (newline !== -1 && newline < pos) start = newline + 1; }; break; }; return start; }; function removalEdits(sql, tokens, first, last) { var edits = []; var runStart = first.start; var addRun = function (runEnd) { var text = sql.slice(runStart, runEnd); var lead = text.length - text.replace(/^\s+/, '').length; var trimmed = text.replace(/\s+$/, ''); if (lead >= trimmed.length) return; edits.push({ start: removalStart(sql, tokens, runStart + lead), end: runStart + trimmed.length, text: '' }); }; for (var i = 0; i < tokens.length; i++) { var t = tokens[i]; if (t.type !== 'comment' || t.start < first.start || t.end > last.end) continue; addRun(t.start); runStart = t.end; }; addRun(last.end); return edits; }; var NULL_ONLY_FOR_NULL_ARGUMENTS = { REPLICATE: 1, CAST: 1, CONVERT: 1, LEFT: 1, RIGHT: 1, UPPER: 1, LOWER: 1, LTRIM: 1, RTRIM: 1, TRIM: 1, REPLACE: 1, SPACE: 1, SUBSTRING: 1, LEN: 1, DATALENGTH: 1, YEAR: 1, MONTH: 1, DAY: 1, EOMONTH: 1, DATEADD: 1, DATEDIFF: 1, DATEDIFF_BIG: 1, DATEPART: 1, DATENAME: 1, DATETRUNC: 1, TODATETIMEOFFSET: 1, SWITCHOFFSET: 1 }; var DATE_PART_FIRST_FUNCTIONS = { DATEADD: 1, DATEDIFF: 1, DATEDIFF_BIG: 1, DATEPART: 1, DATENAME: 1, DATETRUNC: 1 }; var NEVER_NULL_WITHOUT_ARGUMENTS = { GETDATE: 1, GETUTCDATE: 1, SYSDATETIME: 1, SYSUTCDATETIME: 1, SYSDATETIMEOFFSET: 1, NEWID: 1 }; var NO_TEXT_RESULT_FUNCTIONS = { GETDATE: 1, GETUTCDATE: 1, SYSDATETIME: 1, SYSUTCDATETIME: 1, SYSDATETIMEOFFSET: 1, NEWID: 1, COUNT: 1, COUNT_BIG: 1, LEN: 1, DATALENGTH: 1, YEAR: 1, MONTH: 1, DAY: 1, EOMONTH: 1, DATEADD: 1, DATEDIFF: 1, DATEDIFF_BIG: 1, DATEPART: 1, DATETRUNC: 1, TODATETIMEOFFSET: 1, SWITCHOFFSET: 1 }; var COUNT_CAN_BE_NEGATIVE = { DATEDIFF: 1, DATEDIFF_BIG: 1, DATEPART: 1 }; function isInFromList(core, i) { var depth = core[i].depth; for (var j = i - 1; j >= 0; j--) { var t = core[j]; if (t.depth < depth) return false; if (t.depth !== depth || t.type !== 'keyword') continue; if (t.upper === 'FROM' || t.upper === 'JOIN') return true; if (t.upper === 'SELECT' || t.upper === 'WHERE' || t.upper === 'GROUP' || t.upper === 'HAVING' || t.upper === 'ORDER') return false; }; return false; }; function callStartBefore(core, closeIndex) { var balance = 0; for (var j = closeIndex; j >= 0; j--) { var t = core[j]; if (t.type !== 'punct') continue; if (t.value === ')') balance++; if (t.value === '(') { balance--; if (balance === 0) return j > 0 && (isName(core[j - 1]) || core[j - 1].type === 'keyword') ? j - 1 : -1; }; }; return -1; }; function isTextCall(core, nameIndex, closeIndex) { var name = core[nameIndex].upper; if (TEXT_RESULT_FUNCTIONS[name]) return true; var depth = core[nameIndex + 1].depth + 1; var isCharType = function (k) { return !!core[k] && CHAR_TYPES[core[k].upper]; }; if (name === 'CAST') { for (var k = closeIndex - 1; k > nameIndex + 1; k--) { if (core[k].depth === depth && isKw(core[k], 'AS')) return isCharType(k + 1); }; return false; }; if (name === 'CONVERT') return isCharType(nameIndex + 2); if (name === 'ISNULL' || name === 'COALESCE') { var starts = [nameIndex + 2]; for (var m = nameIndex + 2; m < closeIndex; m++) { if (core[m].type === 'punct' && core[m].value === ',' && core[m].depth === depth) starts.push(m + 1); }; if (name === 'ISNULL') { var firstEnd = starts.length > 1 ? starts[1] - 2 : closeIndex - 1; var caseDepth = 0; for (var q = starts[0]; q <= firstEnd; q++) { var tq = core[q]; if (tq.depth !== depth) continue; if (isKw(tq, 'CASE')) { caseDepth++; } else if (isKw(tq, 'END') && caseDepth) { caseDepth--; } else if (!caseDepth && tq.type === 'string') { return true; } else if (!caseDepth && core[q + 1] && core[q + 1].type === 'punct' && core[q + 1].value === '(' && (isName(tq) || tq.type === 'keyword')) { var innerClose = callClose(core, q); if (innerClose !== -1 && innerClose <= firstEnd && isTextCall(core, q, innerClose)) return true; }; }; }; for (var n = 0; n < starts.length; n++) { var argEnd = n + 1 < starts.length ? starts[n + 1] - 2 : closeIndex - 1; var first = core[starts[n]]; if (neverNullExpressionEnd(core, starts[n]) !== argEnd) continue; if (first.type === 'string') return true; if (core[starts[n] + 1] && core[starts[n] + 1].type === 'punct' && core[starts[n] + 1].value === '(' && isTextCall(core, starts[n], argEnd)) return true; }; }; return false; }; var TEXT_RESULT_FUNCTIONS = { REPLICATE: 1, UPPER: 1, LOWER: 1, LTRIM: 1, RTRIM: 1, TRIM: 1, REPLACE: 1, SPACE: 1, LEFT: 1, RIGHT: 1, CONCAT: 1, CONCAT_WS: 1, SUBSTRING: 1, DATENAME: 1 }; var CHAR_TYPES = { VARCHAR: 1, NVARCHAR: 1, CHAR: 1, NCHAR: 1 }; function typeNameEnd(core, i) { var t = core[i]; if (!t || t.type !== 'identifier') return -1; var open = core[i + 1]; if (!open || open.type !== 'punct' || open.value !== '(') return i; var close = callClose(core, i); if (close === -1) return -1; for (var j = i + 2; j < close; j++) { var inner = core[j]; if (!(inner.type === 'number' || inner.upper === 'MAX' || (inner.type === 'punct' && inner.value === ','))) return -1; }; return close; }; function neverNullEnd(core, i, columnEnd) { var t = core[i]; if (!t) return -1; if (t.type === 'string') return t.unterminated ? -1 : i; if (t.type === 'number') return i; if (t.type === 'operator' && (t.value === '-' || t.value === '+') && core[i + 1] && core[i + 1].type === 'number') return i + 1; if (t.type === 'punct' && t.value === '(') { var innerEnd = neverNullExpressionEnd(core, i + 1, columnEnd); var innerClose = innerEnd === -1 ? null : core[innerEnd + 1]; return innerClose && innerClose.type === 'punct' && innerClose.value === ')' && innerClose.depth === t.depth ? innerEnd + 1 : -1; }; var columnLast = columnEnd ? columnEnd(i) : -1; if (columnLast !== -1) return columnLast; if ((t.type !== 'identifier' && t.type !== 'keyword') || t.quote) return -1; if (t.type === 'keyword' && t.upper === 'CURRENT_TIMESTAMP') return i; var close = callClose(core, i); if (close === -1) return -1; if (NEVER_NULL_WITHOUT_ARGUMENTS[t.upper] && close === i + 2) return close; if (t.upper === 'CONCAT' || t.upper === 'CONCAT_WS') return close; if ((t.upper === 'COUNT' || t.upper === 'COUNT_BIG') && close > i + 2) return close; if ((t.upper === 'ISNULL' || t.upper === 'COALESCE') && close > i + 2) { var argDepth = core[i + 1].depth + 1; var starts = [i + 2]; for (var k = i + 2; k < close; k++) { if (core[k].type === 'punct' && core[k].value === ',' && core[k].depth === argDepth) starts.push(k + 1); }; var isArgSafe = function (n) { return neverNullExpressionEnd(core, starts[n], columnEnd) === (n + 1 < starts.length ? starts[n + 1] - 2 : close - 1); }; if (t.upper === 'ISNULL') return (starts.length === 2 && isArgSafe(1)) ? close : -1; for (var n = 0; n < starts.length; n++) { if (isArgSafe(n)) return close; }; return -1; }; if ((t.upper === 'CHAR' || t.upper === 'NCHAR') && close === i + 3 && core[i + 2].type === 'number') { var code = Number(core[i + 2].value); return code >= 0 && code <= (t.upper === 'CHAR' ? 255 : 65535) && Math.floor(code) === code ? close : -1; }; if (!NULL_ONLY_FOR_NULL_ARGUMENTS[t.upper] || close === i + 2) return -1; var depth = core[i + 1].depth + 1; var from = i + 2; if (t.upper === 'CAST') { var valueEnd = neverNullExpressionEnd(core, from, columnEnd); if (valueEnd === -1 || !isKw(core[valueEnd + 1], 'AS')) return -1; return typeNameEnd(core, valueEnd + 2) === close - 1 ? close : -1; }; if (t.upper === 'CONVERT') { var typeEnd = typeNameEnd(core, from); if (typeEnd === -1 || !core[typeEnd + 1] || core[typeEnd + 1].value !== ',') return -1; from = typeEnd + 2; }; if (DATE_PART_FIRST_FUNCTIONS[t.upper]) { var part = core[from]; if (!part || (part.type !== 'identifier' && part.type !== 'keyword') || part.quote || !core[from + 1] || core[from + 1].value !== ',') return -1; from += 2; }; var countArgument = t.upper === 'SPACE' ? 0 : (t.upper === 'REPLICATE' ? 1 : -1); for (var argument = 0; ; argument++) { var count = argument === countArgument ? core[from] : null; if (count && ((count.type === 'operator' && count.value === '-') || (count.type === 'punct' && count.value === '(') || COUNT_CAN_BE_NEGATIVE[count.upper])) return -1; var end = count ? neverNullEnd(core, from) : neverNullExpressionEnd(core, from, columnEnd); if (end === -1) return -1; var after = core[end + 1]; if (end + 1 === close) return close; if (!after || after.type !== 'punct' || after.value !== ',' || after.depth !== depth) return -1; from = end + 2; }; }; function neverNullExpressionEnd(core, i, columnEnd) { var end = neverNullEnd(core, i, columnEnd); while (end !== -1) { while (isKw(core[end + 1], 'AT') && isKw(core[end + 2], 'TIME') && isKw(core[end + 3], 'ZONE') && core[end + 4] && core[end + 4].type === 'string' && !core[end + 4].unterminated) end += 4; var op = core[end + 1]; if (!op || op.type !== 'operator' || ['+', '-', '*', '/', '%'].indexOf(op.value) === -1) return end; end = neverNullEnd(core, end + 2, columnEnd); }; return -1; }; function isNeverNullField(table, field) { if (!table || !field) return false; if (table.kind === 'dataview') { return field.nullable !== true && Array.isArray(table.fields) && table.fields.some(function (f) { return !!f && f.nullable === true; }); }; return field.isPrimaryKey === true || field.isNullable === false; }; function isOuterJoined(core, from, entry) { var kind = joinKindOf(core, entry); if (kind === 'left' || kind === 'full') return true; return from.some(function (f) { if (f.start <= entry.start) return false; var later = joinKindOf(core, f); return later === 'right' || later === 'full'; }); }; function neverNullColumnEnd(core, i, byName, scopes) { var t = core[i]; if (!t || !isName(t) || isPunctToken(core[i - 1], '.') || !scopes.length) return -1; var chain = parseNameChain(core, i, core.length); var parts = chain.parts; if (!parts.length || parts.length > 2 || !parts.every(isName) || isPunctToken(core[chain.end], '(') || isPunctToken(core[chain.end], '.')) return -1; var column = parts[parts.length - 1].value; var entry = null; var scope = null; if (parts.length === 2) { for (var s = 0; s < scopes.length && !entry; s++) { entry = findFromEntry(scopes[s].from, parts[0].value); scope = scopes[s]; }; } else { scope = scopes[0]; var owners = []; var isAllKnown = scope.from.length > 0 && scope.from.every(function (f) { var known = fieldCheckTable(byName, f); if (!known || !Array.isArray(known.fields) || hasOpenFieldList(known)) return false; if (findFieldNamed(known, column)) owners.push(f); return true; }); entry = isAllKnown && owners.length === 1 ? owners[0] : null; }; if (!entry) return -1; var table = fieldCheckTable(byName, entry); var field = table && Array.isArray(table.fields) ? findFieldNamed(table, column) : null; if (!isNeverNullField(table, field) || isOuterJoined(core, scope.from, entry)) return -1; return chain.end - 1; }; function isInJoinGroups(tokens, idx, floor) { floor = floor || 0; var need = tokens[idx].depth; if (need <= floor) return false; for (var j = idx - 1; j >= 0 && need > floor; j--) { var t = tokens[j]; if (t.type !== 'punct' || t.value !== '(' || t.depth !== need - 1) continue; var k = j + 1; while (tokens[k] && tokens[k].type === 'comment') k++; var after = tokens[k]; if (!after || !(after.type === 'identifier' || (after.type === 'punct' && after.value === '('))) return false; need--; }; return need === floor; }; function valuesConstructorFindings(sql, core) { var findings = []; var isPlainName = function (k) { return !!core[k] && core[k].type === 'identifier' && !core[k].quote; }; for (var v = 0; v < core.length; v++) { if (!isPunctToken(core[v], '(') || !isKw(core[v + 1], 'VALUES')) continue; var valuesClose = matchParen(core, v); if (!isPunctToken(core[valuesClose], ')')) continue; var rows = []; var r = v + 2; var isRowsRead = true; while (r < valuesClose && isPunctToken(core[r], '(')) { var rowClose = matchParen(core, r); if (!isPunctToken(core[rowClose], ')') || rowClose > valuesClose) { isRowsRead = false; break; }; var values = []; var from = r + 1; for (var c = r + 1; c <= rowClose; c++) { if (c === rowClose || (isPunctToken(core[c], ',') && core[c].depth === core[r].depth + 1)) { if (c > from) values.push(sql.slice(core[from].start, core[c - 1].end)); from = c + 1; }; }; rows.push(values); r = rowClose + 1; if (isPunctToken(core[r], ',')) r++; }; if (r !== valuesClose) isRowsRead = false; var a = valuesClose + 1; var hasAs = isKw(core[a], 'AS'); if (hasAs) a++; var aliasTok = isPlainName(a) || (core[a] && core[a].type === 'identifier') ? core[a] : null; var names = []; var listClose = -1; if (aliasTok && isPunctToken(core[a + 1], '(')) { listClose = matchParen(core, a + 1); for (var n = a + 2; n < listClose; n++) { if (core[n].type === 'identifier') names.push(core[n].raw); else if (!isPunctToken(core[n], ',')) isRowsRead = false; }; }; var valuesFix = null; var isEven = isRowsRead && rows.length > 0 && names.length > 0 && rows.every(function (row) { return row.length === names.length; }); if (isEven && listClose !== -1) { var hasComment = tokenize(sql.slice(core[v].start, core[listClose].end)).some(function (t) { return t.type === 'comment'; }); var selects = rows.map(function (row, ri) { return 'SELECT ' + row.map(function (value, ci) { return ri === 0 ? value + ' AS ' + names[ci] : value; }).join(', '); }); valuesFix = [{ title: 'Write the rows as SELECT ... UNION ALL', edits: [{ start: core[v].start, end: core[listClose].end, text: '(' + selects.join(' UNION ALL ') + ')' + (hasAs ? ' AS ' : ' ') + aliasTok.raw }], isSafe: !hasComment, isInFixAll: !hasComment }]; }; findings.push(['MCE063', 'error', 'Marketing Cloud Engagement refuses a VALUES table constructor, as its query check reads VALUES as a Data Extension name: VALUES is not a known data extension or system data view. Write the rows as SELECT ... UNION ALL SELECT ....', DOCS.FROM, core[v + 1], core[v + 1], valuesFix]); v = valuesClose; }; return findings; }; function systemViewFindings(sql, core) { var findings = []; var commas = null; for (var k = 1; k + 2 < core.length; k++) { var schemaWord = core[k]; if (schemaWord.type !== 'identifier' || schemaWord.quote || (schemaWord.upper !== 'SYS' && schemaWord.upper !== 'INFORMATION_SCHEMA')) continue; if (!isPunctToken(core[k + 1], '.') || !isName(core[k + 2])) continue; var before = core[k - 1]; var isTablePlace = isKw(before, 'FROM') || isKw(before, 'JOIN') || isKw(before, 'APPLY'); if (!isTablePlace && isPunctToken(before, ',')) { if (!commas) commas = fromListCommas(core); isTablePlace = commas[k - 1] === true; }; if (!isTablePlace) continue; var name = sql.slice(schemaWord.start, core[k + 2].end) + (isPunctToken(core[k + 3], '(') ? '(' : ''); var word = schemaWord.upper === 'SYS' ? 'sys' : 'INFORMATION_SCHEMA'; findings.push(['MCE063', 'error', 'MCE refuses system views: \'' + word + '\' is a reserved word and may not appear in your query. The ' + name + ' table has a multi-part specification. This is not allowed. ' + name + ' is not a known data extension or system data view. Remove it.', DOCS.FROM, schemaWord, core[k + 2]]); }; return findings; }; function xmlNodesFinding(sql, significant, start) { var k = start; if (!isName(significant[k])) return null; while (isPunctToken(significant[k + 1], '.') && isName(significant[k + 2])) k += 2; var method = significant[k]; var open = significant[k + 1]; if (k === start || method.quote || method.upper !== 'NODES' || !isPunctToken(open, '(')) return null; var text = sql.slice(significant[start].start, open.end); var from = open.end; for (var j = k + 2; j < significant.length; j++) { var arg = significant[j]; if ((isPunctToken(arg, ')') && arg.depth === open.depth) || (isPunctToken(arg, ',') && arg.depth === open.depth + 1)) break; if (arg.type === 'string') { text += sql.slice(from, arg.start) + (/^N/i.test(arg.value) ? '' : 'N') + arg.value; from = arg.end; } else { text += sql.slice(from, arg.end); from = arg.end; }; }; return ['MCE067', 'error', 'MCE refuses .nodes() after APPLY: The ' + text + ' table has a multi-part specification. This is not allowed. ' + text + ' is not a known data extension or system data view. Read each value with .value() in the select list instead.', DOCS.FROM, significant[start], method]; }; function parenQueryFindings(sql, core, schema) { var findings = []; var n = core.length; var places = {}; MCE077_PLACES.forEach(function (name) { places[name] = true; }); var matches = parenMatchesOf(core); var isOpen = function (k) { return isPunctToken(core[k], '('); }; var isOperatorAt = function (k) { return !!core[k] && core[k].type === 'keyword' && SET_OPERATOR_WORDS[core[k].upper] === 1; }; var isAfterOperator = function (k) { return isOperatorAt(k - 1) || (isKw(core[k - 1], 'ALL') && isKw(core[k - 2], 'UNION')); }; var holdsMemo = {}; var holds = function (k) { if (!Object.prototype.hasOwnProperty.call(holdsMemo, k)) holdsMemo[k] = parenHoldsQuery(core, k, n); return holdsMemo[k]; }; var fromCommas = null; var tablePlaceAt = function (k) { var before = core[k - 1]; if (isKwAny(before, ['FROM', 'JOIN', 'APPLY'])) return before.upper; if (!isPunctToken(before, ',')) return ''; if (!fromCommas) fromCommas = fromListCommas(core); return fromCommas[k - 1] === true ? 'COMMA' : ''; }; var upMemo = {}; var placeAbove = function (k) { if (Object.prototype.hasOwnProperty.call(upMemo, k)) return upMemo[k]; var place = ''; if (!holds(k)) place = ''; else if (k === 0) place = 'START'; else if (isAfterOperator(k)) place = ''; else if (isOpen(k - 1)) place = placeAbove(k - 1); else place = tablePlaceAt(k); upMemo[k] = place; return place; }; var groupPlaceOf = function (p) { var group = p - 1; if (!isOpen(group) || holds(group)) return ''; var place = tablePlaceAt(group); if (!place) return ''; var h = p; while (isOpen(h) && !holds(h)) h++; return isOpen(h) ? place : ''; }; var used = null; var nextNumber = 1; var freshName = function () { if (!used) { used = {}; core.forEach(function (t) { if (t.type === 'identifier') used[String(t.value).replace(/^\[|\]$/g, '').toLowerCase()] = true; }); }; while (used['b' + nextNumber]) nextNumber++; var name = 'b' + nextNumber; used[name] = true; return name; }; var isBlank = function (text) { return /^\s*$/.test(text); }; var removalEdits = function (p, c) { var open = core[p]; var close = core[c]; var first = core[p + 1]; var last = core[c - 1]; var openEnd = isBlank(sql.slice(open.end, first.start)) ? first.start : open.end; var closeStart = isBlank(sql.slice(last.end, close.start)) ? last.end : close.start; var before = sql.charAt(open.start - 1); var after = sql.charAt(close.end); return [ { start: open.start, end: openEnd, text: /[A-Za-z0-9_\])'"]/.test(before) ? ' ' : '' }, { start: closeStart, end: close.end, text: /[A-Za-z0-9_\[('"]/.test(after) ? ' ' : '' } ]; }; var parseCtx = null; var isDerivedPossible = function (p, c) { if (!parseCtx) { parseCtx = { schemaIndex: ensureIndex(schema), ctes: {} }; }; var shape; try { shape = parseSelect(core, p + 1, c, parseCtx, sql); } catch (e) { return false; }; if (shape.noSelect || !shape.selectList.items.length) return false; var seen = {}; return shape.selectList.items.every(function (item) { if (item.kind === 'star') return true; if (item.kind !== 'column' && item.kind !== 'alias') return false; var key = String(item.name).toLowerCase(); if (seen[key]) return false; seen[key] = true; return true; }); }; var ownFacts = function (open, close) { var facts = { operators: [], hasTop: false, hasOrder: false, hasFor: false }; for (var k = open + 1; k < close; k++) { var t = core[k]; if (isOpen(k)) { if (!(matches[k] > k) || matches[k] >= close) break; k = matches[k]; continue; }; if (isOperatorAt(k)) facts.operators.push(t.upper); else if (isKw(t, 'TOP')) facts.hasTop = true; else if (isKw(t, 'ORDER') && isKw(core[k + 1], 'BY')) facts.hasOrder = true; else if (t.type === 'identifier' && !t.quote && (t.upper === 'OFFSET' || t.upper === 'FETCH')) facts.hasOrder = true; else if (isKw(t, 'FOR') && core[k + 1] && ['XML', 'JSON', 'BROWSE'].indexOf(core[k + 1].upper) !== -1) facts.hasFor = true; }; return facts; }; var parenRemovalBlock = function (facts, next) { if (facts.hasFor) return 'FOR'; if (!next || isPunctToken(next, ')') || isPunctToken(next, ';')) return ''; if (isPunctToken(next, '(')) return 'PAREN'; if (next.type === 'keyword' && SET_OPERATOR_WORDS[next.upper] === 1) { if (facts.hasOrder) return 'ORDER'; if (next.upper === 'INTERSECT' && facts.operators.some(function (op) { return op !== 'INTERSECT'; })) return 'GROUPING'; return ''; }; var isTail = (isKw(next, 'ORDER') || isKw(next, 'FOR') || isKw(next, 'OPTION') || (next.type === 'identifier' && !next.quote && next.upper === 'OFFSET')); if (!isTail) return 'OTHER'; if (facts.hasOrder) return 'ORDER'; if (facts.hasTop) return 'TOP'; return ''; }; var derivedAdvice = 'use SELECT b1.* FROM (...) AS b1.'; var tailFacts = function (from) { var facts = { hasOrder: false, hasOffset: false, hasQualified: false }; var depth = 0; var isInOperand = false; for (var k = from; k < n; k++) { var t = core[k]; if (isOpen(k)) { if (holds(k) && matches[k] > k) { k = matches[k]; continue; }; depth++; continue; }; if (isPunctToken(t, ')')) { if (depth === 0) break; depth--; continue; }; if (depth === 0 && isPunctToken(t, ';')) break; if (depth === 0 && isKw(t, 'SELECT')) isInOperand = true; if (depth === 0 && isKw(t, 'ORDER') && isKw(core[k + 1], 'BY')) { facts.hasOrder = true; isInOperand = false; }; if (depth === 0 && t.type === 'identifier' && !t.quote && t.upper === 'OFFSET') facts.hasOffset = true; if (!isInOperand && (t.type === 'identifier' || t.type === 'keyword') && isPunctToken(core[k + 1], '.')) facts.hasQualified = true; }; return facts; }; var plainTopOf = function (open, close) { for (var k = open + 1; k < close; k++) { if (isOpen(k)) { if (!(matches[k] > k) || matches[k] >= close) return ''; k = matches[k]; continue; }; if (!isKw(core[k], 'TOP')) continue; var count = core[k + 1]; var after = k + 2; var text = ''; if (count && count.type === 'number') { text = String(count.value); } else if (isOpen(k + 1) && core[k + 2] && core[k + 2].type === 'number' && isPunctToken(core[k + 3], ')')) { text = '(' + core[k + 2].value + ')'; after = k + 4; } else { return ''; }; var following = core[after]; if (following && !following.quote && (following.upper === 'PERCENT' || following.upper === 'WITH')) return ''; return 'TOP ' + text + ' '; }; return ''; }; var tableMessage = function (place) { return place === 'COMMA' ? 'Marketing Cloud Engagement\'s query check reads (SELECT in FROM as a table name: (SELECT is not a known data extension or system data view. ' : 'Marketing Cloud Engagement\'s query check reads (SELECT after ' + place + ' ( as a table name: (SELECT is not a known data extension or system data view. '; }; var queryRunFinding = function (p0, select, place) { var layers = []; for (var q = p0; q < select; q++) { layers.push({ open: q, close: parenCloseIndex(core, q, q + 1, n) }); }; var advice = place === 'START' ? 'Remove the parentheses around the first SELECT.' : 'Remove the parentheses around the SELECT.'; var fixes = null; var isClosed = layers.every(function (layer) { return layer.close !== -1; }); if (isClosed) { var blocks = []; var nexts = []; var removeEdits = []; layers.forEach(function (layer, i) { var after = layer.close + 1; var isInRemoved = i > 0 && after === layers[i - 1].close && blocks[i - 1] === ''; nexts[i] = isInRemoved ? nexts[i - 1] : after; blocks[i] = parenRemovalBlock(ownFacts(layer.open, layer.close), core[nexts[i]]); if (blocks[i] === '') removeEdits = removeEdits.concat(removalEdits(layer.open, layer.close)); }); fixes = []; if (removeEdits.length) { fixes.push({ title: 'Remove the parentheses', edits: removeEdits, isSafe: true, isInFixAll: true }); }; var outer = blocks[0]; if (outer === 'ORDER' || outer === 'GROUPING' || outer === 'TOP') { var next = core[nexts[0]]; if (outer === 'ORDER') advice = 'Its ORDER BY would sort the whole query: ' + derivedAdvice; else if (outer === 'GROUPING') advice = 'Its operators would group differently: ' + derivedAdvice; else advice = (isKw(next, 'ORDER') || (next.type === 'identifier' && next.upper === 'OFFSET') ? 'Its TOP would then take the ORDER BY: ' : 'Its TOP would then take what follows: ') + derivedAdvice; var tail = tailFacts(nexts[0]); var isOrderNext = place === 'START' && isKw(core[nexts[0]], 'ORDER') && !tail.hasOffset; var outerTop = isOrderNext ? plainTopOf(layers[0].open, layers[0].close) : ''; var isTopMissing = isOrderNext && !outerTop; if (tail.hasQualified) advice += ' Then name its columns after the ) by b1, as b1.a.'; else if (isTopMissing) advice += ' The ORDER BY after it still needs a TOP of its own, as the query\'s own ORDER BY does (MCE013).'; if (!tail.hasQualified && isDerivedPossible(layers[0].open, layers[0].close)) { var name = freshName(); fixes.push({ title: 'Make it a derived table', edits: [ { start: core[layers[0].open].start, end: core[layers[0].open].start, text: 'SELECT ' + outerTop + name + '.* FROM ' }, { start: core[layers[0].close].end, end: core[layers[0].close].end, text: ' AS ' + name } ], isSafe: false, isInFixAll: false }); }; }; if (!fixes.length) fixes = null; }; var message; if (place === 'START') { message = 'Marketing Cloud Engagement\'s query check refuses a query that starts with (: Select must be the first word of the query. ' + advice; } else { message = tableMessage(place) + advice; }; return ['MCE077', 'error', message, DOCS.SFREF, core[p0], core[select], fixes]; }; var groupRunFinding = function (p, select, place) { var groups = []; for (var g = p - 1; isOpen(g) && !holds(g); g++) { groups.push({ open: g, close: parenCloseIndex(core, g, g + 1, n) }); }; var fixes = null; var advice = 'Remove the parentheses around the join.'; if (place === 'JOIN' || place === 'APPLY') { advice = 'Write the join without these parentheses, or start them with a Data Extension.'; } else if (groups.every(function (group) { return group.close !== -1; })) { var edits = []; groups.forEach(function (group) { edits = edits.concat(removalEdits(group.open, group.close)); }); fixes = [{ title: 'Remove the parentheses around the join', edits: edits, isSafe: true, isInFixAll: true }]; }; return ['MCE077', 'error', tableMessage(place) + advice, DOCS.SFREF, core[p], core[select], fixes]; }; var p = 0; while (p < n) { var select = isOpen(p) ? parenRunToSelect(core, p, n) : -1; if (select === -1) { p++; continue; }; var place = p === 0 ? 'START' : (isOpen(p - 1) ? placeAbove(p - 1) : ''); if (place) { if (places[place]) findings.push(queryRunFinding(p, select, place)); p = select; continue; }; var groupPlace = groupPlaceOf(p); if (groupPlace) { if (places.GROUP && places[groupPlace]) findings.push(groupRunFinding(p, select, groupPlace)); p = select; continue; }; p++; }; return findings; }; function applyDisjointEdits(sql, edits) { var sorted = edits.slice().sort(function (a, b) { return a.start - b.start; }); var parts = []; var at = 0; for (var i = 0; i < sorted.length; i++) { var e = sorted[i]; if (e.start < at || (i > 0 && e.start === sorted[i - 1].start)) return null; parts.push(sql.slice(at, e.start), e.text); at = e.end; }; parts.push(sql.slice(at)); return parts.join(''); }; function commaJoinParenFixes(sql, edits, schema) { var places = []; var joinAt = {}; var shift = 0; var isOrdered = true; edits.forEach(function (e, i) { var start = e.start + shift; shift += e.text.length - (e.end - e.start); places.push({ start: start, end: start + e.text.length, shift: shift }); joinAt[start + e.text.indexOf('JOIN')] = i; if (i > 0 && e.start < edits[i - 1].end) isOrdered = false; }); if (!isOrdered) return []; var edited = applyDisjointEdits(sql, edits); if (edited === null) return []; var core = withoutComments(tokenize(edited)); var indexAt = {}; core.forEach(function (t, k) { indexAt[t.start] = k; }); var firstEndingAfter = function (offset) { var low = 0; var high = places.length; while (low < high) { var mid = (low + high) >> 1; if (places[mid].end > offset) high = mid; else low = mid + 1; }; return low; }; var original = function (offset) { var i = firstEndingAfter(offset); return offset - (i > 0 ? places[i - 1].shift : 0); }; var taken = []; parenQueryFindings(edited, core, schema).forEach(function (f) { var fix = f[6] && f[6][0]; if (!(fix && fix.isSafe && fix.isInFixAll !== false)) return; var k = indexAt[f[4].start]; if (k === undefined) return; while (k > 0 && isPunctToken(core[k - 1], '(')) k--; if (k === 0 || !isKw(core[k - 1], 'JOIN') || joinAt[core[k - 1].start] === undefined) return; var isClear = fix.edits.every(function (e) { var i = firstEndingAfter(e.start); return i === places.length || places[i].start >= e.end; }); if (!isClear) return; taken.push({ index: joinAt[core[k - 1].start], title: fix.title, edits: fix.edits.map(function (e) { return { start: original(e.start), end: original(e.end), text: e.text }; }), inEdited: fix.edits }); }); if (!taken.length) return []; var moved = []; var inEdited = []; taken.forEach(function (x) { x.edits.forEach(function (e) { moved.push(e); }); x.inEdited.forEach(function (e) { inEdited.push(e); }); }); var composed = applyDisjointEdits(sql, edits.concat(moved)); if (composed === null || composed !== applyDisjointEdits(edited, inEdited)) return []; if (parseStrict(composed).errors.length > parseStrict(edited).errors.length) return []; return taken.map(function (x) { return { index: x.index, title: x.title, edits: x.edits }; }); }; var SYSTEM_FUNCTIONS_RUN = ['@@VERSION', '@@SPID', '@@NESTLEVEL', '@@LANGUAGE', '@@LANGID', '@@DATEFIRST', '@@MAX_PRECISION', '@@TEXTSIZE', '@@OPTIONS', '@@LOCK_TIMEOUT', '@@TRANCOUNT', '@@ROWCOUNT', '@@ERROR', '@@IDENTITY', '@@DBTS', '@@CONNECTIONS', '@@CPU_BUSY', '@@IDLE', '@@IO_BUSY', '@@MAX_CONNECTIONS', '@@PACK_RECEIVED', '@@PACK_SENT', '@@PACKET_ERRORS', '@@REMSERVER', '@@TIMETICKS', '@@TOTAL_ERRORS', '@@TOTAL_READ', '@@TOTAL_WRITE', '@@CURSOR_ROWS', '@@FETCH_STATUS']; var SYSTEM_FUNCTIONS_REFUSED = ['@@SERVERNAME', '@@SERVICENAME', '@@PROCID']; var RESERVED_CALLS = ['DB_NAME', 'SERVERPROPERTY', 'APP_NAME', 'DATABASEPROPERTYEX', 'DB_ID', 'ORIGINAL_DB_NAME', 'OBJECT_ID']; var RESERVED_IN_BRACKETS = ['EXEC', 'SYSTEM_USER', 'CURRENT_USER']; var RESERVED_ALIAS_ENDS = ['FROM', 'WHERE', 'GROUP', 'ORDER', 'HAVING', 'UNION', 'EXCEPT', 'INTERSECT', 'JOIN', 'INNER', 'LEFT', 'RIGHT', 'FULL', 'CROSS', 'OUTER', 'ON', 'WITH', 'OPTION', 'FOR', 'PIVOT', 'UNPIVOT', 'TABLESAMPLE']; function isReservedAliasAt(core, i) { var word = core[i]; if (!word || word.type !== 'keyword' || !isKw(core[i - 1], 'AS') || !RESERVED_SET[word.upper]) return false; var after = core[i + 1]; return !after || (after.type === 'punct' && (after.value === ',' || after.value === ';' || after.value === ')')) || (after.type === 'keyword' && RESERVED_ALIAS_ENDS.indexOf(after.upper) !== -1); }; var RULES = [ function rule001(sql, tokens, ctx) { var findings = []; var first = tokens.filter(function (t) { return t.type !== 'comment'; })[0]; if (first && first.type === 'keyword' && STATEMENT_START_DISALLOWED.indexOf(first.upper) !== -1) { findings.push(['MCE001', 'error', 'This statement is not a SELECT query. Use a SELECT query instead. Marketing Cloud Engagement Query Activities cannot run INSERT, UPDATE, DELETE, DECLARE, EXEC or control-flow statements.', DOCS.BASICS, first, first]); }; return findings; }, function rule002Text(sql, tokens, ctx) { var findings = []; if (sql.indexOf(';') === -1) return findings; var core = ctx.getCoreTokens(); var names = null; var BINDING_FREE = ['+', '=', '<>', '!=', '<', '>', '<=', '>=', '!<', '!>']; core.forEach(function (t, k) { if (t.type !== 'string' || t.unterminated || t.value.indexOf(';') === -1) return; var first = t.start + t.value.indexOf(';'); var last = t.start + t.value.lastIndexOf(';') + 1; if (!names) { names = {}; ctx.getSelects().forEach(function (sel) { sel.selectList.items.forEach(function (item) { if (item.kind === 'alias') names[item.nameStart] = true; }); }); }; var open = -1; for (var b = k - 1; b >= 0; b--) { if (isPunctToken(core[b], '(') && core[b].depth === t.depth - 1) { open = b; break; }; }; var callName = open > 0 && (core[open - 1].type === 'identifier' || core[open - 1].type === 'keyword') ? core[open - 1] : null; var isLiteralOnly = names[t.start] === true || (!!callName && (isPunctToken(core[open - 2], '.') || (!callName.quote && ['PATH', 'ROOT', 'RAW'].indexOf(callName.upper) !== -1))); var fixes; if (!isLiteralOnly) { var prefix = /^N/i.test(t.value) ? t.value.charAt(0) : ''; var parts = t.value.slice(prefix.length + 1, -1).split(';'); var pieces = []; parts.forEach(function (part, p) { if (part !== '') pieces.push(prefix + '\'' + part + '\''); if (p < parts.length - 1) pieces.push('CHAR(59)'); }); var before = core[k - 1]; var after = core[k + 1]; var isOperatorNear = function (x) { return !!x && x.type === 'operator' && BINDING_FREE.indexOf(x.value) === -1; }; var needsParens = pieces.length > 1 && (isOperatorNear(before) || isOperatorNear(after) || isKw(after, 'COLLATE') || (!!before && !before.quote && before.upper === 'ZONE')); var text = pieces.join(' + '); fixes = [{ title: 'Write the ; as CHAR(59)', edits: formatLaidOutEdits(sql, [{ start: t.start, end: t.end, text: needsParens ? '(' + text + ')' : text }], ctx), isSafe: true, isInFixAll: true }]; }; findings.push(['MCE002', 'error', 'Marketing Cloud Engagement\'s query check refuses a ; inside a text value too: \';\' is a reserved word and may not appear in your query. ' + (isLiteralOnly ? 'Take the ; out of this text.' : 'Write it as CHAR(59), as in \'a\' + CHAR(59) + \'b\'.'), DOCS.SFREF, first, last, fixes]); }); return findings; }, function rule003(sql, tokens) { var findings = []; tokens.forEach(function (t) { if (t.type !== 'variable') return; if (SYSTEM_FUNCTIONS_RUN.indexOf(t.upper) !== -1 || SYSTEM_FUNCTIONS_REFUSED.indexOf(t.upper) !== -1) return; findings.push(['MCE003', 'error', 'Variables are not supported in Marketing Cloud Engagement SQL. Replace ' + t.value + ' with a literal value or a CASE expression.', DOCS.SEL, t, t]); }); return findings; }, function rule004(sql, tokens) { var findings = []; var coveredTemp = {}; tokens.forEach(function (t, i) { if (t.type === 'temp') { if (coveredTemp[i]) return; findings.push(['MCE004', 'error', 'Temporary tables and SELECT INTO are not supported. Results go to the target Data Extension set on the Query Activity.', DOCS.SFREF, t, t]); } else if (t.type === 'keyword' && t.upper === 'INTO') { var next = i + 1; while (tokens[next] && tokens[next].type === 'comment') next++; var isTempTarget = !!tokens[next] && tokens[next].type === 'temp'; if (isTempTarget) coveredTemp[next] = true; findings.push(['MCE004', 'error', 'Temporary tables and SELECT INTO are not supported. Results go to the target Data Extension set on the Query Activity.', DOCS.SFREF, t, isTempTarget ? tokens[next] : t]); }; }); return findings; }, function rule005(sql, tokens, ctx) { var findings = []; var core = ctx.getCoreTokens(); core.forEach(function (t, i) { if (t.type === 'identifier' && !t.quote && /^(sp_|xp_)/i.test(t.value) && isPunctToken(core[i + 1], '(') && !isPunctToken(core[i - 1], '.') && !isNamedList(core, i, i)) { findings.push(['MCE005', 'error', 'Stored procedures and user-defined functions do not run in Marketing Cloud Engagement SQL. Remove this call. Use a built-in function or CASE expression instead.', DOCS.SFREF, t, t]); } else if (t.type === 'keyword' && (t.upper === 'EXEC' || t.upper === 'EXECUTE') && !isReservedAliasAt(core, i)) { var procedure = core[i + 1] && isName(core[i + 1]) ? core[i + 1] : t; findings.push(['MCE005', 'error', 'Stored procedures and user-defined functions do not run in Marketing Cloud Engagement SQL. Remove this call. Use a built-in function or CASE expression instead.', DOCS.SFREF, t, procedure]); } else if (t.type === 'identifier' && !t.quote && t.upper === 'DBO' && isPunctToken(core[i + 1], '.') && isName(core[i + 2]) && isPunctToken(core[i + 3], '(') && !isNamedList(core, i, i + 2)) { findings.push(['MCE005', 'error', 'Stored procedures and user-defined functions do not run in Marketing Cloud Engagement SQL. Remove this call. Use a built-in function or CASE expression instead.', DOCS.SFREF, t, core[i + 2]]); }; }); return findings; }, function rule007(sql, tokens, ctx) { var findings = []; var significant = tokens.filter(function (t) { return t.type !== 'comment'; }); var first = significant[0]; if (first && isKw(first, 'WITH')) { var fix = cteSubqueryFix(sql, significant, ctx.schema); findings.push(['MCE007', 'error', 'Marketing Cloud Engagement\'s query check refuses a query that starts with WITH, and reads the CTE name as an unknown Data Extension. Rewrite the CTE as a subquery in FROM.', DOCS.SFREF, first, first, fix ? [fix] : []]); }; return findings; }, function rule008and009(sql, tokens, ctx) { var findings = []; var parsed = ctx.getParsed(); if (!parsed) return findings; var branchEnd = parsed.clauses.union && parsed.clauses.union[0] ? parsed.clauses.union[0].end : sql.length; var ownDepth = 0; if (parsed.clauses.union && parsed.clauses.union[0] && parsed.clauses.union[0].isParenthesised) { var ownSelect = tokens[tokenIndexAtOrAfter(tokens, parsed.clauses.union[0].start)]; ownDepth = ownSelect ? ownSelect.depth : 0; }; var hasJoin = null; var hasOwnJoin = function () { if (hasJoin === null) { hasJoin = tokens.some(function (t, idx) { return t.type === 'keyword' && (t.upper === 'JOIN' || t.upper === 'APPLY') && (t.depth === ownDepth || isInJoinGroups(tokens, idx, ownDepth)) && t.start >= parsed.selectList.start && t.end <= branchEnd; }); }; return hasJoin; }; parsed.selectList.items.forEach(function (item) { if (item.kind !== 'star') return; if (!item.starTable && hasOwnJoin()) { var joined = bareStarColumns(parsed); if (!joined) { findings.push(['MCE008', 'warning', 'This * takes every column of each table in FROM, and Marketing Cloud Engagement refuses the query where two of them share a name, as it runs it inside another one. List the columns you need.', DOCS.SEL, item, item]); return; }; var seenName = {}; var repeated = null; joined.forEach(function (name) { var key = String(name).toLowerCase(); if (seenName[key] && !repeated) repeated = name; seenName[key] = true; }); if (repeated) { findings.push(['MCE008', 'error', 'This * takes every column of each table in FROM, so it gives two columns named ' + repeated + ': The column \'' + repeated + '\' was specified multiple times for \'Qry\'. List the columns you need.', DOCS.SEL, item, item]); return; }; }; if (!starHasUnderscoreField(item) && !(!item._certainColumns && underscoreFieldOfOpenStar(item, parsed.from, ctx.schema))) { var fixes = []; var listed = listableStarColumns(item); if (listed) { var qualifier = item.starPrefix || ''; var listText = listed.map(function (name) { return qualifier + formatIdentifier(name); }).join(', '); fixes.push({ title: 'List the columns', edits: [{ start: item.start, end: item.end, text: listText }], isSafe: true, isInFixAll: false }); }; findings.push(['MCE009', 'warning', 'SELECT * fixes the column list when the Query Activity is saved, so columns added to the Data Extension later are ignored. List the columns you need.', DOCS.SEL, item, item, fixes]); }; }); var firstCount = selectWidth(parsed.selectList.items); var firstStars = starTablesOf(parsed.selectList.items, parsed.from); laterBranches(ctx.getCoreTokens(), parsed, ctx.getScopeReadings()).forEach(function (branch) { if (!branch.hasStar || !branch.reading) return; if (firstCount !== null && branch.count !== null && branch.count !== firstCount) return; var ownStars = starTablesOf(branch.reading.scope.selectList.items, branch.reading.scope.from); if (firstStars && ownStars && firstStars === ownStars) return; branch.reading.scope.selectList.items.forEach(function (item) { if (item.kind !== 'star') return; var fixes = []; var listed = listableStarColumns(item); if (listed) { fixes.push({ title: 'List the columns', edits: [{ start: item.start, end: item.end, text: listed.map(function (name) { return (item.starPrefix || '') + formatIdentifier(name); }).join(', ') }], isSafe: true, isInFixAll: false }); }; findings.push(['MCE009', 'warning', 'This * takes every column of its table, so a column added to the table later makes this branch wider than the first, and the query fails. List the columns you need.', DOCS.SEL, item, item, fixes]); }); }); return findings; }, function rule010(sql, tokens, ctx) { var findings = []; var parsed = ctx.getParsed(); if (!parsed) return findings; var extracted = ctx.getExtracted(); var usedLower = {}; extracted.columns.forEach(function (name) { if (name) usedLower[String(name).toLowerCase()] = true; }); parsed.selectList.items.forEach(function (item) { if (item.kind === 'unnamed') { var itemToks = item.tokens || []; var hasDanglingAs = itemToks.length >= 2 && isKw(itemToks[itemToks.length - 2], 'AS'); if (hasDanglingAs && itemToks[itemToks.length - 1].type === 'keyword') return; var fixes = []; if (!hasDanglingAs && !isUnfinishedItem(itemToks)) { var baseName = deriveAliasBaseName(itemToks); var newName = uniqueOutputName(baseName, usedLower); var alias = formatIdentifier(newName); fixes.push({ title: 'Add alias ' + newName, edits: [{ start: item.end, end: item.end, text: ' AS ' + alias }], isSafe: true, isInFixAll: true }); }; findings.push(['MCE010', 'error', 'This column has no name, so MCE cannot map it to a Data Extension field. Add an alias with AS' + (fixes.length ? ', or use the Add alias fix.' : '.'), DOCS.SEL, item, item, fixes]); }; }); columnListOwners(ctx.getCoreTokens(), ctx.getScopeReadings()).forEach(function (owner) { var items = owner.reading.scope.selectList.items; var usedNames = {}; items.forEach(function (item) { if (item.name) usedNames[String(item.name).toLowerCase()] = true; }); var position = 0; items.forEach(function (item) { if (position !== null) position = item.kind === 'star' ? (item._certainColumns ? position + item._certainColumns.length : null) : position + 1; if (item.kind !== 'unnamed') return; var itemToks = item.tokens || []; if (itemToks.length >= 2 && isKw(itemToks[itemToks.length - 2], 'AS')) return; var newName = uniqueOutputName(deriveAliasBaseName(itemToks), usedNames); var where = position === null ? 'a column of \'' + owner.label + '\'' : 'column ' + position + ' of \'' + owner.label + '\''; var fixes = isUnfinishedItem(itemToks) ? [] : [{ title: 'Add alias ' + newName, edits: [{ start: item.end, end: item.end, text: ' AS ' + formatIdentifier(newName) }], isSafe: true, isInFixAll: true }]; findings.push(['MCE010', 'error', 'This column of ' + owner.label + ' has no name: No column name was specified for ' + where + '. Add an alias with AS' + (fixes.length ? ', or use the Add alias fix.' : '.'), DOCS.SEL, item, item, fixes]); }); }); return findings; }, function rule011(sql, tokens, ctx) { var findings = []; columnListOwners(ctx.getCoreTokens(), ctx.getScopeReadings()).forEach(function (owner) { var items = owner.reading.scope.selectList.items; var usedNames = {}; var seen = {}; items.forEach(function (item) { (item.kind === 'star' ? item._resolvedColumns || [] : [item.name]).forEach(function (name) { if (name) usedNames[String(name).toLowerCase()] = true; }); }); items.forEach(function (item) { var names = item.kind === 'star' ? item._certainColumns || [] : (item.kind === 'column' || item.kind === 'alias' ? [item.name] : []); names.forEach(function (name) { if (name == null || name === '') return; var key = String(name).toLowerCase(); if (!seen[key]) { seen[key] = true; return; }; var fixes = []; if (item.kind === 'alias' || item.kind === 'column') { var newName = uniqueOutputName(String(name), usedNames); fixes.push({ title: 'Rename to ' + newName, edits: item.kind === 'alias' ? [{ start: item.nameStart, end: item.nameEnd, text: formatIdentifier(newName) }] : [{ start: item.end, end: item.end, text: ' AS ' + formatIdentifier(newName) }], isSafe: false, isInFixAll: false }); }; findings.push(['MCE011', 'error', 'Column "' + name + '" is in ' + owner.label + ' more than once: The column \'' + name + '\' was specified multiple times for \'' + owner.label + '\'. Give one of them another alias.', DOCS.SEL, item, item, fixes]); }); }); }); var extracted = ctx.getExtracted(); extracted.issues.forEach(function (issue) { var isUncertainStar = function (it) { return !!it && it.kind === 'star' && !it._certainColumns; }; var isUnnamed = issue.name == null || issue.name === ''; if (issue.code === 'DUPLICATE' && !isUnnamed && !isUncertainStar(extracted.items[issue.itemIndex]) && !isUncertainStar(extracted.items[issue.firstItemIndex])) { var item = extracted.items[issue.itemIndex]; var fixes = []; if (item && (item.kind === 'column' || item.kind === 'alias') && item.nameStart !== undefined) { var usedLower = {}; extracted.columns.forEach(function (name) { if (name) usedLower[String(name).toLowerCase()] = true; }); var newName = uniqueOutputName(item.name, usedLower); var text = formatIdentifier(newName); if (item.kind === 'alias') { fixes.push({ title: 'Rename to ' + newName, edits: [{ start: item.nameStart, end: item.nameEnd, text: text }], isSafe: true, isInFixAll: true }); } else { fixes.push({ title: 'Rename to ' + newName, edits: [{ start: item.end, end: item.end, text: ' AS ' + text }], isSafe: true, isInFixAll: true }); }; }; findings.push(['MCE011', 'error', 'Output column "' + issue.name + '" is used more than once: The column \'' + issue.name + '\' was specified multiple times for \'Qry\'. Give one of them another alias, as each column becomes a field of the target Data Extension.', DOCS.SEL, issue, issue, fixes]); }; }); return findings; }, function rule012(sql, tokens, ctx) { var findings = []; var extracted = ctx.getExtracted(); var parsed = ctx.getParsed(); var isTopUnfinished = !!(parsed && parsed.clauses && parsed.clauses.top && parsed.clauses.top.isUnfinished); extracted.issues.forEach(function (issue) { if (issue.code === 'EMPTY' && !isTopUnfinished) { var fixes = []; if (issue.itemIndex !== -1 && sql.charAt(issue.start - 1) === ',') { fixes.push({ title: 'Remove trailing comma', edits: [{ start: issue.start - 1, end: issue.start, text: '' }], isSafe: true, isInFixAll: true }); } else if (issue.itemIndex !== -1 && sql.charAt(issue.end) === ',') { fixes.push({ title: 'Remove trailing comma', edits: [{ start: issue.end, end: issue.end + 1, text: '' }], isSafe: true, isInFixAll: true }); }; findings.push(['MCE012', 'error', 'The select list is empty or ends with a trailing comma. Remove the trailing comma or add a column. The Query Activity column picker sometimes leaves one behind.', DOCS.SFBUILD, issue, issue, fixes]); }; }); return findings; }, function rule013(sql, tokens, ctx) { var findings = []; var selects = ctx.getSelects(); var core = ctx.getCoreTokens(); function isOrderByAllowedWithoutTop(orderBy) { var at = tokenIndexAtOrAfter(core, orderBy.start); var depth = core[at] ? core[at].depth : 0; for (var k = at; k < core.length && core[k].end <= orderBy.end; k++) { if (core[k].depth !== depth) continue; if (core[k].type === 'identifier' && !core[k].quote && core[k].upper === 'OFFSET') return true; if (isKw(core[k], 'FOR') && core[k + 1] && (core[k + 1].upper === 'XML' || core[k + 1].upper === 'JSON')) return true; }; return false; }; var TOP_MESSAGE = 'Marketing Cloud Engagement runs the query inside another one, so it refuses an ORDER BY with no TOP: The ORDER BY clause is invalid in views, inline functions, derived tables, subqueries, and common table expressions, unless TOP, OFFSET or FOR XML is also specified. A Data Extension keeps no row order: remove ORDER BY and sort the results in the grid, or add TOP to keep the first rows.'; var pushFinding = function (orderBy, message, severity) { findings.push(['MCE013', severity || 'error', message, DOCS.SEL, orderBy, orderBy, [ { title: 'Remove ORDER BY', edits: [{ start: removalStart(sql, tokens, orderBy.start), end: orderBy.end, text: '' }], isSafe: true, isInFixAll: false } ]]); }; selects.forEach(function (sel) { if (sel.orderBy && !sel.top && !sel.hasOperandTop && !isOrderByAllowedWithoutTop(sel.orderBy)) { pushFinding(sel.orderBy, sel.isTop ? TOP_MESSAGE : 'This ORDER BY has no TOP in the same subquery, derived table or CTE. Add TOP, or remove ORDER BY. SQL Server rejects a lone ORDER BY here as error 1033.'); }; }); var topSelect = selects.filter(function (sel) { return sel.isTop; })[0]; if (topSelect && !(topSelect.orderBy && !topSelect.hasOperandTop)) { var lastOperator = -1; var hasTop = !!topSelect.hasOperandTop; for (var k = 0; k < core.length; k++) { var t = core[k]; if (t.depth !== 0) continue; if (isKw(t, 'TOP')) hasTop = true; if (isPunctToken(t, ';')) break; if (isKwAny(t, ['UNION', 'EXCEPT', 'INTERSECT'])) lastOperator = k; if (lastOperator === -1 || !isKw(t, 'ORDER') || !isKw(core[k + 1], 'BY')) continue; var last = k + 1; while (core[last + 1] && !(core[last + 1].depth === 0 && (isPunctToken(core[last + 1], ';') || isKw(core[last + 1], 'OPTION')))) last++; var setOrderBy = { start: t.start, end: core[last].end }; if (isOrderByAllowedWithoutTop(setOrderBy)) break; var b = lastOperator + 1; if (isKw(core[b], 'ALL')) b++; while (isPunctToken(core[b], '(')) b++; var top = isKw(core[b], 'SELECT') ? (isKwAny(core[b + 1], ['ALL', 'DISTINCT']) ? b + 2 : b + 1) : -1; var isLastTop = top !== -1 && isKw(core[top], 'TOP'); if (isLastTop) { pushFinding(setOrderBy, 'This ORDER BY sorts the whole query, not the last SELECT, whose TOP may not count for it, as a TOP in the first SELECT did not: The ORDER BY clause is invalid in views, inline functions, derived tables, subqueries, and common table expressions, unless TOP, OFFSET or FOR XML is also specified. Remove ORDER BY and sort the results in the grid.', 'warning'); } else if (hasTop) { pushFinding(setOrderBy, 'A TOP in an earlier SELECT counts for that SELECT alone, and this ORDER BY sorts the whole query, which Marketing Cloud Engagement runs inside another one: The ORDER BY clause is invalid in views, inline functions, derived tables, subqueries, and common table expressions, unless TOP, OFFSET or FOR XML is also specified. Remove ORDER BY and sort the results in the grid.'); } else { pushFinding(setOrderBy, TOP_MESSAGE); }; break; }; }; return findings; }, function rule014(sql, tokens, ctx) { var findings = []; var selects = ctx.getSelects(); selects.forEach(function (sel) { if (sel.top && !sel.top.isUnfinished && !sel.orderBy) { findings.push(['MCE014', 'info', 'TOP without ORDER BY returns an arbitrary set of rows. Add ORDER BY so the same rows come back every time.', DOCS.SEL, sel.top, sel.top]); }; }); return findings; }, function rule015(sql, tokens, ctx) { var findings = []; var parsed = ctx.getParsed(); if (!parsed) return findings; var extracted = ctx.getExtracted(); var usedLower = {}; extracted.columns.forEach(function (name) { if (name) usedLower[String(name).toLowerCase()] = true; }); var unknownItems = ctx.getUnknownColumns().map(function (unknown) { return unknown.item; }); parsed.selectList.items.forEach(function (item) { if (item.kind === 'column' && unknownItems.indexOf(item) !== -1) { if (item.name != null && classifyBadColumnName(String(item.name))) noteRefusedTypo(ctx, item.start, 'MCE015', 'NAME'); return; }; if (item.kind === 'star') { var openField = item._certainColumns ? null : underscoreFieldOfOpenStar(item, parsed.from, ctx.schema); if (openField) { findings.push(['MCE015', 'error', 'This would output a field MCE cannot create on a Data Extension, because one of the table\'s fields, ' + openField + ', starts with an underscore. List the columns and alias that field instead.', DOCS.SEL, item, item]); return; }; if (!starHasUnderscoreField(item)) return; var qualifier = item.starPrefix || ''; var listed = listableStarColumns(item); var starFixes = []; if (listed) { var rowTexts = buildAliasedColumnRows(listed, function (name) { return qualifier + formatIdentifier(name); }, false); starFixes.push({ title: 'List the columns', edits: [{ start: item.start, end: item.end, text: rowTexts.join(', ') }], isSafe: true, isInFixAll: false }); }; findings.push(['MCE015', 'error', 'This would output a field MCE cannot create on a Data Extension, because one of the table\'s fields starts with an underscore. List the columns and alias that field instead.', DOCS.SEL, item, item, starFixes]); return; }; if (item.kind !== 'column' && item.kind !== 'alias') return; var name = item.name; if (name == null || !String(name).length) return; var nameStr = String(name); var reason = classifyBadColumnName(nameStr); if (!reason) return; if (reason === 'toolong') { findings.push(['MCE015', 'error', 'This output column name is ' + nameStr.length + ' characters long, and MCE cannot create a Data Extension field name longer than 128 characters. Rename it to something shorter.', DOCS.SEL, item, item]); return; }; if (reason === 'digits') { findings.push(['MCE015', 'error', 'MCE cannot create a Data Extension field named ' + nameStr + ', because a field name cannot be only digits. Rename it to include at least one letter.', DOCS.SEL, item, item]); return; }; if (reason === 'invalid') { var cleaned = asciiFieldName(nameStr).replace(/[^A-Za-z0-9 _.-]/g, '').replace(/^[\s_]+|\s+$/g, ''); var isLetterChanged = /[^\u0000-\u007f]/.test(nameStr.replace(/[^\p{L}]/gu, '')); var invalidFixes = []; if (cleaned.length && !MCE015_ALL_DIGITS.test(cleaned) && !usedLower[cleaned.toLowerCase()]) { usedLower[cleaned.toLowerCase()] = true; var invalidEdits = item.kind === 'alias' ? [{ start: item.nameStart, end: item.nameEnd, text: formatIdentifier(cleaned) }].concat(orderByAliasEdits(ctx.getCoreTokens(), nameStr, cleaned)) : [{ start: item.end, end: item.end, text: ' AS ' + formatIdentifier(cleaned) }]; invalidFixes.push({ title: 'Rename to ' + cleaned, edits: invalidEdits, isSafe: !isLetterChanged, isInFixAll: !isLetterChanged }); }; findings.push(['MCE015', 'error', 'MCE cannot create a Data Extension field named ' + nameStr + ', because field names can only contain the letters A to Z, digits, spaces, underscores, periods and hyphens. Rename it to remove the other characters.', DOCS.SEL, item, item, invalidFixes]); return; }; var newName = uniqueOutputName(nameStr.replace(/^_+/, ''), usedLower); var aliasText = formatIdentifier(newName); var underscoreEdits, message, fixTitle; if (item.kind === 'alias') { underscoreEdits = [{ start: item.nameStart, end: item.nameEnd, text: aliasText }].concat(orderByAliasEdits(ctx.getCoreTokens(), nameStr, newName)); fixTitle = 'Rename to ' + newName; message = 'MCE cannot create a Data Extension field named ' + nameStr + ', because field names cannot start with an underscore. Rename the alias to ' + newName + '.'; } else { underscoreEdits = [{ start: item.end, end: item.end, text: ' AS ' + aliasText }]; fixTitle = 'Add alias ' + newName; message = 'MCE cannot create a Data Extension field named ' + nameStr + ', because field names cannot start with an underscore. Add an alias such as AS ' + newName + '.'; }; findings.push(['MCE015', 'error', message, DOCS.SEL, item, item, [ { title: fixTitle, edits: underscoreEdits, isSafe: true, isInFixAll: true } ]]); }); return findings; }, function rule016(sql, tokens, ctx) { var findings = []; var parsed = ctx.getParsed(); if (!parsed) return findings; var core = ctx.getCoreTokens(); function isColumnName(list, i) { var t = list[i]; if (!t || t.type !== 'identifier') return false; var prevTok = list[i - 1], nextTok = list[i + 1]; if (nextTok && nextTok.type === 'punct' && (nextTok.value === '.' || nextTok.value === '(')) return false; // a qualifier or a function call if (isKw(prevTok, 'AS')) return false; // CAST(x AS Date) var callTok = list[i - 2]; var isFirstArgument = !!prevTok && prevTok.type === 'punct' && prevTok.value === '(' && !!callTok && !!(FORMATTER_DATE_FUNCS[callTok.upper] || FORMATTER_CONVERT_NAMES[callTok.upper]); return !isFirstArgument; // DATEADD(DAY, ...), CONVERT(DATE, ...) }; function tokenKey(t) { if (t.type === 'identifier' || t.type === 'keyword') return t.type.charAt(0) + ':' + t.upper; return t.type + ':' + t.value; }; var aliasItems = {}; var selfReadNames = {}; parsed.selectList.items.forEach(function (item) { if (item.kind !== 'alias') return; var ownTokens = aliasItemExpressionTokens(item); var chain = parseNameChain(ownTokens, 0, ownTokens.length); var lastPart = chain.parts[chain.parts.length - 1]; var upperName = String(item.name).toUpperCase(); var isSelfAliasedColumn = !!lastPart && chain.end === ownTokens.length && lastPart.type === 'identifier' && lastPart.upper === upperName; if (isSelfAliasedColumn) return; aliasItems[upperName] = item; for (var k = 0; k < ownTokens.length; k++) { if (isPunctToken(ownTokens[k], '(') && isKw(ownTokens[k + 1], 'SELECT')) { k = closingParenOf(ownTokens, k); // a subquery's names are its own if (k === -1) break; continue; }; if (ownTokens[k].upper === upperName && isColumnName(ownTokens, k)) selfReadNames[upperName] = true; }; }); if (!Object.keys(aliasItems).length) return findings; var shadowedNames = {}; Object.keys(selfReadNames).forEach(function (name) { shadowedNames[name] = true; }); var hasUnknownSource = false; var cteColumns = {}; (parsed.ctes || []).forEach(function (c) { if (c.name) cteColumns[c.name.toUpperCase()] = c.columns || []; }); var lastPivot = -1; var taken = {}; parsed.from.forEach(function (f, k) { if (!isPivotEntry(core, f)) return; lastPivot = k; var pivot = pivotColumnsOf(core, f); (pivot ? pivot.taken : []).forEach(function (n) { taken[String(n).toUpperCase()] = true; }); }); parsed.from.forEach(function (f, k) { var names = null; var before = core[tokenIndexAtOrAfter(core, f.start) - 1]; if (isPivotEntry(core, f)) { var pivot = pivotColumnsOf(core, f); names = pivot ? pivot.added : null; hasUnknownSource = true; } else if (f.kind === 'table' && before && before.type === 'keyword' && ['FROM', 'JOIN', 'APPLY'].indexOf(before.upper) === -1) { names = null; } else if (f.kind === 'derived' && f.inner && f.inner.noSelect) { names = f.columns || []; } else if (f.kind === 'derived') { names = resolveEntryColumns(f, { schemaIndex: ctx.schema && ctx.schema.byName ? ctx.schema.byName : null, ctes: {} }, true); } else if (f.name && cteColumns[f.name.toUpperCase()]) { names = cteColumns[f.name.toUpperCase()]; } else if (ctx.schema && ctx.schema.byName) { var table = fieldCheckTable(ctx.schema.byName, f); if (table && Array.isArray(table.fields) && !hasOpenFieldList(table)) names = table.fields.map(function (fld) { return fld.name; }); }; if (!names) hasUnknownSource = true; (names || []).forEach(function (n) { if (n && !(k < lastPivot && taken[String(n).toUpperCase()])) shadowedNames[String(n).toUpperCase()] = true; }); }); ['where', 'groupBy', 'having'].forEach(function (clauseName) { var clause = parsed.clauses[clauseName]; if (!clause) return; var from = tokenIndexAtOrAfter(core, clause.start); var to = from; while (to < core.length && core[to].end <= clause.end) to++; var inCopy = {}; Object.keys(selfReadNames).forEach(function (name) { var keys = aliasItemExpressionTokens(aliasItems[name]).map(tokenKey); for (var s = from; s + keys.length <= to; s++) { var isCopy = true; for (var m = 0; m < keys.length && isCopy; m++) isCopy = tokenKey(core[s + m]) === keys[m]; if (!isCopy) continue; for (var c = s; c < s + keys.length; c++) { if (core[c].upper === name) inCopy[c] = true; }; }; }); for (var i = from; i < to; i++) { var t = core[i]; var prevTok = core[i - 1], nextTok = core[i + 1]; if (t.type === 'punct' && t.value === '(' && isKw(nextTok, 'SELECT')) { i = matchParen(core, i); // a subquery's names are its own continue; }; if (t.type !== 'identifier' || t.quote || inCopy[i]) continue; if (prevTok && prevTok.type === 'punct' && prevTok.value === '.') continue; // qualified, not an alias if (!isColumnName(core, i)) continue; var item = aliasItems[t.upper]; if (!item) continue; var isShadowed = !!shadowedNames[t.upper]; var isCertain = !isShadowed && !hasUnknownSource; var fixes = []; var exprTokens = aliasItemExpressionTokens(item); var kind = expressionCallKind(exprTokens || []); var advice = null; if (kind.hasWindow) { advice = ['its window function cannot be repeated there: select it in a derived table and filter on that column.', 'If you meant the alias, filter on it in a derived table.']; } else if (kind.hasAggregate && clauseName === 'where') { advice = ['its aggregate cannot be repeated in WHERE: repeat it in HAVING.', 'If you meant the alias, repeat its aggregate in HAVING.']; } else if (kind.hasAggregate && clauseName === 'groupBy') { advice = ['an aggregate cannot be grouped by: group by the columns instead.', 'An aggregate cannot be grouped by.']; }; if (exprTokens && exprTokens.length && !advice) { var exprText = sql.slice(exprTokens[0].start, exprTokens[exprTokens.length - 1].end); fixes.push({ title: 'Repeat the expression', edits: [{ start: t.start, end: t.end, text: '(' + exprText + ')' }], isSafe: isCertain, isInFixAll: false }); // one at a time: repeating an expression is a rewrite the user should see }; var ifAlias = advice ? ' ' + advice[1] : ' Repeat the full expression if you meant the alias.'; if (isShadowed) { findings.push(['MCE016', 'warning', '"' + t.value + '" here is the source column, not your alias of the same name, because aliases only work in ORDER BY.' + ifAlias, DOCS.BASICS, t, t, fixes]); } else if (!isCertain) { findings.push(['MCE016', 'warning', '"' + t.value + '" here can only be a source column, not your alias, because aliases only work in ORDER BY. If no table has such a column, the query fails.' + ifAlias, DOCS.BASICS, t, t, fixes]); } else if (advice) { findings.push(['MCE016', 'error', 'Alias "' + t.value + '" cannot be used in WHERE, GROUP BY or HAVING. Aliases only work in ORDER BY, and ' + advice[0], DOCS.BASICS, t, t, fixes]); } else { findings.push(['MCE016', 'error', 'Alias "' + t.value + '" cannot be used in WHERE, GROUP BY or HAVING. Repeat the full expression instead. Aliases only work in ORDER BY.', DOCS.BASICS, t, t, fixes]); }; }; }); return findings; }, function rule017(sql, tokens, ctx) { var findings = []; if (!ctx.schema || !ctx.schema.byName) return findings; function fieldIn(table, column) { if (!table || !Array.isArray(table.fields)) return null; var field = table.fields.filter(function (f) { return f.name && f.name.toLowerCase() === String(column).toLowerCase(); })[0]; return field && field.type ? field : null; }; var core = ctx.getCoreTokens(); var reportedAt = {}; ctx.getSelectScopes().forEach(function (scope) { var byAlias = {}; var entryByAlias = {}; scope.from.forEach(function (f) { var table = fieldCheckTable(ctx.schema.byName, f); if (table && Array.isArray(table.fields)) { byAlias[(f.alias || f.name).toUpperCase()] = table; byAlias[f.name.toUpperCase()] = table; entryByAlias[(f.alias || f.name).toUpperCase()] = f; entryByAlias[f.name.toUpperCase()] = f; }; }); if (!Object.keys(byAlias).length) return; var onlyTable = scope.from.length === 1 ? fieldCheckTable(ctx.schema.byName, scope.from[0]) : null; if (onlyTable && (!Array.isArray(onlyTable.fields) || hasOpenFieldList(onlyTable))) onlyTable = null; var tableOf = function (side) { return side.alias ? byAlias[String(side.alias).toUpperCase()] || null : onlyTable; }; var entryOf = function (side) { return side.alias ? entryByAlias[String(side.alias).toUpperCase()] || null : (onlyTable ? scope.from[0] : null); }; var refOf = function (side) { return side.alias ? side.alias + '.' + side.column : side.column; }; var spans = scope.ons.map(function (on) { return { start: on.start, end: on.end, depth: on.depth, isOn: true }; }); if (scope.where) { spans.push({ start: scope.where.start, end: scope.where.end, depth: scope.depth, isOn: false }); }; spans.forEach(function (span) { collectColumnComparisons(core, span.start, span.end, span.depth).forEach(function (cmp) { var leftField = fieldIn(tableOf(cmp.left), cmp.left.column); var rightField = fieldIn(tableOf(cmp.right), cmp.right.column); if (!leftField || !rightField) return; var isLeftText = addedFieldKind(leftField) === 'text'; var isRightText = addedFieldKind(rightField) === 'text'; var isLeftNumeric = leftField.type === 'Number' || leftField.type === 'Decimal'; var isRightNumeric = rightField.type === 'Number' || rightField.type === 'Decimal'; if (!((isLeftText && isRightNumeric) || (isRightText && isLeftNumeric))) return; var textSide = isLeftText ? cmp.left : cmp.right; var otherSide = isLeftText ? cmp.right : cmp.left; var textField = isLeftText ? leftField : rightField; var otherField = isLeftText ? rightField : leftField; if (reportedAt[textSide.start + ':' + otherSide.start]) return; reportedAt[textSide.start + ':' + otherSide.start] = true; var castType = numericCastType(otherField); var sameColumnName = textSide.column.toLowerCase() === otherSide.column.toLowerCase(); var textRef = refOf(textSide); var otherRef = refOf(otherSide); var textIs = textField.type === 'Text' ? 'Text' : textFieldLabel(textField); var subject = sameColumnName && textSide.alias && otherSide.alias ? textSide.column + ' is ' + textIs + ' on ' + textSide.alias + ' and ' + otherField.type + ' on ' + otherSide.alias : textRef + ' is ' + textIs + ' and ' + otherRef + ' is a ' + otherField.type; var textTable = tableOf(textSide); var otherTable = tableOf(otherSide); var textEntry = entryOf(textSide); var isWords = isNeverNumber(textField); var isCertain = isWords && isCertainNeverNumber(textField) && isNeverNullField(textTable, textField) && !!textEntry && !isOuterJoined(core, scope.from, textEntry); var pair = null; if (span.isOn && textTable && otherTable && textTable !== otherTable && textSide.alias && otherSide.alias) { var numberTwin = fieldIn(textTable, otherSide.column); var textTwin = fieldIn(otherTable, textSide.column); var swapped = numberTwin && numberTwin.type === otherField.type ? textSide : (textTwin && addedFieldKind(textTwin) === 'text' ? otherSide : null); var twin = swapped === textSide ? numberTwin : textTwin; if (swapped) { pair = { edit: { start: swapped.start, end: swapped.end, text: swapped.alias + '.' + formatIdentifier(twin.name) }, shown: swapped === textSide ? textSide.alias + '.' + twin.name + ' = ' + otherRef : textRef + ' = ' + otherSide.alias + '.' + twin.name }; }; }; var tryCast = 'TRY_CAST(' + textRef + ' AS ' + castType + ')'; var advice = pair ? 'Compare ' + pair.shown + ' instead' + (isWords ? '.' : ', or use TRY_CAST on the Text side.') : (isWords ? 'Compare columns of one type.' : 'Use ' + tryCast + ' instead, which gives NULL for such a value.'); var effect = isCertain ? 'No value of ' + textRef + ' is a number, so the run fails.' : 'One value that is not a number fails the whole run, which can look like 0 rows.'; var message = subject + '. ' + effect + ' ' + advice; var fixes = pair ? [ { title: 'Compare ' + pair.shown, edits: [pair.edit], isSafe: false, isInFixAll: false } ] : []; if (!isWords) { fixes.push( { title: 'Use ' + tryCast, edits: [{ start: textSide.start, end: textSide.end, text: 'TRY_CAST(' + sql.slice(textSide.start, textSide.end) + ' AS ' + castType + ')' }], isSafe: false, isInFixAll: false } ); }; findings.push(['MCE017', isCertain ? 'error' : 'warning', message, DOCS.WHERE, cmp.opToken, cmp.opToken, fixes]); }); }); }); if (Object.keys(ensureIndex(ctx.schema)).length) { inSubqueryTypeFindings(ctx).forEach(function (f) { findings.push(f); }); }; return findings; }, function rule018(sql, tokens, ctx) { var findings = []; function cleanTableName(f) { return f && f.name ? stripEnt(f.name).replace(/^\[|\]$/g, '') : ''; }; var core = ctx.getCoreTokens(); ctx.getSelectScopes().forEach(function (scope) { scope.ons.forEach(function (on) { if (on.entries.length < 2 || on.end <= on.start) return; var left = on.entries[on.entries.length - 2]; var right = on.entries[on.entries.length - 1]; var leftName = cleanTableName(left).toUpperCase(); var rightName = cleanTableName(right).toUpperCase(); var names = [leftName, rightName]; if (names.indexOf('_PUSHTAG') === -1 || names.indexOf('_PUSHADDRESS') === -1) return; var pushTagEntry = leftName === '_PUSHTAG' ? left : right; var pushAddressEntry = leftName === '_PUSHADDRESS' ? left : right; var pushTagAlias = pushTagEntry.alias || pushTagEntry.name; var pushAddressAlias = pushAddressEntry.alias || pushAddressEntry.name; var hasDeviceIdMatch = false; var hasApidMatch = false; collectSimpleComparisons(core, on.start, on.end, on.depth).forEach(function (cmp) { if (cmp.opToken.value !== '=') return; if (cmp.left.column.toUpperCase() !== cmp.right.column.toUpperCase()) return; if (cmp.left.column.toUpperCase() === '_DEVICEID') hasDeviceIdMatch = true; if (cmp.left.column.toUpperCase() === '_APID') hasApidMatch = true; }); if (hasDeviceIdMatch && !hasApidMatch) { var apidCondition = pushTagAlias + '._APID = ' + pushAddressAlias + '._APID'; findings.push(['MCE018', 'warning', 'Join _PushTag to _PushAddress on both _DeviceID and _APID, so each tag matches the device record of the same app.', DOCS.JOIN, core[on.onIdx], core[on.onIdx], [ { title: 'Add AND ' + apidCondition, edits: appendConditionEdits(core, on.start, on.end, on.depth, apidCondition), isSafe: false, isInFixAll: false } ]]); }; }); }); return findings; }, function rule019(sql, tokens, ctx) { var findings = []; var core = ctx.getCoreTokens(); var byName = ctx.schema && ctx.schema.byName ? ctx.schema.byName : null; function isClauseWord(word) { var k = tokenIndexAtOrAfter(core, word.start); var next = core[k + 1]; if (word.upper === 'QUALIFY') return true; if (word.upper === 'LIMIT') return !!next && next.type === 'number'; if (word.upper === 'WINDOW') return isName(next) && isKw(core[k + 2], 'AS'); if (word.upper === 'NATURAL' || word.upper === 'ANTI' || word.upper === 'SEMI') return isKwAny(next, ['JOIN', 'INNER', 'LEFT', 'RIGHT', 'FULL', 'OUTER', 'CROSS']); return false; }; var qualifiers = {}; core.forEach(function (t, k) { if (isBareName(t) && isPunctToken(core[k + 1], '.') && !isPunctToken(core[k - 1], '.')) qualifiers[t.upper] = true; }); function bracketFinding(first, last, table) { var written = sql.slice(first.start, last.end).replace(/\s+/g, ' ').toLowerCase(); var isNamed = !!table && String(table.name).replace(/\s+/g, ' ').toLowerCase() === written; var edits = isNamed ? [{ start: first.start, end: last.end, text: '[' + String(table.name).replace(/]/g, ']]') + ']' }] : [{ start: first.start, end: first.start, text: '[' }, { start: last.end, end: last.end, text: ']' }]; return ['MCE019', 'error', 'This Data Extension name has a space or hyphen but no brackets. Wrap it in brackets, for example FROM [My Data Extension].', DOCS.FROM, first, last, [{ title: 'Wrap in brackets', edits: edits, isSafe: !!table, isInFixAll: !!table }]]; }; reservedWordNames(sql, core, byName).forEach(function (run) { findings.push(bracketFinding(core[run.first], core[run.last], run.table)); }); for (var i = 0; i < tokens.length; i++) { var t = tokens[i]; if (t.type === 'keyword' && !t.isDistinctFrom && (t.upper === 'FROM' || t.upper === 'JOIN')) { var j = i + 1; var runTokens = []; var hasHyphen = false; while (j < tokens.length) { var x = tokens[j]; if (isBareName(x)) { runTokens.push(x); j++; continue; }; if (x.type === 'number' && runTokens.length && runTokens[runTokens.length - 1].value === '-') { runTokens.push(x); j++; continue; }; if (x.type === 'operator' && x.value === '-' && runTokens.length && (isBareName(tokens[j + 1]) || (tokens[j + 1] && tokens[j + 1].type === 'number'))) { hasHyphen = true; runTokens.push(x); j++; continue; }; break; }; var knownWordCount = 0; var knownTable = null; if (byName) { for (var w = runTokens.length; w >= 1; w--) { var candidateName = sql.slice(runTokens[0].start, runTokens[w - 1].end).replace(/\s+/g, ' '); var isWordEnd = runTokens[w - 1].type !== 'operator' && !(runTokens[w] && runTokens[w].type === 'operator'); if (isWordEnd && byName[candidateName.toLowerCase()]) { knownWordCount = w; knownTable = byName[candidateName.toLowerCase()]; break; }; }; }; if (knownWordCount === 1) continue; // a known one-word name: the rest is not a bracket problem if (knownWordCount >= 2 && qualifiers[runTokens[knownWordCount - 1].upper]) { var shorterName = sql.slice(runTokens[0].start, runTokens[knownWordCount - 2].end).replace(/\s+/g, ' ').toLowerCase(); if (byName[shorterName]) continue; }; if (knownWordCount) { runTokens = runTokens.slice(0, knownWordCount); } else { for (var stop = 1; stop < runTokens.length; stop++) { if (runTokens[stop].type === 'identifier' && isClauseWord(runTokens[stop])) { runTokens = runTokens.slice(0, stop); break; }; }; while (runTokens.length && runTokens[runTokens.length - 1].type === 'operator') runTokens.pop(); hasHyphen = runTokens.some(function (r) { return r.type === 'operator'; }); }; if (!runTokens.length) continue; if (!knownWordCount && runTokens.length >= 2 && qualifiers[runTokens[runTokens.length - 1].upper] && runTokens[runTokens.length - 2].type !== 'operator') { runTokens = runTokens.slice(0, -1); if (runTokens.length >= 2 || hasHyphen) { findings.push(bracketFinding(runTokens[0], runTokens[runTokens.length - 1], null)); }; continue; }; if (hasHyphen || runTokens.length >= 3 || knownWordCount >= 2) { findings.push(bracketFinding(runTokens[0], runTokens[runTokens.length - 1], knownWordCount ? knownTable : null)); }; }; }; return findings; }, function rule020(sql, tokens, ctx) { var findings = []; var core = ctx.getCoreTokens(); for (var i = 0; i < core.length; i++) { if (isKw(core[i], 'AS') && core[i + 1] && core[i + 1].type === 'keyword') { var word = core[i + 1]; if (isReservedAliasAt(core, i + 1)) { var edits = []; core.forEach(function (t, k) { var isSameWord = t.type === 'keyword' && t.upper === word.upper; var isUse = t === word || (isSameWord && (isPunctToken(core[k + 1], '.') !== isPunctToken(core[k - 1], '.') || isOrderByItemWord(core, k))); if (!isUse) return; edits.push({ start: t.start, end: t.start, text: '[' }); edits.push({ start: t.end, end: t.end, text: ']' }); }); if (RESERVED_IN_BRACKETS.indexOf(word.upper) !== -1) { findings.push(['MCE020', 'error', '"' + word.value + '" is a reserved word, and SQL Server refuses it as a name without brackets: Incorrect syntax near the keyword \'' + word.value + '\'. Pick another name, as the query check refuses it in brackets too.', DOCS.SEL, word, word]); continue; }; findings.push(['MCE020', 'error', '"' + word.value + '" is a reserved word, and SQL Server refuses it as a name without brackets: Incorrect syntax near the keyword \'' + word.value + '\'. Wrap it in brackets, or pick another name.', DOCS.SEL, word, word, [ { title: 'Wrap in brackets', edits: edits, isSafe: true, isInFixAll: true } ]]); }; }; }; return findings; }, function rule021(sql, tokens) { var findings = []; for (var i = 0; i < tokens.length - 2; i++) { if (tokens[i].type === 'number' && /^\d+$/.test(tokens[i].value) && tokens[i + 1].type === 'punct' && tokens[i + 1].value === '.' && isName(tokens[i + 2])) { findings.push(['MCE021', 'error', 'This table name has a Business Unit MID prefix, which Marketing Cloud Engagement no longer allows. Use a Shared Data Extension with the Ent. prefix instead.', DOCS.FROM, tokens[i], tokens[i + 2]]); }; }; return findings; }, function rule026(sql, tokens, ctx) { var findings = []; var assignmentAt = {}; ctx.getSelects().forEach(function (sel) { sel.selectList.items.forEach(function (item) { var list = item.tokens || []; if (item.kind === 'alias' && list.length >= 3 && list[0].start === item.nameStart && list[1].type === 'operator' && list[1].value === '=') { assignmentAt[list[1].start] = true; }; }); }); for (var i = 0; i < tokens.length - 1; i++) { var t = tokens[i]; if (assignmentAt[t.start]) continue; if (t.type === 'operator' && (t.value === '=' || t.value === '!=' || t.value === '<>') && isKw(tokens[i + 1], 'NULL')) { var isEq = t.value === '='; var replacement = isEq ? 'IS NULL' : 'IS NOT NULL'; var afterNull = tokens[i + 2]; var isWholeOperand = !(afterNull && (afterNull.type === 'operator' || (afterNull.type === 'punct' && afterNull.value === '.') || isKw(afterNull, 'COLLATE'))); findings.push(['MCE026', 'warning', 'Comparing with NULL using = or != is never true. Use IS NULL or IS NOT NULL instead.', DOCS.NULLF, t, tokens[i + 1], isWholeOperand ? [ { title: 'Use ' + replacement, edits: [{ start: t.start, end: tokens[i + 1].end, text: replacement }], isSafe: true, isInFixAll: true } ] : []]); }; }; return findings; }, function rule027(sql, tokens, ctx) { var findings = []; var core = ctx.getCoreTokens(); var byName = ensureIndex(ctx.schema); var scopeBySelect = null; var branchesByHead = null; function cannotYieldNull(k, close) { if (!scopeBySelect) { scopeBySelect = {}; branchesByHead = {}; ctx.getSelectScopes().forEach(function (scope) { scopeBySelect[scope.selectIdx] = scope; }); var nesting = ctx.getSelectNesting(); Object.keys(nesting).forEach(function (key) { var head = nesting[key].head; if (head === undefined || head === -1 || !scopeBySelect[key]) return; (branchesByHead[head] = branchesByHead[head] || []).push(scopeBySelect[key]); }); }; var scope = scopeBySelect[k]; if (!scope) return false; var hasSetOperator = false; for (var q = k + 1; q < close && !hasSetOperator; q++) { if (core[q].depth === core[k].depth && core[q].type === 'keyword' && SET_OPERATOR_WORDS[core[q].upper]) hasSetOperator = true; }; var branches = hasSetOperator ? branchesByHead[k] || [] : [scope]; return branches.length > (hasSetOperator ? 1 : 0) && branches.every(branchCannotYieldNull); }; function branchCannotYieldNull(scope) { if (scope.selectList.items.length !== 1) return false; var item = scope.selectList.items[0]; if (item.kind !== 'column' && item.kind !== 'alias') return false; var aliasExpr = item.kind === 'alias' ? aliasItemExpressionTokens(item) : null; if (aliasExpr && !aliasExpr.length) return false; var first = tokenIndexAtOrAfter(core, aliasExpr ? aliasExpr[0].start : item.start); var last = aliasExpr ? tokenIndexAtOrAfter(core, aliasExpr[aliasExpr.length - 1].start) : tokenIndexAtOrAfter(core, item.end) - 1; if (last < first) return false; var expr = core.slice(first, last + 1); if (neverNullColumnEnd(core, first, byName, [scope]) === last) return true; if (!scope.where) return false; var columnKey = expr.map(function (t) { return t.type === 'identifier' ? t.upper : t.value; }).join(''); var segments = formatterSplitAndOr(core, scope.where.start, scope.where.end, scope.depth); if (segments.some(function (seg) { return seg.connector === 'OR'; })) return false; return segments.some(function (seg) { var n = seg.end - seg.start; if (n !== expr.length + 3 || !isKw(core[seg.end - 3], 'IS') || !isKw(core[seg.end - 2], 'NOT') || !isKw(core[seg.end - 1], 'NULL')) return false; return core.slice(seg.start, seg.end - 3).map(function (t) { return t.type === 'identifier' ? t.upper : t.value; }).join('') === columnKey; }); }; for (var i = 0; i < core.length - 3; i++) { if (isKw(core[i], 'NOT') && isKw(core[i + 1], 'IN') && core[i + 2].type === 'punct' && core[i + 2].value === '(' && isKw(core[i + 3], 'SELECT')) { if (cannotYieldNull(i + 3, matchParen(core, i + 2))) continue; findings.push(['MCE027', 'warning', 'NOT IN returns zero rows if this subquery yields any NULL. Add IS NOT NULL inside the subquery, or use NOT EXISTS or a LEFT JOIN instead.', DOCS.NULLF, core[i], core[i + 3]]); }; }; return findings; }, function rule029(sql, tokens, ctx) { var findings = []; var core = ctx.getCoreTokens(); for (var i = 0; i < core.length - 1; i++) { if (isKw(core[i], 'LIKE') && core[i + 1].type === 'string') { if (isKw(core[i + 2], 'ESCAPE')) continue; if (core[i + 2] && core[i + 2].type === 'operator') continue; var content = stringLiteralContent(core[i + 1]); if (content.indexOf('%') === -1 && content.indexOf('_') === -1 && content.indexOf('[') === -1) { var isNegated = isKw(core[i - 1], 'NOT'); var replaceFrom = isNegated ? core[i - 1].start : core[i].start; var operatorText = isNegated ? '!=' : '='; findings.push(['MCE029', 'warning', "This LIKE pattern has no wildcard, so it matches as = does, except that LIKE keeps trailing spaces where = ignores them. Add % to match part of the value, for example '%@gmail.com'.", DOCS.LIKE, core[i + 1], core[i + 1], [ { title: 'Use ' + operatorText + ' instead', edits: [{ start: replaceFrom, end: core[i].end, text: operatorText }], isSafe: false, isInFixAll: false } ]]); }; }; }; return findings; }, function rule031(sql, tokens) { var findings = []; for (var i = 0; i < tokens.length - 2; i++) { if (isKw(tokens[i], 'LIKE') && isKw(tokens[i + 1], 'IN') && tokens[i + 2].type === 'punct' && tokens[i + 2].value === '(') { var fix1 = buildLikeInFix(tokens, sql, i - 1, i + 1); findings.push(['MCE031', 'error', "LIKE cannot be combined with IN. Use separate LIKE conditions joined with OR instead, for example col LIKE 'a%' OR col LIKE 'b%'.", DOCS.WHERE, tokens[i], tokens[i + 2], fix1 ? [{ title: 'Rewrite as OR conditions', edits: fix1.edits, isSafe: true, isInFixAll: true }] : []]); }; if (isKw(tokens[i], 'IN') && !isKw(tokens[i - 1], 'LIKE') && tokens[i + 1] && tokens[i + 1].type === 'punct' && tokens[i + 1].value === '(') { var close = matchParen(tokens, i + 1); var listDepth = tokens[i + 1].depth + 1; for (var k = i + 2; k < close; k++) { if (tokens[k].type !== 'string') continue; var from = k; var to = k; while (isPunctToken(tokens[from - 1], '(') && tokens[from - 1].depth >= listDepth && matchParen(tokens, from - 1) === to + 1) { from--; to++; }; var before = tokens[from - 1]; var after = tokens[to + 1]; var isWholeValue = tokens[from].depth === listDepth && (isPunctToken(before, '(') || isPunctToken(before, ',')) && (isPunctToken(after, ',') || isPunctToken(after, ')')); if (isWholeValue && stringLiteralContent(tokens[k]).indexOf('%') !== -1) { findings.push(['MCE031', 'warning', "IN compares exactly, so % here matches a literal percent sign, not any text. For a wildcard match, use LIKE conditions joined with OR, for example col LIKE 'a%' OR col LIKE 'b%'.", DOCS.WHERE, tokens[i], tokens[close]]); break; }; }; }; }; return findings; }, function rule032(sql, tokens, ctx) { var findings = []; var parsed = ctx.getParsed(); if (!parsed) return findings; var core = ctx.getCoreTokens(); var rangeStart = parsed.selectList.start, rangeEnd = parsed.selectList.end; var byName = ensureIndex(ctx.schema); var scopeBySelect = null; var columnEnd = function (k) { if (!scopeBySelect) { scopeBySelect = {}; ctx.getSelectScopes().forEach(function (scope) { scopeBySelect[scope.selectIdx] = scope; }); }; return neverNullColumnEnd(core, k, byName, enclosingSelectScopes(core, k, scopeBySelect)); }; var columnStartAt = function (k) { return k >= 2 && isPunctToken(core[k - 1], '.') && isName(core[k - 2]) ? k - 2 : k; }; for (var i = 0; i < core.length; i++) { var t = core[i]; if (t.start < rangeStart || t.end > rangeEnd) continue; if (t.type === 'operator' && t.value === '+') { var prevT = core[i - 1], nextT = core[i + 1]; var prevCallStart = prevT && prevT.type === 'punct' && prevT.value === ')' ? callStartBefore(core, i - 1) : -1; var isPrevSafeCall = prevCallStart !== -1 && neverNullEnd(core, prevCallStart, columnEnd) === i - 1 && isTextCall(core, prevCallStart, i - 1); var prevCallTok = prevCallStart !== -1 ? core[prevCallStart] : null; var isPrevUnsafeCall = !!prevCallTok && !prevCallTok.quote && (isName(prevCallTok) || prevCallTok.type === 'keyword') && !isPrevSafeCall && !NO_TEXT_RESULT_FUNCTIONS[prevCallTok.upper] && neverNullEnd(core, prevCallStart, columnEnd) !== i - 1; var isNextCall = !!nextT && (isName(nextT) || nextT.type === 'keyword') && !nextT.quote && !!core[i + 2] && core[i + 2].type === 'punct' && core[i + 2].value === '(' && callClose(core, i + 1) !== -1; var isPrevOperand = prevT && (prevT.type === 'string' || isName(prevT) || isPrevSafeCall || isPrevUnsafeCall); var isNextOperand = nextT && (nextT.type === 'string' || isName(nextT) || isNextCall); var hasString = (prevT && prevT.type === 'string') || isPrevSafeCall || (nextT && nextT.type === 'string'); if (!(isPrevOperand && isNextOperand && hasString)) continue; var isNextSafeCall = isNextCall && !NO_TEXT_RESULT_FUNCTIONS[nextT.upper] && neverNullEnd(core, i + 1, columnEnd) !== -1; var isPrevColumn = isName(prevT) && columnEnd(columnStartAt(i - 1)) !== i - 1; var isNextColumn = isName(nextT) && !isNextCall && columnEnd(i + 1) === -1; var hasColumn = isPrevColumn || isPrevUnsafeCall || isNextColumn || (isNextCall && !isNextSafeCall); if (hasColumn) { findings.push(['MCE032', 'warning', 'This + concatenation returns NULL if either side is NULL. Use CONCAT() instead, which treats NULL as an empty string, or wrap the operand with ISNULL().', DOCS.STR, t, t]); }; }; }; return findings; }, function rule033(sql, tokens, ctx) { var findings = []; var core = ctx.getCoreTokens(); var byName = ensureIndex(ctx.schema); var scopeBySelect = null; var ctes = null; core.forEach(function (t, i) { if (t.type !== 'identifier' || t.quote || t.upper !== 'GETDATE' || !isPunctToken(core[i + 1], '(')) return; if (isPunctToken(core[i + 2], ')') && Object.keys(byName).length) { if (!scopeBySelect) { scopeBySelect = {}; ctx.getSelectScopes().forEach(function (scope) { scopeBySelect[scope.selectIdx] = scope; }); var parsed = ctx.getParsed(); ctes = parsed && parsed._ctx ? parsed._ctx.ctes : {}; }; var resolveField = serverTimeFieldResolver(byName, enclosingSelectScopes(core, i, scopeBySelect), ctes); if (isComparedWithServerDate(core, serverTimeSpan(core, i, i + 3), resolveField)) return; }; findings.push(['MCE033', 'info', 'GETDATE() returns MCE server time, US Central Standard Time all year, without daylight saving. Use GETUTCDATE() for UTC. Tracking dates such as EventDate use the same server time.', DOCS.DATE, t, t]); }); return findings; }, function rule034(sql, tokens, ctx) { var findings = []; tokens = ctx.getCoreTokens(); function isOffsetBefore(k) { var last = tokens[k]; if (last && last.type === 'string' && isKw(tokens[k - 1], 'ZONE') && isKw(tokens[k - 2], 'TIME') && isKw(tokens[k - 3], 'AT')) return true; if (!isPunctToken(last, ')')) return false; var open = findEnclosingOpenParen(tokens, k); if (open === -1) return false; var name = callNameAt(tokens, open); if (name === 'SYSDATETIMEOFFSET' || name === 'TODATETIMEOFFSET' || name === 'SWITCHOFFSET') return true; if (name === 'CAST' || name === 'TRY_CAST' || name === 'CONVERT' || name === 'TRY_CONVERT' || isKwAny(tokens[open - 1], ['CAST', 'TRY_CAST', 'CONVERT', 'TRY_CONVERT'])) { var typeSpan = castTypeSpan(tokens, open - 1, k); return !!typeSpan && tokens[typeSpan.start].upper === 'DATETIMEOFFSET'; }; var before = tokens[open - 1]; var isGroup = !before || before.type === 'punct' || before.type === 'operator' || isKwAny(before, ['SELECT', 'WHERE', 'AND', 'OR', 'ON', 'WHEN', 'THEN', 'ELSE', 'NOT', 'BY', 'HAVING', 'DISTINCT']); return isGroup && isOffsetBefore(k - 1); }; function isLiteralSpan(start, end, levels) { if (end <= start || levels <= 0) return false; if (end === start + 1 && tokens[start].type === 'string') return true; if (numberLiteralEnd(tokens, start) === end) return true; if (!isPunctToken(tokens[end - 1], ')')) return false; if (isPunctToken(tokens[start], '(') && matchParen(tokens, start) === end - 1) return isLiteralSpan(start + 1, end - 1, levels - 1); return isPunctToken(tokens[start + 1], '(') && matchParen(tokens, start + 1) === end - 1 && isLiteralCast(start + 1, end - 1, levels); }; function isLiteralCast(open, close, levels) { var name = tokens[open - 1]; if (!name || name.quote || !(name.type === 'identifier' || name.type === 'keyword')) return false; if (name.upper === 'CONVERT' || name.upper === 'TRY_CONVERT') { var args = addedArguments(tokens, open - 1, close); return args.length >= 2 && isLiteralSpan(args[1].start, args[1].end, levels - 1); }; if (name.upper !== 'CAST' && name.upper !== 'TRY_CAST') return false; var typeSpan = castTypeSpan(tokens, open - 1, close); return !!typeSpan && isLiteralSpan(open + 1, typeSpan.start - 1, levels - 1); }; function isLiteralBefore(k) { var last = tokens[k]; if (!last) return false; if (last.type === 'string') return true; if (!isPunctToken(last, ')')) return false; var open = findEnclosingOpenParen(tokens, k); if (open === -1) return false; return (open > 0 && !isPunctToken(tokens[open - 2], '.') && isLiteralSpan(open - 1, k + 1, 32)) || isLiteralSpan(open, k + 1, 32); }; for (var i = 0; i < tokens.length - 3; i++) { if (isKw(tokens[i], 'AT') && isKw(tokens[i + 1], 'TIME') && isKw(tokens[i + 2], 'ZONE') && tokens[i + 3].type === 'string' && stringLiteralContent(tokens[i + 3]).toLowerCase() === 'central standard time' && !isOffsetBefore(i - 1) && !isLiteralBefore(i - 1)) { findings.push(['MCE034', 'warning', "Marketing Cloud Engagement server time never observes daylight saving, so this time zone offset is wrong for half the year. Use 'Central America Standard Time' instead, or convert with TODATETIMEOFFSET and a fixed '-06:00' offset.", DOCS.DATE, tokens[i], tokens[i + 3], [ { title: 'Use Central America Standard Time', edits: [{ start: tokens[i + 3].start, end: tokens[i + 3].end, text: "'Central America Standard Time'" }], isSafe: false, isInFixAll: false } ]]); }; }; return findings; }, function rule041(sql, tokens) { var findings = []; tokens.forEach(function (t, i) { if (t.type === 'identifier' && !t.quote && (t.upper === 'DECIMAL' || t.upper === 'NUMERIC')) { var next = tokens[i + 1]; if (!next || !(next.type === 'punct' && next.value === '(')) { findings.push(['MCE041', 'warning', 'DECIMAL without precision and scale rounds to a whole number, so 123.6 becomes 124. Add precision and scale, for example DECIMAL(18, 2).', DOCS.CONV, t, t, [ { title: 'Use ' + t.upper + '(18, 2)', edits: [{ start: t.end, end: t.end, text: '(18, 2)' }], isSafe: false, isInFixAll: false } ]]); }; }; }); return findings; }, function rule043(sql, tokens, ctx) { var findings = []; var core = ctx.getCoreTokens(); var known = knownColumnsOf(ctx); var strictErrors = ctx.getStrict().errors; var parsed = ctx.getParsed(); var missing = missingColumnStarts(ctx); var firstUnclosed = -1; if (strictErrors.length) { var closes = parenMatchesOf(core); for (var u = 0; u < core.length && firstUnclosed === -1; u++) { if (isPunctToken(core[u], '(') && closes[u] === -1) firstUnclosed = u; }; }; var hasSyntaxError = function (reading) { if (!strictErrors.length) return false; if (firstUnclosed !== -1 && reading.scope.selectIdx > firstUnclosed) return true; var from = core[reading.scope.selectIdx].start; var endIdx = reading.place ? reading.place.end : core.length; var to = endIdx >= 0 && endIdx < core.length ? core[endIdx].end : Infinity; return strictErrors.some(function (e) { return e.start >= from && e.start < to; }); }; ctx.getScopeReadings().list.forEach(function (reading) { if (hasSyntaxError(reading)) return; var grouping = groupingOf(core, reading, known); if (!grouping.isGrouped || grouping.constants.length || (reading.groupBy && reading.groupBy.aggregates.length)) return; var aliasedItems = []; if (reading.groupBy && parsed && parsed.selectList && reading.scope.selectList.start === parsed.selectList.start) { var columns = scopeBinder(core, reading).columns(known); reading.scope.selectList.items.forEach(function (item) { if (item.kind !== 'alias' || !item.name) return; var upper = String(item.name).replace(/^\[|\]$/g, '').toUpperCase(); if (!grouping.names[upper] || Object.prototype.hasOwnProperty.call(columns.owners, upper.toLowerCase())) return; var isSelfRead = reading.select.refs.some(function (ref) { return ref.item === item && ref.parts[ref.parts.length - 1].upper === upper; }); if (!isSelfRead) aliasedItems.push(item); }); }; var isTypedCall = function (ref) { return ref.end >= core.length && /^(ROLLUP|CUBE|GROUPING)$/.test(ref.parts[ref.parts.length - 1].upper); }; if (reading.groupBy && reading.groupBy.refs.some(isTypedCall)) return; var groupTypos = reading.groupBy ? reading.groupBy.refs.filter(function (ref) { return missing[refSpan(core, ref).start]; }) : []; reading.select.refs.forEach(function (ref) { if (ref.isAggregated) return; var beforeRef = core[ref.first - 1]; if (isKw(beforeRef, 'OVER') || (isPunctToken(beforeRef, '(') && isKw(core[ref.first - 2], 'OVER'))) return; var binding = localBinding(core, reading, ref, known); if (binding === 'outer') return; var severity = binding === 'local' ? 'error' : 'warning'; if (grouping.names[ref.parts[ref.parts.length - 1].upper]) { if (isGroupedRef(core, grouping, ref, ref.expression)) return; severity = 'warning'; }; if (aliasedItems.indexOf(ref.item) !== -1) { if (severity !== 'error') return; var aliasUpper = String(ref.item.name).replace(/^\[|\]$/g, '').toUpperCase(); reading.groupBy.refs.forEach(function (use) { if (use.parts.length === 1 && String(use.parts[0].value).replace(/^\[|\]$/g, '').toUpperCase() === aliasUpper) noteRefusedTypo(ctx, refSpan(core, use).start, 'MCE043', 'ALIAS'); }); return; }; var span = refSpan(core, ref); if (missing[span.start] || groupTypos.length) { if (severity !== 'error') return; if (missing[span.start]) noteRefusedTypo(ctx, span.start, 'MCE043', 'UNGROUPED'); groupTypos.forEach(function (typo) { noteRefusedTypo(ctx, refSpan(core, typo).start, 'MCE043', 'GROUP'); }); return; }; var text = sql.slice(span.start, span.end); // as written, brackets included var message; if (severity === 'warning') { message = 'Column "' + text + '" is not in GROUP BY and is not aggregated. Add it to GROUP BY, or wrap it in an aggregate function such as MAX().'; } else { var quote = 'Column \'' + text + '\' is invalid in the select list because it is not contained in either an aggregate function or the GROUP BY clause.'; var maker = grouping.aggregateIdx !== -1 ? core[grouping.aggregateIdx].upper : 'HAVING'; message = grouping.hasGroupBy ? 'SQL Server refuses this column: ' + quote + ' Add it to GROUP BY, or wrap it in MAX().' : 'With no GROUP BY, ' + maker + ' makes one group: ' + quote + ' Add GROUP BY ' + text + ', or wrap it in MAX().'; }; findings.push(['MCE043', severity, message, DOCS.AGG, span, span, groupByFixes(sql, core, reading, text)]); }); }); return findings; }, function rule045(sql, tokens, ctx) { var findings = []; var scopes = ctx.getSelectScopes(); if (!scopes.some(function (scope) { return scope.from.some(function (f) { return !!sendLogViewName(f); }); })) return findings; var parsed = ctx.getParsed(); if (!parsed) return findings; var core = ctx.getCoreTokens(); var nesting = ctx.getSelectNesting(); if (Object.keys(nesting).length !== scopes.length) return findings; var isFiltered = collectTrackingFilters(core, scopes, nesting, parsed._ctx ? parsed._ctx.ctes : {}); var jobIdCured = {}; var disabledIds = (ctx.options && ctx.options.disabled) || []; ctx.getSendLogJoins().forEach(function (join) { if (disabledIds.indexOf('MCE071') !== -1) return; if (!join.best || join.best.matched.JobID || ['inner', 'left'].indexOf(joinKindOf(core, join.joined)) === -1) return; var scope = scopes.filter(function (sc) { return sc.from.indexOf(join.joined) !== -1; })[0]; var anchorUpper = String(join.best.anchorRef).toUpperCase(); var anchor = scope ? scope.from.filter(function (f) { return f !== join.joined && String(f.alias || sendLogViewName(f) || '').toUpperCase() === anchorUpper; })[0] : null; if (anchor && isFiltered(scope, anchor)) jobIdCured[join.joined.start] = true; }); var unlinked = disabledIds.indexOf('MCE046') === -1 ? unlinkedTrackingView(ctx) : null; if (unlinked && !unlinked.isThroughOther) { var topScope = scopes.filter(function (sc) { return sc.selectList.start === parsed.selectList.start; })[0]; var isOtherLimited = !!topScope && topScope.from.some(function (f) { return f.start !== unlinked.entry.start && !!sendLogViewName(f) && isFiltered(topScope, f); }); if (isOtherLimited) jobIdCured[unlinked.entry.start] = true; }; scopes.forEach(function (scope) { scope.from.forEach(function (f) { var clean = sendLogViewName(f); if (!clean || isFiltered(scope, f) || jobIdCured[f.start]) return; var condition = (f.alias || clean) + '.EventDate >= DATEADD(DAY, -30, GETDATE())'; var viewName = TRACKING_VIEWS.filter(function (v) { return v.toUpperCase() === clean.toUpperCase(); })[0] || clean; findings.push(['MCE045', 'warning', viewName + ' can hold up to six months of large tracking data. Filter by EventDate, for example the last 30 days, or by JobID. Otherwise the query can hit the 30-minute timeout.', DOCS.DV, f, f, trackingFilterFixes(sql, core, scope, nesting, condition)]); }); }); return findings; }, function rule046(sql, tokens, ctx) { var findings = []; var unlinked = unlinkedTrackingView(ctx); if (!unlinked) return findings; var last = unlinked.entry; var isThroughOther = unlinked.isThroughOther; var message = isThroughOther ? 'These tracking views are each joined to another table, not to each other, so their rows multiply: 3 opens and 2 clicks give 6 rows. Group each view by subscriber in its own derived table, then join those.' : 'These tracking views are joined without JobID. Join on SubscriberKey AND JobID, plus ListID and BatchID for an exact send. Otherwise rows fan out.'; findings.push(['MCE046', 'warning', message, DOCS.DV, last, last]); return findings; }, function rule050(sql, tokens, ctx) { var findings = []; if (!ctx.targetKey && !ctx.targetName) return findings; var action = String(ctx.targetAction || '').toLowerCase(); if (action === 'update') return findings; var parsed = ctx.getParsed(); if (!parsed) return findings; var byName = ensureIndex(ctx.schema); var tables = ctx.schema && Array.isArray(ctx.schema.tables) ? ctx.schema.tables : []; var lower = function (text) { return String(text).toLowerCase(); }; var byKey = ctx.targetKey ? tables.filter(function (t) { return !!t && t.kind !== 'dataview' && !!t.key && lower(t.key) === lower(ctx.targetKey); }) : []; byKey.sort(function (a, b) { return (a.isShared ? 1 : 0) - (b.isShared ? 1 : 0); }); var target = byKey[0] || null; if (!target) { var named = byName[lower(ctx.targetName || ctx.targetKey)]; target = named && named.kind !== 'dataview' ? named : null; }; var cteNames = {}; (parsed.ctes || []).forEach(function (c) { if (c && c.name) cteNames[String(c.name).replace(/^\[|\]$/g, '').toUpperCase()] = true; }); var isTargetEntry = function (f) { if (f.kind !== 'table' || !f.name || f.isFunction || cteNames[stripEnt(f.name).replace(/^\[|\]$/g, '').toUpperCase()]) return false; return target ? fieldCheckTable(byName, f) === target : (!!ctx.targetName && !/^ENT\./i.test(f.name) && lower(f.name.replace(/^\[|\]$/g, '')) === lower(ctx.targetName)); }; var quoted = 'Update Type must be \'Update\' or the target data extension \'' + (ctx.targetName || (target && target.name) || ctx.targetKey) + '\' cannot appear in the query.'; var advice = ' Use the Update action, or read a copy of the Data Extension.'; var isRefused = action === 'append' || action === 'overwrite'; var topMessage = isRefused ? 'MCE refuses to save this Query Activity with ' + (action === 'append' ? 'Append' : 'Overwrite') + ': ' + quoted + advice : 'MCE saves a query that reads its own target only with Update: ' + quoted + advice; var seen = {}; parsed.from.forEach(function (f) { seen[f.start] = true; if (isTargetEntry(f)) findings.push(['MCE050', isRefused ? 'error' : 'warning', topMessage, DOCS.SFBUILD, f, f]); }); ctx.getSelectScopes().forEach(function (scope) { scope.from.forEach(function (f) { if (seen[f.start]) return; seen[f.start] = true; if (isTargetEntry(f)) findings.push(['MCE050', isRefused ? 'error' : 'warning', topMessage, DOCS.SFBUILD, f, f]); }); }); return findings; }, function rule053(sql, tokens, ctx) { var findings = []; var parsed = ctx.getParsed(); if (!parsed) return findings; var core = ctx.getCoreTokens(); var readings = ctx.getScopeReadings(); var push = function (span, count, firstCount) { findings.push(['MCE053', 'error', 'This branch has ' + count + (count === 1 ? ' column' : ' columns') + ' and the first has ' + firstCount + ': All queries combined using a UNION, INTERSECT or EXCEPT operator must have an equal number of expressions in their target lists. Match the columns on every branch.', DOCS.JOIN, span, span]); }; var strictErrors = ctx.getStrict().errors; var isTyped = function (span, count) { return count === 0 || strictErrors.some(function (e) { return e.start >= span.start && e.start <= span.end; }); }; var firstCount = selectWidth(parsed.selectList.items); var firstBranch = (parsed.clauses.union || [])[0]; if (firstCount !== null && !(firstBranch && isTyped(firstBranch, firstCount))) { laterBranches(core, parsed, readings).forEach(function (branch) { if (branch.count !== null && branch.count !== firstCount && !isTyped(branch.span, branch.count)) push(branch.span, branch.count, firstCount); }); }; var chains = {}; readings.list.forEach(function (reading) { if (!reading.place || reading.place.kind === 'top') return; var head = reading.place.head; (chains[head] = chains[head] || []).push(reading); }); Object.keys(chains).forEach(function (head) { var chain = chains[head]; if (chain.length < 2 || chain[0].scope.selectIdx !== Number(head)) return; var width = selectWidth(chain[0].scope.selectList.items); if (width === null || isTyped(branchSpanOf(core, chain[0]), width)) return; chain.slice(1).forEach(function (reading) { var count = selectWidth(reading.scope.selectList.items); var span = branchSpanOf(core, reading); if (count !== null && count !== width && !isTyped(span, count)) push(span, count, width); }); }); return findings; }, function rule054(sql, tokens, ctx) { var findings = []; var core = ctx.getCoreTokens(); core.forEach(function (t, i) { if (isKw(t, 'UNION') && !isKw(core[i + 1], 'ALL')) { findings.push(['MCE054', 'info', 'UNION removes duplicate rows, which costs an extra sort. Use UNION ALL instead when duplicates are impossible or wanted.', DOCS.JOIN, t, t, [ { title: 'Use UNION ALL', edits: [{ start: t.end, end: t.end, text: ' ALL' }], isSafe: false, isInFixAll: false } ]]); }; }); return findings; }, function rule060(sql, tokens) { var findings = []; try { tokens.forEach(function (t) { if (t.unterminated) { var msg = t.type === 'string' ? 'This string is never closed. Add the missing closing quote.' : (t.type === 'comment' ? 'This comment is never closed. Add the missing */ to close it.' : 'This bracketed name is never closed. Add the missing ] to close it.'); findings.push(['MCE060', 'error', msg, '', t, t]); }; }); var stack = []; tokens.forEach(function (t) { if (t.type === 'punct' && t.value === '(') { stack.push(t); } else if (t.type === 'punct' && t.value === ')') { if (stack.length) stack.pop(); }; }); stack.forEach(function (t) { findings.push(['MCE060', 'error', 'This ( is never closed. Add the missing ) to close it.', '', t, t]); }); } catch (e) { /* never throw */ } return findings; }, function rule061(sql, tokens, ctx) { var findings = []; if (!ctx.schema || !ctx.schema.byName) return findings; var parsed = ctx.getParsed(); if (!parsed) return findings; parsed.from.forEach(function (f) { if (f.kind !== 'table' || !f.name || f.isFunction) return; // STRING_SPLIT(...), OPENJSON(...): built-in functions, not tables if (/^ENT\./i.test(f.name)) return; var clean = f.name.replace(/^\[|\]$/g, ''); if (/^_/.test(clean)) return; if (ctx.ctes && ctx.ctes[clean.toUpperCase()]) return; var cteNames = (parsed.ctes || []).map(function (c) { return c.name.toUpperCase(); }); if (cteNames.indexOf(clean.toUpperCase()) !== -1) return; if (!lookupTable(ctx.schema.byName, clean)) { findings.push(['MCE061', 'error', 'Data Extension or Data View "' + clean + '" was not found in this Business Unit. Check the spelling, or add the Ent. prefix for a shared object.', DOCS.FROM, f, f]); }; }); return findings; }, function rule062(sql, tokens, ctx) { return ctx.getUnknownColumns().map(function (unknown) { var item = unknown.item; return ['MCE062', 'warning', 'Column "' + unknown.column + '" does not exist on "' + unknown.table.name + '". Check the spelling or remove the column.', DOCS.DV, item, item, unknown.fixes]; }); }, function rule063(sql, tokens, ctx) { var core = ctx.getCoreTokens(); var findings = valuesConstructorFindings(sql, core).concat(systemViewFindings(sql, core)); var reservedWord = function (t, written, word, advice) { return ['MCE063', 'error', 'Marketing Cloud Engagement\'s query check refuses ' + written + ': \'' + word + '\' is a reserved word and may not appear in your query. ' + advice, DOCS.SFREF, t, t]; }; var aliasStarts = {}; core.forEach(function (t, k) { if (isReservedAliasAt(core, k)) aliasStarts[t.start] = true; }); tokens.forEach(function (t, i) { if (t.type === 'identifier' && !t.quote && t.upper.indexOf('FN_') === 0) { findings.push(reservedWord(t, 'a name that starts with fn_', 'fn_', 'Pick another name.')); return; }; var previous = tokens[i - 1]; if (previous && previous.value === '.') return; if (t.quote) { if (t.quote === 'bracket' && RESERVED_IN_BRACKETS.indexOf(t.upper) !== -1) findings.push(reservedWord(t, t.upper + ' in brackets too', t.upper, 'Pick another name.')); return; }; var isCall = tokens[i + 1] && tokens[i + 1].value === '('; if (t.type === 'keyword' && (t.upper === 'SYSTEM_USER' || t.upper === 'CURRENT_USER')) { if (!aliasStarts[t.start]) findings.push(reservedWord(t, t.upper, t.upper, 'Remove it.')); } else if (t.type === 'variable' && SYSTEM_FUNCTIONS_REFUSED.indexOf(t.upper) !== -1) { findings.push(reservedWord(t, t.upper, t.upper, 'Remove it.')); } else if (t.type === 'identifier' && isCall && RESERVED_CALLS.indexOf(t.upper) !== -1) { findings.push(reservedWord(t, t.upper + '()', t.upper, 'Remove it.')); } else if (t.type === 'identifier' && isCall && t.upper === 'OPENJSON') { findings.push(['MCE063', 'error', 'Marketing Cloud Engagement does not allow OPENJSON in a query. Its query check reads it as a Data Extension name. Remove it.', DOCS.JSON, t, t]); }; }); return findings; }, function rule064(sql, tokens, ctx) { var findings = []; if (!ctx.options || ctx.options.isParentBusinessUnit !== true) return findings; var significant = tokens.filter(function (t) { return t.type !== 'whitespace' && t.type !== 'comment'; }); significant.forEach(function (t, i) { if (t.type !== 'identifier' || t.quote || t.upper !== 'ENT') return; var dot = significant[i + 1]; var name = significant[i + 2]; var previous = significant[i - 1]; if (!dot || dot.value !== '.' || !name || !isName(name)) return; if (!previous || !(isKw(previous, 'FROM') || isKw(previous, 'JOIN') || isKw(previous, 'APPLY'))) return; findings.push(['MCE064', 'error', 'The parent Business Unit refuses the Ent. prefix, which works only from a child Business Unit. Remove it. The parent reads its shared Data Extensions and _EnterpriseAttribute without it.', DOCS.FROM, t, name, [ { title: 'Remove Ent.', edits: [{ start: t.start, end: dot.end, text: '' }], isSafe: true, isInFixAll: true } ]]); }); return findings; }, function rule066(sql, tokens, ctx) { var findings = []; var significant = tokens.filter(function (t) { return t.type !== 'comment'; }); significant.forEach(function (t, i) { if (t.type === 'keyword' && t.upper === 'FROM' && t.isDistinctFrom) { findings.push(['MCE066', 'error', 'The query check reads the FROM in IS DISTINCT FROM as a table and refuses the query. Compare with = or !=, and test NULL with IS NULL.', DOCS.WHERE, t, t]); return; }; if (t.upper !== 'TRIM' || t.quote || !significant[i + 1] || significant[i + 1].value !== '(') return; var level = 0; for (var j = i + 1; j < significant.length; j++) { var inner = significant[j]; if (inner.value === '(') level++; if (inner.value === ')') level--; if (level === 0) break; if (level === 1 && inner.type === 'keyword' && inner.upper === 'FROM') { var charStart = i + 2; var direction = significant[charStart]; if (charStart < j && !direction.quote && (direction.type === 'keyword' || direction.type === 'identifier') && ['LEADING', 'TRAILING', 'BOTH'].indexOf(direction.upper) !== -1) charStart++; var side = charStart > i + 2 ? direction.upper : 'BOTH'; var close = matchParen(significant, i + 1); var fixes; var hasComment = tokens.some(function (c) { return c.type === 'comment' && c.start > t.start && c.end <= significant[close].start; }); var hasComma = significant.slice(j + 1, close).some(function (c) { return isPunctToken(c, ',') && c.depth === significant[i + 1].depth + 1; }); if (isPunctToken(significant[close], ')') && significant[close].depth === significant[i + 1].depth && close > j + 1 && !hasComment && !hasComma) { var value = sql.slice(significant[j + 1].start, significant[close - 1].end); var cut = charStart < j ? ', ' + sql.slice(significant[charStart].start, significant[j - 1].end) : ''; var rewrite = side === 'LEADING' ? 'LTRIM(' + value + cut + ')' : (side === 'TRAILING' ? 'RTRIM(' + value + cut + ')' : 'LTRIM(RTRIM(' + value + cut + ')' + cut + ')'); var isChangingTwice = side === 'BOTH' && significant.slice(charStart, j).some(function (c, n, list) { return !c.quote && CHANGING_CALLS[c.upper] === 1 && isPunctToken(list[n + 1], '('); }); fixes = [{ title: 'Use ' + (side === 'LEADING' ? 'LTRIM' : (side === 'TRAILING' ? 'RTRIM' : 'LTRIM and RTRIM')), edits: formatLaidOutEdits(sql, [{ start: t.start, end: significant[close].end, text: rewrite }], ctx), isSafe: !isChangingTwice, isInFixAll: !isChangingTwice }]; }; findings.push(['MCE066', 'error', 'The query check reads the FROM inside TRIM as a table and refuses the query. Use ' + (side === 'LEADING' ? 'LTRIM(y, c)' : (side === 'TRAILING' ? 'RTRIM(y, c)' : 'LTRIM(RTRIM(y, c), c)')) + ' instead, which cuts the characters c from y, or spaces where there is no c.', DOCS.STR, inner, inner, fixes]); }; }; }); return findings; }, function rule067(sql, tokens) { var findings = []; var significant = tokens.filter(function (t) { return t.type !== 'comment'; }); significant.forEach(function (t, i) { if (!(isKw(t, 'FROM') || isKw(t, 'JOIN') || isKw(t, 'APPLY'))) return; if (isKw(t, 'APPLY')) { var nodes = xmlNodesFinding(sql, significant, i + 1); if (nodes) { findings.push(nodes); return; }; }; var name = significant[i + 1]; var open = significant[i + 2]; if (!name || name.type !== 'identifier' || name.quote || name.upper === 'OPENJSON' || !open || open.value !== '(') return; var level = 0; var hasComma = false; for (var j = i + 2; j < significant.length; j++) { if (significant[j].value === '(') level++; if (significant[j].value === ')') level--; if (level === 0) break; if (level === 1 && significant[j].value === ',') hasComma = true; }; if (!hasComma) return; var isSeries = name.upper === 'GENERATE_SERIES'; var advice = isSeries ? 'Use a subquery: SELECT TOP 10 ROW_NUMBER() OVER (ORDER BY SubscriberID) AS n FROM _Subscribers ORDER BY n.' : 'Use a subquery, or split text with CHARINDEX and SUBSTRING.'; var subject = isSeries ? name.value + ' with more than one argument' : 'a table function with more than one argument, such as ' + name.value + ','; findings.push(['MCE067', 'error', 'The query check reads ' + subject + ' as a table name and refuses the query. ' + advice, DOCS.FROM, name, name]); }); return findings; }, function rule068(sql, tokens) { var findings = []; tokens.forEach(function (t) { if (t.type !== 'string') return; var first = t.value.charAt(0); if (first !== 'N' && first !== 'n') return; findings.push(['MCE068', 'error', 'The query check adds N to every text value itself, so an N before the quote fails with Incorrect syntax. Remove the N. Plain quotes keep Polish and other letters.', DOCS.STR, t, t, [ { title: 'Remove the N', edits: [{ start: t.start, end: t.start + 1, text: '' }], isSafe: true, isInFixAll: true } ]]); }); return findings; }, function rule069(sql, tokens) { var findings = []; tokens.forEach(function (t) { if (t.type !== 'comment' || t.value.split("'").length % 2 === 1) return; /* none, or pairs */ var edits = []; for (var i = 0; i < t.value.length; i++) { if (t.value.charAt(i) !== "'") continue; edits.push({ start: t.start + i, end: t.start + i + 1, text: '' }); }; findings.push(['MCE069', 'error', 'Marketing Cloud Engagement reads an unpaired apostrophe in a comment as the start of a text value, after Validate passed it. Alone, it fails the run with Error while processing square brackets. With one in another comment, it changes the query: \'a\' came back as aN. Remove the apostrophe, or pair it with another in the same comment.', DOCS.BASICS, t, t, [ { title: 'Remove the apostrophes', edits: edits, isSafe: true, isInFixAll: true } ]]); }); return findings; }, function rule070(sql, tokens, ctx) { var findings = []; var sig = tokens.filter(function (t) { return t.type !== 'whitespace' && t.type !== 'comment'; }); for (var i = 0; i < sig.length - 1; i++) { var t = sig[i]; if (t.type !== 'keyword' || t.upper !== 'FOR' || t.depth !== 0) continue; var kind = String(sig[i + 1].upper || sig[i + 1].value || '').toUpperCase(); if (kind !== 'JSON' && kind !== 'XML') continue; var last = sig[sig.length - 1]; if (last.type === 'punct' && last.value === ';' && sig.length > 1) last = sig[sig.length - 2]; var alias = kind === 'JSON' ? 'JsonOutput' : 'XmlOutput'; findings.push(['MCE070', 'error', 'Marketing Cloud Engagement runs the query inside another one, and FOR ' + kind + ' on the query itself returns a column with no name: the check fails with No column name was specified for column 1 of Qry. Put the query in a subquery with an alias.', kind === 'JSON' ? DOCS.JSON : DOCS.SEL, t, sig[i + 1], [ { title: 'Wrap it in a subquery', edits: formatLaidOutEdits(sql, [ { start: 0, end: 0, text: 'SELECT (\n' }, { start: last.end, end: last.end, text: '\n) AS ' + alias } ], ctx), isSafe: true, isInFixAll: true } ]]); break; }; return findings; }, function rule071(sql, tokens, ctx) { var findings = []; var core = ctx.getCoreTokens(); ctx.getSendLogJoins().forEach(function (join) { var best = join.best; if (!best) return; var missing = SEND_LOG_KEYS.filter(function (key) { return !best.matched[key]; }); if (!missing.length) return; var missingText = missing.length === 1 ? missing[0] : missing.slice(0, -1).join(', ') + ' and ' + missing[missing.length - 1]; var conditions = missing.map(function (key) { return join.joinedRef + '.' + key + ' = ' + best.anchorRef + '.' + key; }); var onTok = core[join.onIdx]; var firstTok = core[join.start]; var lastTok = core[join.end - 1]; var hasOr = formatterSplitAndOr(core, join.start, join.end, join.depth).some(function (seg) { return seg.connector === 'OR'; }); var edits; var isMultiLine = sql.slice(firstTok.start, lastTok.end).indexOf('\n') !== -1; var onLinePrefix = sql.slice(lineStartOf(sql, onTok.start), onTok.start); if (!hasOr && (isMultiLine || /^[ \t]*$/.test(onLinePrefix))) { var indent = isMultiLine ? /^[ \t]*/.exec(sql.slice(lineStartOf(sql, lastTok.start), lastTok.start))[0] : onLinePrefix; var insertAt = lastTok.end; var lineEnd = sql.indexOf('\n', insertAt); if (lineEnd === -1) lineEnd = sql.length; if (sql.charAt(lineEnd - 1) === '\r') lineEnd--; if (/^[ \t]*--/.test(sql.slice(insertAt, lineEnd))) insertAt = lineEnd; edits = [{ start: insertAt, end: insertAt, text: conditions.map(function (condition) { return '\n' + indent + 'AND ' + condition; }).join('') }]; } else { edits = appendConditionEdits(core, join.start, join.end, join.depth, conditions.join(' AND ')); }; findings.push(['MCE071', 'warning', 'Joining ' + join.joinedName + ' to ' + best.anchorName + ' leaves out ' + missingText + ', so the join can repeat rows. JobID, ListID, BatchID and SubscriberID together identify one send to one subscriber. Add the missing conditions.', DOCS.DV, onTok, onTok, [ { title: 'Add ' + missingText + ' to the join', edits: edits, isSafe: false, isInFixAll: false } ]]); }); return findings; }, function rule072(sql, tokens, ctx) { var findings = []; var schema = ctx.schema; if (!schema || !schema.byName || schema.isDataExtensionListLoaded !== true) return findings; var core = ctx.getCoreTokens(); var parsed = ctx.getParsed(); var cteNames = {}; ((parsed && parsed.ctes) || []).forEach(function (c) { if (c && c.name) cteNames[String(c.name).replace(/^\[|\]$/g, '').toUpperCase()] = true; }); var tokenAt = {}; core.forEach(function (t, i) { tokenAt[t.start] = i; }); var seen = {}; var scopes = ctx.getSelectScopes(); var scopeBySelect = {}; scopes.forEach(function (scope) { scopeBySelect[scope.selectIdx] = scope; }); function nameEdit(tok, name) { return { start: tok.start, end: tok.end, text: tok.quote === 'bracket' ? '[' + name + ']' : formatIdentifier(name) }; }; scopes.forEach(function (scope) { scope.from.forEach(function (f) { if (f.kind !== 'table' || !f.name || f.isFunction || seen[f.start]) return; seen[f.start] = true; var isEntPrefixed = /^ENT\./i.test(f.name); var bare = stripEnt(f.name).replace(/^\[|\]$/g, ''); if (bare.charAt(0) !== '_' || cteNames[bare.toUpperCase()] || lookupTable(schema.byName, f.name) || isUndocumentedDataView(bare)) return; var first = tokenAt[f.start]; if (first === undefined) return; var chain = parseNameChain(core, first, core.length); if (!chain.parts.length) return; var nameTok = chain.parts[chain.parts.length - 1]; var lower = bare.toLowerCase(); var maxDistance = maxNameDistance(bare); var matches = underscoreTableNames(schema, isEntPrefixed).map(function (name) { return { name: name, distance: editDistance(name.toLowerCase(), lower, true) }; }).filter(function (m) { return m.distance <= maxDistance; }); matches.sort(function (a, b) { return a.distance - b.distance || (a.name < b.name ? -1 : a.name > b.name ? 1 : 0); }); matches = matches.slice(0, 3); var isSafe = matches.length === 1; var qualifiers = qualifierTokens(core, f, scope, scopeBySelect); var fixes = matches.map(function (m) { return { title: 'Change to ' + m.name, edits: [nameEdit(nameTok, m.name)].concat(qualifiers.map(function (q) { return nameEdit(q, m.name); })), isSafe: isSafe, isInFixAll: isSafe }; }); var names = matches.map(function (m) { return m.name; }); var guess = names.length > 1 ? names.slice(0, -1).join(', ') + ' or ' + names[names.length - 1] : names[0]; findings.push(['MCE072', 'warning', 'No System Data View is named ' + bare + ', and a Data Extension name cannot start with an underscore. ' + (guess ? 'Did you mean ' + guess + '?' : 'Check the spelling.'), DOCS.DV, chain.parts[0], nameTok, fixes]); }); }); return findings; }, function rule073(sql, tokens, ctx) { var findings = []; var schema = ctx.schema; if (!schema || !schema.byName || schema.isDataExtensionListComplete !== true) return findings; var isChild = !!ctx.options && ctx.options.isParentBusinessUnit === false; var hasParentList = isChild && schema.isParentDataExtensionListLoaded === true; var core = ctx.getCoreTokens(); var parsed = ctx.getParsed(); var scopes = ctx.getSelectScopes(); var defined = {}; ((parsed && parsed.ctes) || []).forEach(function (c) { if (c && c.name) defined[String(c.name).toUpperCase()] = true; }); scopes.forEach(function (scope) { scope.from.forEach(function (f) { if (f.kind === 'derived' && f.alias) defined[String(f.alias).toUpperCase()] = true; }); }); var tokenAt = {}; core.forEach(function (t, i) { tokenAt[t.start] = i; }); var timeText = listTimeText(schema.dataExtensionListLoadedAt); var loaded = timeText ? ', loaded ' + timeText : ''; var seen = {}; scopes.forEach(function (scope) { scope.from.forEach(function (f) { if (f.kind !== 'table' || !f.name || f.isFunction || seen[f.start]) return; seen[f.start] = true; var first = tokenAt[f.start]; if (first === undefined) return; var before = core[first - 1]; if (!before || !(isKw(before, 'FROM') || isKw(before, 'JOIN') || isKw(before, 'APPLY') || (before.type === 'punct' && before.value === ','))) return; var chain = parseNameChain(core, first, core.length); var parts = chain.parts; var isEntPrefixed = parts.length === 2 && parts[0].type === 'identifier' && !parts[0].quote && parts[0].upper === 'ENT'; if (parts.length !== 1 && !isEntPrefixed) return; var nameTok = parts[parts.length - 1]; if (nameTok.type !== 'identifier') return; var bare = nameTok.value; var lower = bare.toLowerCase(); if (bare.charAt(0) === '_') return; if (isEntPrefixed) { if (!hasParentList || schema.byName['ent.' + lower]) return; findings.push(['MCE073', 'warning', 'ENT.' + bare + ' is not in the parent Business Unit\'s list of shared and synchronized Data Extensions' + loaded + '. Check the spelling, or reload the list if it was shared since.', DOCS.FROM, parts[0], nameTok, null, reloadListAction()]); return; }; if (defined[bare.toUpperCase()] || schema.byName[lower] || SQLSTUDIO_TEMP_NAME.test(bare) || isUnbracketedWordName(sql, core, first, schema)) return; if (hasParentList && schema.byName['ent.' + lower]) { findings.push(['MCE073', 'warning', bare + ' is a Data Extension of the parent Business Unit. A child Business Unit reads a shared Data Extension only as ENT.' + formatIdentifier(bare) + '.', DOCS.FROM, nameTok, nameTok, [ { title: 'Add ENT.', edits: [{ start: nameTok.start, end: nameTok.start, text: 'ENT.' }], isSafe: true, isInFixAll: false } ]]); return; }; findings.push(['MCE073', 'warning', bare + ' is not in this Business Unit\'s Data Extension list' + loaded + '. Check the spelling, or reload the list if you created it since.', DOCS.FROM, nameTok, nameTok, null, reloadListAction()]); }); }); return findings; }, function rule074(sql, tokens, ctx) { var findings = []; var core = ctx.getCoreTokens(); var arrived = ' arrived in SQL Server 2025, and Marketing Cloud Engagement runs SQL Server 2022, so it refuses it'; for (var i = 0; i < core.length; i++) { var t = core[i]; var prev = core[i - 1]; var next = core[i + 1]; var isAfterDot = !!prev && prev.type === 'punct' && prev.value === '.'; if (t.type === 'operator' && t.value.indexOf('||') === 0) { var operatorLength = t.value.indexOf('||=') === 0 ? 3 : 2; var operatorAnswer = operatorLength === 2 ? ': Incorrect syntax near \'|\'' : ''; findings.push(['MCE074', 'error', 'The ' + t.value.slice(0, operatorLength) + ' operator' + arrived + operatorAnswer + '. Use CONCAT(a, b), which also treats NULL as empty text.', DOCS.BASICS, t.start, t.start + operatorLength]); continue; }; if (t.type === 'keyword' && t.upper === 'CURRENT_DATE') { if (isAfterDot || isKw(prev, 'AS') || (next && next.type === 'punct' && next.value === '.')) continue; var isCall = !!next && next.type === 'punct' && next.value === '('; findings.push(['MCE074', 'error', 'CURRENT_DATE' + arrived + ': Incorrect syntax near the keyword \'' + t.raw + '\'. Use CAST(GETDATE() AS DATE).', DOCS.BASICS, t, t, isCall ? null : [{ title: 'Use CAST(GETDATE() AS DATE)', edits: [{ start: t.start, end: t.end, text: 'CAST(GETDATE() AS DATE)' }], isSafe: true, isInFixAll: true }]]); continue; }; if (!((t.type === 'identifier' && !t.quote) || t.type === 'keyword') || isAfterDot) continue; var close = callClose(core, i); if (close === -1) continue; var open = core[i + 1]; var name = t.upper; if (NEWER_FUNCTIONS[name]) { var isTableName = name === 'PRODUCT' && (isKwAny(prev, ['FROM', 'JOIN', 'APPLY', 'WITH']) || ((isKw(prev, 'AS') || (!!prev && prev.type === 'punct' && (prev.value === ')' || prev.value === ','))) && isInFromList(core, i)) || (isKw(core[close + 1], 'AS') && !!core[close + 2] && core[close + 2].value === '(')); var functionAnswer = REFUSED_AS_UNKNOWN_FUNCTION[name] ? ': \'' + t.raw + '\' is not a recognized built-in function name' : ''; if (!isTableName) findings.push(['MCE074', 'error', name + arrived + functionAnswer + '. ' + NEWER_FUNCTIONS[name], name.indexOf('JSON_') === 0 ? DOCS.JSON : DOCS.BASICS, t, t]); }; if (JSON_RETURNING_FUNCTIONS[name]) { for (var j = i + 2; j < close; j++) { var word = core[j]; if (word.depth !== open.depth + 1 || !(word.type === 'identifier' || word.type === 'keyword') || word.upper !== 'RETURNING') continue; var typeTok = j + 1 < close && (core[j + 1].type === 'identifier' || core[j + 1].type === 'keyword') && RETURNING_TYPES[core[j + 1].upper] ? core[j + 1] : null; var before = core[j - 1]; var isAfterValue = before.type === 'string' || before.type === 'number' || before.type === 'identifier' || before.type === 'variable' || (before.type === 'punct' && before.value === ')') || (before.type === 'keyword' && (OPERAND_END_KEYWORDS.indexOf(before.upper) !== -1 || before.upper === 'ASC' || before.upper === 'DESC')); var isFirstInCall = before.type === 'punct' && before.value === '(' && j - 1 === i + 1; if (!isAfterValue && !(typeTok && isFirstInCall)) continue; if (!typeTok) { if (j + 1 !== close || !isAfterValue) continue; findings.push(['MCE074', 'error', 'RETURNING' + arrived + '. Remove RETURNING.' + (name === 'JSON_VALUE' ? ' Without it, a value over 4,000 characters comes back as NULL.' : ''), DOCS.JSON, word, word, [{ title: 'Remove RETURNING', edits: removalEdits(sql, tokens, word, word), isSafe: false, isInFixAll: false }]]); break; }; var typeLastIndex = j + 1; if (core[j + 2] && core[j + 2].type === 'punct' && core[j + 2].value === '(' && j + 2 < close) { var typeClose = callClose(core, j + 1); if (typeClose === -1 || typeClose >= close) continue; typeLastIndex = typeClose; }; if (typeLastIndex + 1 !== close) continue; var typeLast = core[typeLastIndex]; var typeText = sql.slice(typeTok.start, typeLast.end).replace(/\s+/g, ' '); var typeKey = typeText.replace(/ /g, '').toUpperCase(); var message = 'RETURNING' + arrived + ': it answers Incorrect syntax near \'' + typeTok.raw + '\'. Remove RETURNING and its type.'; if (name === 'JSON_VALUE') message += ' Without it, a value over 4,000 characters comes back as NULL.'; var isSafe = name !== 'JSON_VALUE' && (typeKey === 'JSON' || /\(MAX\)$/.test(typeKey)); findings.push(['MCE074', 'error', message, DOCS.JSON, word, typeLast, [{ title: 'Remove RETURNING ' + typeText, edits: removalEdits(sql, tokens, word, typeLast), isSafe: isSafe, isInFixAll: isSafe }]]); break; }; }; if (name === 'CAST' || name === 'TRY_CAST' || name === 'CONVERT' || name === 'TRY_CONVERT') { var newTypeIndex = -1; if (name === 'CONVERT' || name === 'TRY_CONVERT') { newTypeIndex = i + 2; } else { for (var k = close - 1; k > i + 1; k--) { if (core[k].depth === open.depth + 1 && isKw(core[k], 'AS')) { newTypeIndex = k + 1; break; }; }; }; var newType = newTypeIndex !== -1 && newTypeIndex < close ? core[newTypeIndex] : null; if (newType && newType.type === 'identifier' && NEWER_TYPES[newType.upper]) { var newTypeLast = newType; if (core[newTypeIndex + 1] && core[newTypeIndex + 1].type === 'punct' && core[newTypeIndex + 1].value === '(') { var newTypeClose = callClose(core, newTypeIndex); if (newTypeClose !== -1 && newTypeClose < close) newTypeLast = core[newTypeClose]; }; var typeAnswer = newType.upper === 'JSON' ? ': Type json is not a defined system type' : ''; findings.push(['MCE074', 'error', 'The ' + newType.upper + ' data type' + arrived + typeAnswer + '. Use NVARCHAR(MAX).', DOCS.CONV, newType, newTypeLast, newType.upper === 'JSON' ? [{ title: 'Use NVARCHAR(MAX)', edits: [{ start: newType.start, end: newTypeLast.end, text: 'NVARCHAR(MAX)' }], isSafe: false, isInFixAll: false }] : null]); }; }; if (name === 'SUBSTRING') { var commas = []; for (var m = i + 2; m < close; m++) { if (core[m].depth === open.depth + 1 && core[m].type === 'punct' && core[m].value === ',') commas.push(m); }; if (commas.length !== 1 || commas[0] === i + 2 || commas[0] === close - 1) continue; findings.push(['MCE074', 'error', 'SUBSTRING with two arguments' + arrived + ': The substring function requires 3 argument(s). Add 2147483647 as the length, to read to the end.', DOCS.STR, t, t, [{ title: 'Add 2147483647 as the length', edits: [{ start: core[close - 1].end, end: core[close - 1].end, text: ', 2147483647' }], isSafe: true, isInFixAll: true }]]); }; }; return findings; }, function rule075(sql, tokens, ctx) { var findings = []; var core = ctx.getCoreTokens(); var isPlainName = function (k) { return !!core[k] && core[k].type === 'identifier' && !core[k].quote; }; var isDefinitionAt = function (k) { return isPlainName(k) && isKw(core[k + 1], 'AS') && isPunctToken(core[k + 2], '('); }; var clauses = []; for (var i = 0; i < core.length; i++) { if (!isPlainName(i) || core[i].upper !== 'WINDOW' || isPunctToken(core[i - 1], '.') || !isDefinitionAt(i + 1)) continue; var clause = { at: i, end: i, depth: core[i].depth, defs: {} }; var k = i + 1; for (;;) { var close = matchParen(core, k + 2); if (!isPunctToken(core[close], ')')) break; clause.defs[core[k].upper] = { open: k + 2, close: close }; clause.end = close; if (!(isPunctToken(core[close + 1], ',') && isDefinitionAt(close + 2))) break; k = close + 2; }; clauses.push(clause); i = clause.end; }; var refs = []; for (var j = 0; j < core.length; j++) { if (!isKw(core[j], 'OVER') || !isPunctToken(core[j - 1], ')')) continue; var refAt = isPlainName(j + 1) ? j + 1 : (isPunctToken(core[j + 1], '(') && isPlainName(j + 2) ? j + 2 : -1); if (refAt === -1) continue; refs.push({ tok: core[refAt], idx: refAt, isBare: refAt === j + 1, depth: core[j].depth }); }; var clauseOf = function (ref) { for (var c = 0; c < clauses.length; c++) { if (clauses[c].depth === ref.depth && clauses[c].at > ref.idx) return clauses[c]; }; return null; }; var definitionText = function (cl, name, seen) { var def = cl.defs[name]; if (!def || seen[name]) return null; if (def.close === def.open + 1) return ''; var first = core[def.open + 1]; var text = sql.slice(first.start, core[def.close - 1].end); if (isPlainName(def.open + 1)) { var next = {}; for (var key in seen) { if (seen.hasOwnProperty(key)) next[key] = true; }; next[name] = true; var inner = definitionText(cl, first.upper, next); if (inner === null) return null; text = inner + sql.slice(first.end, core[def.close - 1].end); }; return text; }; clauses.forEach(function (cl) { var edits = []; var isResolved = true; refs.forEach(function (ref) { if (clauseOf(ref) !== cl || ref.idx > cl.at) return; var text = definitionText(cl, ref.tok.upper, {}); if (text === null) { isResolved = false; return; }; edits.push({ start: ref.tok.start, end: ref.tok.end, text: ref.isBare ? '(' + text + ')' : text }); }); edits.push({ start: removalStart(sql, tokens, core[cl.at].start), end: core[cl.end].end, text: '' }); findings.push(['MCE075', 'error', 'Marketing Cloud Engagement refuses the WINDOW clause of SQL Server 2022: Incorrect syntax near \'WINDOW\'. Write each window inside the OVER ( ... ) that names it.', DOCS.WINDOW, core[cl.at], core[cl.at], isResolved ? [{ title: 'Write each window into its OVER', edits: edits, isSafe: true, isInFixAll: true }] : null]); }); refs.forEach(function (ref) { var cl = clauseOf(ref); if (cl && cl.defs[ref.tok.upper]) return; findings.push(['MCE075', 'error', 'OVER ' + ref.tok.raw + ' names a window of the WINDOW clause of SQL Server 2022, which Marketing Cloud Engagement refuses. Write the window\'s PARTITION BY and ORDER BY inside OVER ( ... ).', DOCS.WINDOW, ref.tok, ref.tok]); }); return findings; }, function rule076(sql, tokens, ctx) { var findings = []; var core = ctx.getCoreTokens(); var REFUSED = { WHERE: 'WHERE', GROUP: 'GROUP BY', HAVING: 'HAVING', ON: 'ON' }; var CLAUSES = { SELECT: 1, FROM: 1, WHERE: 1, GROUP: 1, HAVING: 1, ORDER: 1, ON: 1, JOIN: 1, UNION: 1, EXCEPT: 1, INTERSECT: 1 }; var AGGREGATES = { APPROX_COUNT_DISTINCT: 1, AVG: 1, CHECKSUM_AGG: 1, COUNT: 1, COUNT_BIG: 1, GROUPING: 1, GROUPING_ID: 1, MAX: 1, MIN: 1, STDEV: 1, STDEVP: 1, STRING_AGG: 1, SUM: 1, VAR: 1, VARP: 1 }; var WINDOW_ONLY = { ROW_NUMBER: true, RANK: true, DENSE_RANK: true, NTILE: true, LAG: true, LEAD: true, FIRST_VALUE: true, LAST_VALUE: true, CUME_DIST: true, PERCENT_RANK: true, PERCENTILE_CONT: false, PERCENTILE_DISC: false }; var FRAMELESS_WINDOW_FUNCTIONS = { ROW_NUMBER: 1, RANK: 1, DENSE_RANK: 1, NTILE: 1, LAG: 1, LEAD: 1, CUME_DIST: 1, PERCENT_RANK: 1 }; var overAfter = function (close) { var k = close + 1; if (isKw(core[k], 'WITHIN') && isKw(core[k + 1], 'GROUP') && isPunctToken(core[k + 2], '(')) k = matchParen(core, k + 2) + 1; if (core[k] && (core[k].upper === 'IGNORE' || core[k].upper === 'RESPECT') && core[k + 1] && core[k + 1].upper === 'NULLS') k += 2; return isKw(core[k], 'OVER') ? k : -1; }; var overBody = function (over) { if (!isPunctToken(core[over + 1], '(')) return null; var close = matchParen(core, over + 1); var body = []; for (var k = over + 2; k < close; k++) { if (core[k].depth === core[over + 1].depth + 1) body.push(k); }; return { close: close, body: body }; }; var enclosingCall = function (nameIdx) { var depth = core[nameIdx].depth; for (var k = nameIdx - 1; k >= 0; k--) { if (core[k].depth >= depth) continue; depth = core[k].depth; if (!isPunctToken(core[k], '(') || isKw(core[k + 1], 'SELECT')) return null; var owner = core[k - 1]; if (!owner) return null; if (isKw(owner, 'OVER')) return 'OVER'; var ownerIdx = k - 1; if (isKw(owner, 'GROUP') && isKw(core[k - 2], 'WITHIN') && isPunctToken(core[k - 3], ')')) ownerIdx = callStartBefore(core, k - 3); if (ownerIdx === -1 || isPunctToken(core[ownerIdx - 1], '.') || (!isName(core[ownerIdx]) && core[ownerIdx].type !== 'keyword')) continue; var ownerName = core[ownerIdx].upper; var ownerClose = matchParen(core, ownerIdx + 1); if (AGGREGATES[ownerName] || WINDOW_ONLY.hasOwnProperty(ownerName) || overAfter(ownerClose) !== -1) return ownerName; }; return null; }; var columnStarts = null; var holdsColumn = function (from, to) { if (!columnStarts) { columnStarts = []; ctx.getScopeReadings().list.forEach(function (reading) { [reading.select, reading.where, reading.groupBy, reading.having, reading.orderBy].concat(reading.ons).forEach(function (clause) { if (clause) clause.refs.forEach(function (ref) { columnStarts.push(ref.first); }); }); }); }; return columnStarts.some(function (k) { return k >= from && k < to; }); }; var distinctCountFix = function (i, close, over) { if (core[i].upper !== 'COUNT' && core[i].upper !== 'COUNT_BIG') return null; if (over !== close + 1) return null; var window = overBody(over); if (!window) return null; var hasOther = window.body.some(function (k) { return core[k].type === 'keyword' && (core[k].upper === 'ORDER' || core[k].upper === 'ROWS' || core[k].upper === 'RANGE'); }); if (hasOther || (window.body.length && core[window.body[0]].upper !== 'PARTITION')) return null; var argFirst = i + 3; if (argFirst >= close) return null; for (var k = argFirst; k < close; k++) { if (core[k].depth === core[argFirst].depth && isPunctToken(core[k], ',')) return null; }; if (argFirst === close - 1 && (core[argFirst].type === 'number' || core[argFirst].type === 'string')) return null; if (!holdsColumn(argFirst, close)) return null; var value = sql.slice(core[argFirst].start, core[close - 1].end); var partition = window.body.length ? sql.slice(core[over + 2].start, core[window.close - 1].end) : ''; var lead = partition ? partition + ' ' : ''; return [{ title: 'Count with DENSE_RANK', edits: [{ start: core[i].start, end: core[window.close].end, text: '(DENSE_RANK() OVER (' + lead + 'ORDER BY ' + value + ') + DENSE_RANK() OVER (' + lead + 'ORDER BY ' + value + ' DESC) - 1 - MAX(CASE WHEN ' + value + ' IS NULL THEN 1 ELSE 0 END) OVER (' + partition + '))' }], isSafe: true, isInFixAll: true }]; }; for (var i = 0; i < core.length; i++) { if (!isName(core[i]) || core[i].quote || !isPunctToken(core[i + 1], '(') || isPunctToken(core[i - 1], '.')) continue; var name = core[i].upper; var close = matchParen(core, i + 1); var over = overAfter(close); if (over === -1) { if (!WINDOW_ONLY.hasOwnProperty(name)) continue; findings.push(['MCE076', 'error', WINDOW_ONLY[name] ? name + ' needs OVER (ORDER BY ...) after it: The function \'' + name + '\' must have an OVER clause. Add OVER (ORDER BY ...) with the columns that set the order, and PARTITION BY to start again in each group.' : name + ' needs OVER after its WITHIN GROUP ( ... ): The function \'' + name + '\' must have an OVER clause. Write OVER () for one value over all rows, or OVER (PARTITION BY ...) for one in each group, and SELECT DISTINCT to keep one row of each.', DOCS.WINDOW, core[i], core[i]]); continue; }; var window = overBody(over); var isNamedWindow = !!window && window.body.length > 0 && isName(core[window.body[0]]); var hasOrder = !!window && window.body.some(function (k) { return isKw(core[k], 'ORDER') && isKw(core[k + 1], 'BY'); }); if (WINDOW_ONLY[name] && window && !isNamedWindow && !hasOrder) { findings.push(['MCE076', 'error', name + ' needs ORDER BY in its OVER ( ... ): The function \'' + name + '\' must have an OVER clause with ORDER BY. Add ORDER BY with the columns that set the order.', DOCS.WINDOW, core[i], core[over]]); }; var isFrameAt = function (k) { if (!(isKw(core[k], 'ROWS') || isKw(core[k], 'RANGE')) || isPunctToken(core[k - 1], '.')) return false; var next = core[k + 1]; return !!next && (next.type === 'number' || isKw(next, 'BETWEEN') || isKw(next, 'UNBOUNDED') || isKw(next, 'CURRENT')); }; var frame = window && !isNamedWindow ? window.body.filter(isFrameAt)[0] : undefined; if (frame !== undefined) { var isClosed = isPunctToken(core[window.close], ')'); var frameLast = isClosed ? window.close - 1 : window.close; if (FRAMELESS_WINDOW_FUNCTIONS[name]) { var frameFrom = window.body.indexOf(frame) > 0 ? core[window.body[window.body.indexOf(frame) - 1]].end : core[over + 1].end; findings.push(['MCE076', 'error', name + ' takes no ROWS or RANGE in its OVER ( ... ): The function \'' + name + '\' may not have a window frame. Remove the frame, and keep PARTITION BY and ORDER BY.', DOCS.WINDOW, core[frame], core[frameLast], isClosed ? [{ title: 'Remove the frame', edits: formatLaidOutEdits(sql, [{ start: frameFrom, end: core[frameLast].end, text: '' }], ctx), isSafe: true, isInFixAll: true }] : []]); } else if (!hasOrder && !WINDOW_ONLY.hasOwnProperty(name)) { findings.push(['MCE076', 'error', name + ' has ROWS or RANGE but no ORDER BY in its OVER ( ... ): Window frame with ROWS or RANGE must have an ORDER BY clause. Add ORDER BY before the frame, or remove the frame.', DOCS.WINDOW, core[frame], core[frame]]); }; var isRangeOffset = isKw(core[frame], 'RANGE') && window.body.some(function (k) { return (isKw(core[k], 'PRECEDING') || isKw(core[k], 'FOLLOWING')) && k > frame && !isKw(core[k - 1], 'UNBOUNDED'); }); if (isRangeOffset) findings.push(['MCE076', 'error', 'RANGE takes no number of rows: RANGE is only supported with UNBOUNDED and CURRENT ROW window frame delimiters. Write ROWS for a number of rows, as ROWS BETWEEN 1 PRECEDING AND CURRENT ROW.', DOCS.WINDOW, core[frame], core[frame]]); }; if (window && !isNamedWindow && hasOrder && (name === 'PERCENTILE_CONT' || name === 'PERCENTILE_DISC')) { findings.push(['MCE076', 'error', name + ' takes PARTITION BY alone in its OVER ( ... ): The function \'' + name + '\' may not have ORDER BY in OVER clause. Its WITHIN GROUP ( ORDER BY ... ) sets the order: remove ORDER BY from OVER.', DOCS.WINDOW, core[i], core[over]]); }; if (name === 'STRING_AGG') { findings.push(['MCE076', 'error', 'STRING_AGG takes no OVER: The function \'STRING_AGG\' is not a valid windowing function, and cannot be used with the OVER clause. Join the texts with GROUP BY in a subquery, then join it back.', DOCS.WINDOW, core[i], core[over]]); }; if (isKw(core[i + 2], 'DISTINCT')) { findings.push(['MCE076', 'error', 'DISTINCT cannot sit in ' + name + ' with OVER: Use of DISTINCT is not allowed with the OVER clause. Work out ' + name + '(DISTINCT ...) in a subquery with GROUP BY, then join it back.', DOCS.WINDOW, core[i + 2], core[i + 2], distinctCountFix(i, close, over)]); }; var owner = enclosingCall(i); if (owner) { findings.push(['MCE076', 'error', 'A window function cannot sit inside ' + owner + ' ( ... ): Windowed functions cannot be used in the context of another windowed function or aggregate. Work it out in a subquery\'s SELECT, then use that column in ' + owner + ' outside it.', DOCS.WINDOW, core[i], core[over]]); continue; }; var depth = core[i].depth; var clause = null; for (var k = i - 1; k >= 0 && !clause; k--) { var t = core[k]; if (t.depth > depth) continue; if (t.depth < depth) { if (isPunctToken(t, '(') && isKw(core[k + 1], 'SELECT')) break; depth = t.depth; }; if (t.type === 'keyword' && CLAUSES[t.upper] && !(t.upper === 'GROUP' && !isKw(core[k + 1], 'BY'))) clause = t.upper; }; if (!clause || !REFUSED[clause]) continue; findings.push(['MCE076', 'error', 'A window function cannot sit in ' + REFUSED[clause] + ': Windowed functions can only appear in the SELECT or ORDER BY clauses. Number the rows in a subquery\'s SELECT, then filter on that number outside it.', DOCS.WINDOW, core[i], core[over]]); }; return findings; }, function rule077(sql, tokens, ctx) { return parenQueryFindings(sql, ctx.getCoreTokens(), ctx.schema); }, function rule078(sql, tokens, ctx) { var core = ctx.getCoreTokens(); var commas = fromListCommas(core); var lists = []; Object.keys(commas).map(Number).sort(function (a, b) { return a - b; }).forEach(function (k) { var last = lists[lists.length - 1]; var isSameList = !!last && core[last.commas[last.commas.length - 1]].depth === core[k].depth && !core.slice(last.commas[last.commas.length - 1] + 1, k).some(function (t) { return t.depth === core[k].depth && isKw(t, 'FROM'); }); if (isSameList) last.commas.push(k); else lists.push({ commas: [k] }); }); var isEndOfList = function (t, depth) { if (t.depth < depth) return true; if (t.depth !== depth) return false; if (t.type === 'punct' && (t.value === ';' || t.value === ',')) return true; return t.type === 'keyword' && ['WHERE', 'GROUP', 'HAVING', 'ORDER', 'UNION', 'EXCEPT', 'INTERSECT', 'OPTION', 'WINDOW', 'FOR', 'SELECT'].indexOf(t.upper) !== -1; }; var commaEdits = []; var isParenAfter = false; lists.forEach(function (list) { var depth = core[list.commas[0]].depth; list.isGrouped = list.commas.some(function (k) { for (var j = k + 1; j < core.length && !isEndOfList(core[j], depth); j++) { if (core[j].depth === depth && (isKw(core[j], 'RIGHT') || isKw(core[j], 'FULL'))) return true; }; return false; }); if (list.isGrouped) return; list.first = commaEdits.length; list.commas.forEach(function (k) { var comma = core[k]; var before = /\s/.test(sql.charAt(comma.start - 1)) ? '' : ' '; var after = /\s/.test(sql.charAt(comma.end)) ? '' : ' '; commaEdits.push({ start: comma.start, end: comma.end, text: before + 'CROSS JOIN' + after }); if (isPunctToken(core[k + 1], '(')) isParenAfter = true; }); }); var isMce077Off = ((ctx.options && ctx.options.disabled) || []).indexOf('MCE077') !== -1; var composed = isParenAfter && !isMce077Off ? commaJoinParenFixes(sql, commaEdits, ctx.schema) : []; return lists.map(function (list) { var fixes = null; if (!list.isGrouped) { var end = list.first + list.commas.length; var edits = commaEdits.slice(list.first, end); var titles = []; composed.forEach(function (c) { if (c.index < list.first || c.index >= end) return; c.edits.forEach(function (e) { edits.push(e); }); if (titles.indexOf(c.title) === -1) titles.push(c.title); }); edits.sort(function (a, b) { return a.start - b.start; }); var taken = titles.map(function (title) { return ' and ' + title.charAt(0).toLowerCase() + title.slice(1); }).join(''); fixes = [{ title: 'Use CROSS JOIN' + taken, edits: edits, isSafe: true, isInFixAll: true }]; }; var advice = list.isGrouped ? 'A RIGHT or FULL JOIN follows a comma, so put the joins after it in parentheses as you write CROSS JOIN: A CROSS JOIN (B RIGHT JOIN C ON ...).' : 'Write CROSS JOIN for the comma, or JOIN ... ON with the condition that matches the rows.'; return ['MCE078', 'error', 'Marketing Cloud Engagement\'s query check refuses tables separated by commas in FROM: Old style JOIN (ANSI JOINs) syntax is not allowed. ' + advice, DOCS.JOIN, core[list.commas[0]], core[list.commas[0]], fixes]; }); }, function rule079(sql, tokens, ctx) { var findings = []; if (ctx.getStrict().errors.length) return findings; var core = ctx.getCoreTokens(); var known = knownColumnsOf(ctx); var MESSAGES = { WHERE: ' in WHERE: An aggregate may not appear in the WHERE clause unless it is in a subquery contained in a HAVING clause or a select list, and the column being aggregated is an outer reference. Put it in HAVING.', ON: ' in ON: An aggregate cannot appear in an ON clause unless it is in a subquery contained in a HAVING clause or select list, and the column being aggregated is an outer reference. Group in a derived table first.', 'GROUP BY': ' in GROUP BY: Cannot use an aggregate or a subquery in an expression used for the group by list of a GROUP BY clause. Group by the columns, and aggregate in the select list.', NESTED: ' inside another aggregate: Cannot perform an aggregate function on an expression containing an aggregate or a subquery. Add OVER () to the outer one, or work out the inner one in a subquery.', 'NESTED HAVING': ' inside another aggregate: Cannot perform an aggregate function on an expression containing an aggregate or a subquery. Work out the inner one in a derived table, then aggregate its column.' }; ctx.getScopeReadings().list.forEach(function (reading) { var isCorrelatable = reading.isCorrelatable; [reading.select, reading.where, reading.groupBy, reading.having, reading.orderBy].concat(reading.ons).forEach(function (clause) { if (!clause) return; var place = clause === reading.where ? 'WHERE' : (clause === reading.groupBy ? 'GROUP BY' : (clause.on ? 'ON' : null)); clause.aggregates.forEach(function (agg) { var close = matchParen(core, agg.idx + 1); var span = { start: core[agg.idx].start, end: core[close].end }; if (!place) { if (agg.isNested) findings.push(['MCE079', 'error', core[agg.idx].upper + (clause === reading.having ? MESSAGES['NESTED HAVING'] : MESSAGES.NESTED), DOCS.AGG, span, span]); return; }; if (place !== 'GROUP BY' && isCorrelatable) { var refs = refsWithin(clause.refs, agg.idx + 1, close); if (refs.length && !refs.some(function (ref) { return localBinding(core, reading, ref, known) === 'local'; })) return; }; findings.push(['MCE079', 'error', core[agg.idx].upper + MESSAGES[place], DOCS.AGG, span, span]); }); }); }); return findings; }, function rule080(sql, tokens, ctx) { var findings = []; if (ctx.getStrict().errors.length) return findings; var core = ctx.getCoreTokens(); var known = knownColumnsOf(ctx); var missing = missingColumnStarts(ctx); ctx.getScopeReadings().list.forEach(function (reading) { var grouping = groupingOf(core, reading, known); if (!grouping.isGrouped || grouping.constants.length) return; var scope = reading.scope; var outputNames = {}; scope.selectList.items.forEach(function (item) { if (item.name) outputNames[String(item.name).toUpperCase()] = true; }); var isUngrouped = function (ref) { var name = ref.parts[ref.parts.length - 1].upper; var isRefused = !ref.isAggregated && !ref.isWindowed && !outputNames[name] && !grouping.names[name] && localBinding(core, reading, ref, known) === 'local'; var start = refSpan(core, ref).start; if (!missing[start]) return isRefused; if (isRefused) noteRefusedTypo(ctx, start, 'MCE080', 'UNGROUPED'); return false; }; var push = function (ref, clauseName, tail) { var span = refSpan(core, ref); var text = sql.slice(span.start, span.end); findings.push(['MCE080', 'error', 'SQL Server refuses this column: Column \'' + text + '\' is invalid in the ' + clauseName + ' clause because it is not contained in ' + tail, DOCS.AGG, span, span]); }; if (reading.having) { reading.having.refs.forEach(function (ref) { if (isUngrouped(ref)) push(ref, 'HAVING', 'either an aggregate function or the GROUP BY clause. Move the condition to WHERE, or group by the column.'); }); }; if (!reading.orderBy || isSetOperationOrder(core, reading)) return; var itemKeys = {}; scope.selectList.items.forEach(function (item) { var expression = selectItemExpression(core, item); if (expression) itemKeys[groupTokenKeys(core, expression.from, expression.to).keys.join(' ')] = true; }); clauseItems(reading.orderBy).forEach(function (item) { var to = item.to; if (to - 1 > item.from && (isKw(core[to - 1], 'ASC') || isKw(core[to - 1], 'DESC'))) to--; if (to - item.from === 1 && core[item.from].type === 'number') return; if (itemKeys[groupTokenKeys(core, item.from, to).keys.join(' ')]) return; refsWithin(reading.orderBy.refs, item.from, to).forEach(function (ref) { if (!isUngrouped(ref)) return; push(ref, 'ORDER BY', grouping.hasGroupBy ? 'either an aggregate function or the GROUP BY clause. Sort by a grouped column or an aggregate.' : 'an aggregate function and there is no GROUP BY clause. Sort by an aggregate, or add GROUP BY.'); }); }); }); return findings; }, function rule081(sql, tokens, ctx) { var findings = []; if (ctx.getStrict().errors.length) return findings; var core = ctx.getCoreTokens(); var readings = ctx.getScopeReadings(); var known = knownColumnsOf(ctx); var missing = missingColumnStarts(ctx); var namesOf = function (reading, names) { if (!names) return null; reading.select.refs.forEach(function (ref) { names[ref.parts[ref.parts.length - 1].upper] = true; }); var isOpen = false; reading.scope.selectList.items.forEach(function (item) { if (item.kind === 'star') { if (!item._certainColumns) isOpen = true; (item._certainColumns || []).forEach(function (name) { if (name) names[String(name).toUpperCase()] = true; }); return; }; if (item.name) names[String(item.name).toUpperCase()] = true; }); return isOpen ? null : names; }; var frames = {}; readings.list.forEach(function (reading) { if (!reading.place) return; var key = reading.place.open + ':' + reading.place.parent; (frames[key] = frames[key] || []).push(reading); }); readings.list.forEach(function (reading) { var scope = reading.scope; var items = scope.selectList.items; var isStarList = items.some(function (item) { return item.kind === 'star'; }); if (reading.groupBy) { groupByElements(core, reading.groupBy).forEach(function (el) { var keys = groupTokenKeys(core, el.from, el.to); if (!el.isTop || keys.keys.length !== 1 || !/^[ns]:/.test(keys.keys[0])) return; var tok = core[keys.at[0]]; var span = { start: core[el.from].start, end: core[el.to - 1].end }; var fixes = []; var position = tok.type === 'number' && /^\d+$/.test(tok.value) ? Number(tok.value) : 0; var expression = position >= 1 && position <= items.length && !isStarList ? selectItemExpression(core, items[position - 1]) : null; var isGroupable = !!expression && expression.to > expression.from && !core.slice(expression.from, expression.to).some(function (t) { return isKw(t, 'OVER') || (isName(t) && !t.quote && !!GROUP_AGGREGATES[t.upper]); }); if (isGroupable) { var text = sql.slice(core[expression.from].start, core[expression.to - 1].end); fixes.push({ title: 'Group by ' + (text.length <= 40 ? text : 'column ' + position), edits: [{ start: span.start, end: span.end, text: text }], isSafe: false, isInFixAll: false }); }; var advice = tok.type === 'number' ? 'Write the column: a number in GROUP BY is no position in the select list.' : 'Write the column to group by.'; findings.push(['MCE081', 'error', 'GROUP BY ' + tok.raw + ' groups by a constant: Each GROUP BY expression must contain at least one column that is not an outer reference. ' + advice, DOCS.AGG, span, span, fixes]); }); }; if (!reading.orderBy) return; var place = reading.place; var isSetOrder = isSetOperationOrder(core, reading); var head = isSetOrder ? readings.bySelect[place.head] : reading; var width = head ? selectWidth(head.scope.selectList.items) : null; var listNames = null; var operator = ''; if (isSetOrder) { listNames = {}; frames[place.open + ':' + place.parent].forEach(function (other) { listNames = namesOf(other, listNames); }); var operatorToken = core[tokenIndexAtOrAfter(core, branchSpanOf(core, reading).start)]; operator = operatorToken && SET_OPERATOR_WORDS[operatorToken.upper] ? operatorToken.upper : 'UNION'; } else if (scope.clauses.distinct) { listNames = namesOf(reading, {}); }; clauseItems(reading.orderBy).forEach(function (item, k) { var to = item.to; if (to - 1 > item.from && (isKw(core[to - 1], 'ASC') || isKw(core[to - 1], 'DESC'))) to--; var tok = core[item.from]; var span = { start: tok.start, end: core[to - 1].end }; if (to - item.from === 1 && tok.type === 'number') { var position = /^\d+$/.test(tok.value) ? Number(tok.value) : -1; if (width !== null && position !== -1 && (position < 1 || position > width)) { findings.push(['MCE081', 'error', 'ORDER BY ' + tok.value + ' names no column: The ORDER BY position number ' + tok.value + ' is out of range of the number of items in the select list. The list has ' + width + (width === 1 ? ' column.' : ' columns.'), DOCS.SEL, span, span]); }; return; }; if (to - item.from === 1 && tok.type === 'string') { findings.push(['MCE081', 'error', 'This ORDER BY item is a text, the same on every row: A constant expression was encountered in the ORDER BY list, position ' + (k + 1) + '. Sort by a column, or remove the item.', DOCS.SEL, span, span]); return; }; if (!listNames) return; var outside = refsWithin(reading.orderBy.refs, item.from, to).filter(function (ref) { if (listNames[ref.parts[ref.parts.length - 1].upper]) return false; var isOwn = isSetOrder || localBinding(core, reading, ref, known) === 'local'; var start = refSpan(core, ref).start; if (!missing[start]) return isOwn; if (isOwn) noteRefusedTypo(ctx, start, 'MCE081', 'UNLISTED'); return false; })[0]; if (!outside) return; var name = sql.slice(refSpan(core, outside).start, refSpan(core, outside).end); findings.push(['MCE081', 'error', isSetOrder ? name + ' is in no select list of this ' + operator + ': ORDER BY items must appear in the select list if the statement contains a UNION, INTERSECT or EXCEPT operator. Sort by a column of the first SELECT.' : name + ' is not in the select list of this SELECT DISTINCT: ORDER BY items must appear in the select list if SELECT DISTINCT is specified. Sort by a column of the list.', DOCS.SEL, span, span]); }); }); return findings; }, function rule082(sql, tokens, ctx) { var findings = []; if (ctx.getStrict().errors.length) return findings; var core = ctx.getCoreTokens(); var readings = ctx.getScopeReadings(); if (!readings.index.isComplete) return findings; var nesting = ctx.getSelectNesting(); var known = knownColumnsOf(ctx); var clean = function (name) { return String(name).replace(/^\[|\]$/g, ''); }; var hasOwn = function (map, key) { return Object.prototype.hasOwnProperty.call(map, key); }; readings.list.forEach(function (reading) { var scope = reading.scope; var binder = scopeBinder(core, reading); if (binder.isMisread) return; var entries = binder.entries; var exposed = {}; var lastPivot = -1; entries.forEach(function (f, k) { if (isPivotEntry(core, f)) lastPivot = k; }); entries.forEach(function (f, k) { if (k < lastPivot) return; var name = f.alias ? clean(f.alias) : (f.name ? clean(stripEnt(f.name)) : ''); if (!name) return; var key = name.toLowerCase(); var before = exposed[key]; if (!before) { exposed[key] = f; return; }; var message = null; if (f.alias && before.alias) { message = 'Two tables of this FROM have the alias ' + name + ': The correlation name \'' + name + '\' is specified multiple times in a FROM clause. Give each table an alias of its own.'; } else if (!f.alias && !before.alias && clean(f.name).toLowerCase() === clean(before.name).toLowerCase()) { message = 'Two tables of this FROM are ' + name + ' with no alias: The objects "' + name + '" and "' + name + '" in the FROM clause have the same exposed names. Use correlation names to distinguish them.'; } else if (!!f.alias !== !!before.alias && !/^ENT\./i.test(String((f.alias ? before : f).name))) { message = 'The alias ' + name + ' is also the name of a table of this FROM: The correlation name \'' + name + '\' has the same exposed name as table \'' + name + '\'. Give one of them another alias.'; }; if (message) findings.push(['MCE082', 'error', message, DOCS.FROM, f, f]); }); if (reading.place && placeKindOf(reading.place) !== reading.place.kind) return; var outer = visibleScopes(core, nesting, readings.index.bySelect, scope.selectIdx).slice(1).map(function (link) { var outerBinder = scopeBinder(core, readings.bySelect[link.scope.selectIdx]); return { binder: outerBinder, count: entriesBefore(outerBinder, link.limit) }; }); if (outer.some(function (o) { return o.binder.isMisread; })) return; if (!entries.length && !outer.some(function (o) { return o.count > 0; })) return; var columns = null; var outputNames = {}; scope.selectList.items.forEach(function (item) { if (item.name) outputNames[String(item.name).toUpperCase()] = true; }); var stars = scope.selectList.items.filter(function (item) { return item.kind === 'star'; }); stars.forEach(function (item) { if (!item.starTable) return; var lower = clean(item.starTable).toLowerCase(); if (firstBound(binder.exposed, lower, entries.length) !== -1) return; if (outer.some(function (o) { return firstBound(o.binder.exposed, lower, o.binder.entries.length) !== -1; })) return; var prefix = String(item.starPrefix || clean(item.starTable) + '.').slice(0, -1); var unmatched = ': The column prefix \'' + prefix + '\' does not match with a table name or alias name used in the query.'; var named = (hasOwn(binder.named, lower) ? binder.named[lower] : []).map(function (k) { return entries[k]; }); if (named.length !== 1) { findings.push(['MCE082', 'error', 'No table of this query is called ' + prefix + unmatched + ' Write the alias of a table of its FROM.', DOCS.FROM, item, item]); return; }; var alias = formatIdentifier(named[0].alias); var first = tokenIndexAtOrAfter(core, item.start); var isOneName = isPunctToken(core[first + 1], '.') && core[first + 2] && core[first + 2].value === '*' && core[first].start === item.start; var fixes = isOneName ? [{ title: 'Use the alias ' + alias, edits: [{ start: core[first].start, end: core[first].end, text: alias }], isSafe: true, isInFixAll: true }] : []; findings.push(['MCE082', 'error', prefix + ' has the alias ' + alias + ' here' + unmatched + ' Write ' + alias + '.*.', DOCS.FROM, item, item, fixes]); }); var isSetBranch = !!reading.place && reading.place.head !== scope.selectIdx; [reading.select, reading.where, reading.groupBy, reading.having, reading.orderBy].concat(reading.ons).forEach(function (clause) { if (!clause || (clause === reading.orderBy && isSetBranch)) return; var onStart = clause.on ? core[clause.on.onIdx].start : Infinity; var isInGroup = !!clause.on && scope.joinGroups.some(function (g) { return g.start < onStart && g.end > onStart; }); var count = entriesBefore(binder, onStart); clause.refs.forEach(function (ref) { var span = refSpan(core, ref); var text = sql.slice(span.start, span.end); if (ref.parts.length === 2) { var lower = clean(ref.parts[0].value).toLowerCase(); if (firstBound(binder.exposed, lower, count) !== -1) return; if (outer.some(function (o) { return firstBound(o.binder.exposed, lower, o.count) !== -1; })) return; var column = ref.parts[1].raw; var qualifier = ref.parts[0].value; var later = firstBound(binder.exposed, lower, entries.length) !== -1; var named = (hasOwn(binder.named, lower) ? binder.named[lower] : []).filter(function (k) { return k < count; }).map(function (k) { return entries[k]; }); var outerNamed = []; var isOuterLater = false; outer.forEach(function (o, n) { if (firstBound(o.binder.exposed, lower, o.binder.entries.length) !== -1) isOuterLater = true; (hasOwn(o.binder.named, lower) ? o.binder.named[lower] : []).forEach(function (k) { if (k >= o.count) return; var alias = clean(o.binder.entries[k].alias).toLowerCase(); var isHidden = firstBound(binder.exposed, alias, entries.length) !== -1 || outer.slice(0, n).some(function (p) { return firstBound(p.binder.exposed, alias, p.binder.entries.length) !== -1; }); if (!isHidden) outerNamed.push(o.binder.entries[k]); }); }); var aliases = named.concat(outerNamed).map(function (f) { return formatIdentifier(f.alias); }); var bound = ': The multi-part identifier "' + text + '" could not be bound.'; if (later) { findings.push(['MCE082', 'error', qualifier + ' is joined after this ON' + bound + ' Move the condition to the ON of ' + qualifier + ', or join it earlier.', DOCS.JOIN, span, span]); } else if (!aliases.length && isOuterLater) { findings.push(['MCE082', 'error', qualifier + ' is joined after the APPLY that reads it' + bound + ' Join ' + qualifier + ' before the APPLY.', DOCS.JOIN, span, span]); } else if (!aliases.length) { findings.push(['MCE082', 'error', 'No table of this query is called ' + qualifier + bound + ' Write the alias of a table of its FROM.', DOCS.FROM, span, span]); } else if (aliases.length === 1) { var isLocal = named.length === 1; var isSelf = isLocal && isSelfComparison(core, clause.refs, ref, clean(named[0].alias).toLowerCase(), lower); var written = aliases[0] + '.' + column; findings.push(['MCE082', 'error', qualifier + ' has the alias ' + aliases[0] + (isLocal ? ' here' : ' in the query around it') + bound + (isSelf ? ' Write the alias of the table you mean: ' + written + ' would compare the column with itself.' : ' Write ' + written + '.'), DOCS.FROM, span, span, [{ title: 'Use the alias ' + aliases[0], edits: [{ start: core[ref.first].start, end: core[ref.first].end, text: aliases[0] }], isSafe: isLocal && !isSelf, isInFixAll: isLocal && !isSelf }]]); } else { var list = function (names) { return names.length === 1 ? names[0] : names.slice(0, -1).join(', ') + ' and ' + names[names.length - 1]; }; var local = aliases.slice(0, named.length); var around = aliases.slice(named.length); var here = local.length ? (local.length === 1 ? 'the alias ' : 'the aliases ') + list(local) + ' here' : ''; var there = around.length ? list(around) + (around.length === 1 ? ' in the query around it' : ' in the queries around it') : ''; findings.push(['MCE082', 'error', qualifier + ' has ' + (here && there ? here + ' and ' + there : here || 'the aliases ' + there) + bound + ' Write the alias of the table you mean.', DOCS.FROM, span, span]); }; return; }; if (ref.parts.length !== 1 || isInGroup) return; if (clause === reading.orderBy && outputNames[ref.parts[0].upper]) return; columns = columns || binder.columns(known); if (columns.firstUnknown < count) return; var owned = hasOwn(columns.owners, ref.parts[0].value.toLowerCase()) ? columns.owners[ref.parts[0].value.toLowerCase()] : []; var owners = owned.filter(function (k) { return k < count; }).map(function (k) { return entries[k]; }); if (owners.length < 2) return; if (clause === reading.orderBy && stars.length) { if (stars.some(function (star) { return star.starTable && firstBound(binder.exposed, clean(star.starTable).toLowerCase(), entries.length) === -1; })) return; var starred = owned.filter(function (k) { return stars.some(function (star) { return !star.starTable || firstBound(binder.exposed, clean(star.starTable).toLowerCase(), entries.length) === k; }); }); if (starred.length === 1) return; }; var references = owners.map(function (f) { return f.alias || clean(stripEnt(f.name)); }); findings.push(['MCE082', 'error', 'Two tables of this FROM have a column ' + text + ': Ambiguous column name \'' + text + '\'. Write the alias of the one you mean before it, such as ' + formatIdentifier(references[0]) + '.' + text + '.', DOCS.JOIN, span, span, references.map(function (reference) { return { title: 'Read it from ' + reference, edits: [{ start: span.start, end: span.start, text: formatIdentifier(reference) + '.' }], isSafe: false, isInFixAll: false }; })]); }); }); }); return findings; }, function rule083(sql, tokens, ctx) { var findings = []; if (ctx.getStrict().errors.length) return findings; var core = ctx.getCoreTokens(); var readings = ctx.getScopeReadings(); readings.list.forEach(function (reading) { var place = reading.place; var items = reading.scope.selectList.items; if (placeKindOf(place) !== 'subquery' || place.open < 1 || reading.scope.clauses.forClause || isKw(core[place.open - 1], 'EXISTS')) return; var own = core[reading.scope.selectIdx - 1]; var parent = readings.bySelect[place.parent]; if (isPunctToken(own, '(') && parent && parent.scope.from.some(function (f) { return f.kind === 'derived' && f.start === own.start && !isMisreadEntry(f); })) return; var width = selectWidth(items); if (width === null || width < 2) return; var span = { start: items[0].start, end: items[items.length - 1].end }; findings.push(['MCE083', 'error', 'This subquery gives ' + width + ' columns where one value is read: Only one expression can be specified in the select list when the subquery is not introduced with EXISTS. Keep one column.', DOCS.WHERE, span, span]); }); return findings; } ]; var ADDED_FIELD_KINDS = { Text: 'text', Email: 'text', EmailAddress: 'text', Phone: 'text', Number: 'number', Decimal: 'decimal', Date: 'date', Boolean: 'boolean' }; var ADDED_NOW_FUNCTIONS = { GETDATE: 1, GETUTCDATE: 1, SYSDATETIME: 1, SYSUTCDATETIME: 1, SYSDATETIMEOFFSET: 1 }; function addedRuleFacts(ctx) { if (ctx.addedRuleFacts) return ctx.addedRuleFacts; var core = ctx.getCoreTokens(); var scopeBySelect = {}; ctx.getSelectScopes().forEach(function (scope) { scopeBySelect[scope.selectIdx] = scope; }); var assignmentAt = {}; ctx.getSelects().forEach(function (sel) { sel.selectList.items.forEach(function (item) { var list = item.tokens || []; if (item.kind === 'alias' && list.length >= 3 && list[0].start === item.nameStart && list[1].type === 'operator' && list[1].value === '=') { assignmentAt[list[1].start] = true; }; }); }); var owners = selectOfToken(core); ctx.addedRuleFacts = { core: core, byName: ensureIndex(ctx.schema), scopeBySelect: scopeBySelect, assignmentAt: assignmentAt, ownSelect: owners.ownSelect, outerSelect: owners.outerSelect }; return ctx.addedRuleFacts; }; function selectOfToken(core) { var ownSelect = new Array(core.length); var outerSelect = {}; var frames = [{ inherited: -1, current: -1 }]; for (var i = 0; i < core.length; i++) { var t = core[i]; var frame = frames[frames.length - 1]; if (isPunctToken(t, ')') && frames.length > 1) { frames.pop(); frame = frames[frames.length - 1]; }; if (isKw(t, 'SELECT')) { outerSelect[i] = frame.inherited; frame.current = i; }; ownSelect[i] = frame.current !== -1 ? frame.current : frame.inherited; if (isPunctToken(t, '(')) { frames.push({ inherited: ownSelect[i], current: -1 }); }; }; return { ownSelect: ownSelect, outerSelect: outerSelect }; }; function addedScopesAt(facts, idx) { var chain = []; var s = facts.ownSelect[idx]; var guard = 0; while (s !== undefined && s !== -1 && guard++ < 1000) { if (facts.scopeBySelect[s]) chain.push(facts.scopeBySelect[s]); s = facts.outerSelect[s]; }; return chain; }; function addedColumnAt(facts, idx, qualifier, column) { var lower = String(column).toLowerCase(); var fieldOf = function (table) { if (!table || !Array.isArray(table.fields)) return null; var field = table.fields.filter(function (f) { return f && f.name && f.name.toLowerCase() === lower; })[0]; return field ? { field: field, table: table } : null; }; var scopes = addedScopesAt(facts, idx); if (qualifier) { var upper = String(qualifier).replace(/^\[|\]$/g, '').toUpperCase(); for (var s = 0; s < scopes.length; s++) { var entry = scopes[s].from.filter(function (f) { return entryReference(f).toUpperCase() === upper; })[0]; if (entry) return fieldOf(fieldCheckTable(facts.byName, entry)); }; return null; }; if (!scopes.length || !scopes[0].from.length) return null; var owners = []; var isAllKnown = scopes[0].from.every(function (f) { var table = fieldCheckTable(facts.byName, f); if (!table || !Array.isArray(table.fields) || hasOpenFieldList(table)) return false; var found = fieldOf(table); if (found) owners.push(found); return true; }); return isAllKnown && owners.length === 1 ? owners[0] : null; }; function addedFieldAt(facts, idx, qualifier, column) { var found = addedColumnAt(facts, idx, qualifier, column); return found ? found.field : null; }; function plainColumnRef(core, start, end) { if (start < 0 || end <= start) return null; var chain = parseNameChain(core, start, end); if (chain.end !== end || !chain.parts.length || chain.parts.length > 3 || !chain.parts.every(isName)) return null; if (isPunctToken(core[end], '(') || isPunctToken(core[end], '.')) return null; var parts = chain.parts; return { qualifier: parts.length > 1 ? parts[parts.length - 2].value : null, column: parts[parts.length - 1].value, start: start, end: end }; }; function addedFieldKind(field) { return field && field.type ? ADDED_FIELD_KINDS[field.type] || null : null; }; function numberLiteralEnd(core, i) { var t = core[i]; var isNumber = function (token) { return !!token && token.type === 'number' && !/^0x/i.test(token.value); }; if (!t) return -1; if (isNumber(t)) return i + 1; if (t.type === 'operator' && (t.value === '-' || t.value === '+') && isNumber(core[i + 1])) return i + 2; return -1; }; function openOfClose(tokens) { var kept = tokens.parenOpens; if (kept && kept.length === tokens.length) return kept; var matches = parenMatchesOf(tokens); var opens = new Array(tokens.length); for (var k = 0; k < matches.length; k++) { if (matches[k] !== undefined && matches[k] !== -1) opens[matches[k]] = k; }; try { Object.defineProperty(tokens, 'parenOpens', { value: opens, enumerable: false, configurable: true, writable: true }); } catch (e) { }; return opens; }; function addedOperandBefore(core, endIdx) { var last = core[endIdx - 1]; if (isPunctToken(last, ')')) { var open = openOfClose(core)[endIdx - 1]; return open !== undefined && open > 0 ? { start: open - 1, end: endIdx } : null; }; if (!isName(last)) return null; var start = endIdx - 1; while (start >= 2 && isPunctToken(core[start - 1], '.') && isName(core[start - 2])) start -= 2; return { start: start, end: endIdx }; }; function isNowCallAt(core, i) { var t = core[i]; return !!t && t.type === 'identifier' && !t.quote && !!ADDED_NOW_FUNCTIONS[t.upper] && isPunctToken(core[i + 1], '(') && isPunctToken(core[i + 2], ')'); }; function fromListFlagsOf(core) { var kept = core.addedFromList; if (kept && kept.length === core.length) return kept; var flags = new Array(core.length); var states = [false]; for (var k = 0; k < core.length; k++) { var t = core[k]; flags[k] = !!states[t.depth]; if (isPunctToken(t, '(')) { states[t.depth + 1] = false; } else if (t.type === 'keyword') { if (t.upper === 'FROM' || t.upper === 'JOIN') states[t.depth] = true; else if (['SELECT', 'WHERE', 'GROUP', 'HAVING', 'ORDER'].indexOf(t.upper) !== -1) states[t.depth] = false; }; }; try { Object.defineProperty(core, 'addedFromList', { value: flags, enumerable: false, configurable: true, writable: true }); } catch (e) { }; return flags; }; function isTableNameCall(core, i, close) { var prev = core[i - 1]; if (isKwAny(prev, ['FROM', 'JOIN', 'APPLY', 'WITH'])) return true; var isAfterTable = isKw(prev, 'AS') || isName(prev) || (!!prev && prev.type === 'punct' && (prev.value === ')' || prev.value === ',')); if (isAfterTable && fromListFlagsOf(core)[i]) return true; return isKw(core[close + 1], 'AS') && isPunctToken(core[close + 2], '('); }; var TABLE_HINT_WORDS = { FORCESCAN: 1, FORCESEEK: 1, HOLDLOCK: 1, IGNORE_CONSTRAINTS: 1, IGNORE_TRIGGERS: 1, INDEX: 1, KEEPDEFAULTS: 1, KEEPIDENTITY: 1, NOEXPAND: 1, NOLOCK: 1, NOWAIT: 1, PAGLOCK: 1, READCOMMITTED: 1, READCOMMITTEDLOCK: 1, READPAST: 1, READUNCOMMITTED: 1, REPEATABLEREAD: 1, ROWLOCK: 1, SERIALIZABLE: 1, SNAPSHOT: 1, TABLOCK: 1, TABLOCKX: 1, UPDLOCK: 1, XLOCK: 1 }; function isNamedList(core, first, last) { var prev = core[first - 1]; var close = matchParen(core, last + 1); if (first === last && (isKw(prev, 'AS') || isPunctToken(prev, ')'))) return true; if (isKw(core[close + 1], 'AS') && isPunctToken(core[close + 2], '(')) return true; var isTablePlace = isKwAny(prev, ['FROM', 'JOIN', 'APPLY']) || ((isPunctToken(prev, ',') || isName(prev) || isKw(prev, 'AS')) && !!fromListFlagsOf(core)[first]); if (!isTablePlace) return false; var hints = 0; for (var k = last + 2; k < close; k++) { var t = core[k]; if (isPunctToken(t, ',')) continue; if (!(t.type === 'identifier' || t.type === 'keyword') || !TABLE_HINT_WORDS[t.upper]) return false; hints++; if (isPunctToken(core[k + 1], '(')) k = matchParen(core, k + 1); else if (core[k + 1] && core[k + 1].value === '=') k += 2; }; return hints > 0; }; function isOdbcEscapeName(core, i) { var fn = core[i - 1]; return !!fn && fn.type === 'identifier' && fn.upper === 'FN' && !!core[i - 2] && core[i - 2].value === '{'; }; function addedCallClose(core, i, keywords) { var t = core[i]; if (!t || t.quote || !(t.type === 'identifier' || (t.type === 'keyword' && keywords[t.upper]))) return -1; if (isPunctToken(core[i - 1], '.') || isOdbcEscapeName(core, i)) return -1; var close = callClose(core, i); if (close === -1 || isTableNameCall(core, i, close)) return -1; return close; }; function hasStrictErrorInCall(ctx, core, open, close) { return (ctx.getStrict().errors || []).some(function (e) { return e.start > core[open].start && e.start <= core[close].start; }); }; function isCallUnread(ctx, core, close) { return (ctx.getStrict().errors || []).some(function (e) { return e.start <= core[close].start; }); }; function addedArguments(core, i, close) { if (close === i + 2) return []; var matches = parenMatchesOf(core); var depth = core[i + 1].depth + 1; var args = []; var start = i + 2; for (var k = i + 2; k < close; k++) { var t = core[k]; if (isPunctToken(t, '(') && matches[k] !== undefined && matches[k] !== -1 && matches[k] < close) { k = matches[k]; continue; }; if (t.depth === depth && isPunctToken(t, ',')) { args.push({ start: start, end: k }); start = k + 1; }; }; args.push({ start: start, end: close }); return args; }; function quotable(text, fallback) { var isPlain = text.split(/\s+/).filter(Boolean).length <= 5 && text.indexOf(';') === -1 && !/[–—]/.test(text) && !/(^|[^A-Za-z0-9_])\([a-z]/.test(text); return isPlain ? text : fallback; }; function spanText(sql, core, span) { return sql.slice(core[span.start].start, core[span.end - 1].end); }; function groupedText(sql, core, span) { var text = spanText(sql, core, span); return span.end - span.start === 1 || plainColumnRef(core, span.start, span.end) ? text : '(' + text + ')'; }; var OTHER_DIALECT_FUNCTIONS = { LENGTH: { use: 'Use LEN(x), which leaves out trailing spaces, or DATALENGTH(x) for bytes.', docs: DOCS.STR, rename: 'LEN', counts: [1], isSafe: false }, CHAR_LENGTH: { use: 'Use LEN(x), which leaves out trailing spaces.', docs: DOCS.STR, rename: 'LEN', counts: [1], isSafe: false }, CHARACTER_LENGTH: { use: 'Use LEN(x), which leaves out trailing spaces.', docs: DOCS.STR, rename: 'LEN', counts: [1], isSafe: false }, UCASE: { use: 'Use UPPER(x).', docs: DOCS.STR, rename: 'UPPER', counts: [1], isSafe: true }, LCASE: { use: 'Use LOWER(x).', docs: DOCS.STR, rename: 'LOWER', counts: [1], isSafe: true }, SUBSTR: { use: 'Use SUBSTRING(x, start, length).', docs: DOCS.STR, kind: 'substr' }, INSTR: { use: 'Use CHARINDEX(find, text), which takes the two the other way round.', docs: DOCS.STR, kind: 'swap' }, STRPOS: { use: 'Use CHARINDEX(find, text), which takes the two the other way round.', docs: DOCS.STR, kind: 'swap' }, LOCATE: { use: 'Use CHARINDEX(find, text), which takes them in the same order.', docs: DOCS.STR, rename: 'CHARINDEX', counts: [2], isSafe: true }, LPAD: { use: 'Use RIGHT(REPLICATE(\'0\', n) + x, n) to pad on the left.', docs: DOCS.STR }, RPAD: { use: 'Use LEFT(x + REPLICATE(\' \', n), n) to pad on the right.', docs: DOCS.STR }, REPEAT: { use: 'Use REPLICATE(x, n).', docs: DOCS.STR, rename: 'REPLICATE', counts: [2], isSafe: false }, INITCAP: { use: 'For one word, use UPPER(LEFT(x, 1)) + LOWER(SUBSTRING(x, 2, 2147483647)).', docs: DOCS.STR }, SPLIT_PART: { use: 'Use SUBSTRING with CHARINDEX.', docs: DOCS.STR }, CHR: { use: 'Use CHAR(n) for 0 to 255, or NCHAR(n) for any Unicode character.', docs: DOCS.STR, rename: 'CHAR', counts: [1], isSafe: false }, TO_CHAR: { use: 'Use FORMAT(x, \'yyyy-MM-dd\'), or CONVERT(VARCHAR(10), x, 23) for a date.', docs: DOCS.CONV }, TO_NUMBER: { use: 'Use CAST(x AS DECIMAL(18, 2)), or TRY_CAST, which gives NULL for a text that is no number.', docs: DOCS.CONV }, TO_DATE: { use: 'Use CONVERT(DATE, x, style), or TRY_CONVERT, which gives NULL for a text that is no date.', docs: DOCS.CONV }, TO_TIMESTAMP: { use: 'Use CONVERT(DATETIME, x, style), or TRY_CONVERT.', docs: DOCS.CONV }, STR_TO_DATE: { use: 'Use CONVERT(DATE, x, style), or TRY_CONVERT.', docs: DOCS.CONV }, IFNULL: { use: 'Use COALESCE(x, y).', docs: DOCS.NULLF, rename: 'COALESCE', counts: [2], isSafe: true }, NVL: { use: 'Use COALESCE(x, y).', docs: DOCS.NULLF, rename: 'COALESCE', counts: [2], isSafe: true }, NVL2: { use: 'Use IIF(x IS NOT NULL, a, b).', docs: DOCS.NULLF, kind: 'nvl2' }, DECODE: { use: 'Use CASE x WHEN a THEN b ELSE c END.', docs: DOCS.CASE }, IF: { use: 'Use IIF(condition, a, b).', docs: DOCS.CASE, rename: 'IIF', counts: [3], isSafe: true }, NOW: { use: 'Use GETDATE().', docs: DOCS.DATE, rename: 'GETDATE', counts: [0], isSafe: true }, SYSDATE: { use: 'Use GETDATE().', docs: DOCS.DATE, rename: 'GETDATE', counts: [0], isSafe: false }, CURDATE: { use: 'Use CAST(GETDATE() AS DATE).', docs: DOCS.DATE, replace: 'CAST(GETDATE() AS DATE)', isSafe: true }, CURTIME: { use: 'Use CAST(GETDATE() AS TIME).', docs: DOCS.DATE, replace: 'CAST(GETDATE() AS TIME)', isSafe: false }, DATE_TRUNC: { use: 'Use DATETRUNC(MONTH, x), with the date part as a word.', docs: DOCS.DATE, kind: 'dateTrunc' }, DATE_FORMAT: { use: 'Use FORMAT(x, \'yyyy-MM-dd\'), or CONVERT(VARCHAR(10), x, 23).', docs: DOCS.DATE }, DATE_ADD: { use: 'Use DATEADD(DAY, n, x).', docs: DOCS.DATE }, DATE_SUB: { use: 'Use DATEADD(DAY, -n, x).', docs: DOCS.DATE }, TIMESTAMPADD: { use: 'Use DATEADD(DAY, n, x).', docs: DOCS.DATE }, TIMESTAMPDIFF: { use: 'Use DATEDIFF(DAY, start, end).', docs: DOCS.DATE }, DATE_PART: { use: 'Use DATEPART(DAY, x), with the date part as a word.', docs: DOCS.DATE }, DAYOFMONTH: { use: 'Use DAY(x).', docs: DOCS.DATE, rename: 'DAY', counts: [1], isSafe: true }, DAYOFYEAR: { use: 'Use DATEPART(DAYOFYEAR, x).', docs: DOCS.DATE, datePart: 'DAYOFYEAR', isSafe: true }, HOUR: { use: 'Use DATEPART(HOUR, x).', docs: DOCS.DATE, datePart: 'HOUR', isSafe: true }, MINUTE: { use: 'Use DATEPART(MINUTE, x).', docs: DOCS.DATE, datePart: 'MINUTE', isSafe: true }, SECOND: { use: 'Use DATEPART(SECOND, x).', docs: DOCS.DATE, datePart: 'SECOND', isSafe: true }, QUARTER: { use: 'Use DATEPART(QUARTER, x).', docs: DOCS.DATE, datePart: 'QUARTER', isSafe: true }, WEEK: { use: 'Use DATEPART(WEEK, x), or DATEPART(ISO_WEEK, x), which count weeks their own way.', docs: DOCS.DATE, datePart: 'WEEK', isSafe: false }, DAYOFWEEK: { use: 'Use DATEPART(WEEKDAY, x), whose first day follows the language setting.', docs: DOCS.DATE, datePart: 'WEEKDAY', isSafe: false }, WEEKDAY: { use: 'Use DATEPART(WEEKDAY, x), whose first day follows the language setting.', docs: DOCS.DATE }, DAYNAME: { use: 'Use DATENAME(WEEKDAY, x).', docs: DOCS.DATE, dateName: 'WEEKDAY', isSafe: false }, MONTHNAME: { use: 'Use DATENAME(MONTH, x).', docs: DOCS.DATE, dateName: 'MONTH', isSafe: false }, LAST_DAY: { use: 'Use EOMONTH(x).', docs: DOCS.DATE, rename: 'EOMONTH', counts: [1], isSafe: false }, UNIX_TIMESTAMP: { use: 'For a UTC date, use DATEDIFF_BIG(SECOND, \'1970-01-01\', x).', docs: DOCS.DATE }, FROM_UNIXTIME: { use: 'Use DATEADD(SECOND, n, \'1970-01-01\'), which gives UTC.', docs: DOCS.DATE }, CEIL: { use: 'Use CEILING(x).', docs: DOCS.NUM, rename: 'CEILING', counts: [1], isSafe: true }, POW: { use: 'Use POWER(x, y), whose result has the type of x, so POWER(2, 0.5) is 1.', docs: DOCS.NUM, rename: 'POWER', counts: [2], isSafe: false }, LN: { use: 'Use LOG(x), the natural logarithm.', docs: DOCS.NUM, rename: 'LOG', counts: [1], isSafe: false }, MOD: { use: 'Use x % y.', docs: DOCS.NUM, kind: 'mod' }, TRUNC: { use: 'Use ROUND(x, 0, 1) for a number, or CAST(x AS DATE) for a date.', docs: DOCS.NUM }, TRUNCATE: { use: 'Use ROUND(x, d, 1).', docs: DOCS.NUM }, RANDOM: { use: 'Sort at random with NEWID(). RAND() gives every row the same number.', docs: DOCS.NUM }, STDDEV: { use: 'Use STDEV(x) for a sample, or STDEVP(x) for a whole population.', docs: DOCS.AGG }, STDDEV_SAMP: { use: 'Use STDEV(x).', docs: DOCS.AGG, rename: 'STDEV', counts: [1], isSafe: true }, STDDEV_POP: { use: 'Use STDEVP(x).', docs: DOCS.AGG, rename: 'STDEVP', counts: [1], isSafe: true }, VARIANCE: { use: 'Use VAR(x) for a sample, or VARP(x) for a whole population.', docs: DOCS.AGG }, VAR_SAMP: { use: 'Use VAR(x).', docs: DOCS.AGG, rename: 'VAR', counts: [1], isSafe: true }, VAR_POP: { use: 'Use VARP(x).', docs: DOCS.AGG, rename: 'VARP', counts: [1], isSafe: true }, GROUP_CONCAT: { use: 'Use STRING_AGG(x, \',\'), with WITHIN GROUP (ORDER BY ...) for an order.', docs: DOCS.AGG, kind: 'groupConcat' }, LISTAGG: { use: 'Use STRING_AGG(x, separator), which keeps WITHIN GROUP (ORDER BY ...).', docs: DOCS.AGG, kind: 'listagg' }, ARRAY_AGG: { use: 'For a list as text, use STRING_AGG(x, \',\').', docs: DOCS.AGG }, JSON_EXTRACT: { use: 'Use JSON_VALUE(x, path) for a value, or JSON_QUERY(x, path) for an object or an array.', docs: DOCS.JSON }, GUID: { ampscript: 'GUID', use: 'Use NEWID() for a new GUID on each row, which SQL Server writes in capital letters.', docs: DOCS.STR }, LOWERCASE: { ampscript: 'Lowercase', use: 'Use LOWER(x).', docs: DOCS.STR, rename: 'LOWER', counts: [1], isSafe: true }, UPPERCASE: { ampscript: 'Uppercase', use: 'Use UPPER(x).', docs: DOCS.STR, rename: 'UPPER', counts: [1], isSafe: true }, PROPERCASE: { ampscript: 'ProperCase', use: 'For one word, use UPPER(LEFT(x, 1)) + LOWER(SUBSTRING(x, 2, 2147483647)).', docs: DOCS.STR }, INDEXOF: { ampscript: 'IndexOf', use: 'Use CHARINDEX(find, text), which takes the two the other way round.', docs: DOCS.STR }, DOMAIN: { ampscript: 'Domain', use: 'Use the Domain field of _Subscribers, or SUBSTRING(x, CHARINDEX(\'@\', x) + 1, 254).', docs: DOCS.STR }, EMPTY: { ampscript: 'Empty', use: 'Write x IS NULL OR x = \'\' as the condition instead.', docs: DOCS.NULLF }, FORMATNUMBER: { ampscript: 'FormatNumber', use: 'Use FORMAT(x, \'N2\'), whose x must be a number.', docs: DOCS.NUM }, FORMATDATE: { ampscript: 'FormatDate', use: 'Use FORMAT(x, \'yyyy-MM-dd\'), or CONVERT(VARCHAR(10), x, 23).', docs: DOCS.DATE }, DATEPARSE: { ampscript: 'DateParse', use: 'Use CONVERT(DATETIME, x, style), or TRY_CONVERT, which gives NULL for a text that is no date.', docs: DOCS.CONV }, STRINGTODATE: { ampscript: 'StringToDate', use: 'Use CONVERT(DATETIME, x, style), or TRY_CONVERT, which gives NULL for a text that is no date.', docs: DOCS.CONV }, SYSTEMDATETOLOCALDATE: { ampscript: 'SystemDateToLocalDate', use: 'For a local time, use x AT TIME ZONE \'Central America Standard Time\' AT TIME ZONE with your own zone, as \'Central European Standard Time\'.', docs: DOCS.DATE }, LOCALDATETOSYSTEMDATE: { ampscript: 'LocalDateToSystemDate', use: 'For server time, use x AT TIME ZONE with your own zone, then AT TIME ZONE \'Central America Standard Time\'.', docs: DOCS.DATE }, ATTRIBUTEVALUE: { ampscript: 'AttributeValue', use: 'Name the field as a column instead, in brackets when it has a space.', docs: DOCS.SEL }, LOOKUP: { ampscript: 'Lookup', use: 'Join the Data Extension on the field to match instead, or read the value with a subquery.', docs: DOCS.JOIN }, LOOKUPROWS: { ampscript: 'LookupRows', use: 'Join the Data Extension on the field to match instead.', docs: DOCS.JOIN }, LOOKUPORDEREDROWS: { ampscript: 'LookupOrderedRows', use: 'Join the Data Extension on the field to match instead, and number its rows with ROW_NUMBER() OVER (ORDER BY ...) to keep the first ones.', docs: DOCS.JOIN }, ROWCOUNT: { ampscript: 'RowCount', use: 'Use COUNT(*).', docs: DOCS.AGG }, DATAEXTENSIONROWCOUNT: { ampscript: 'DataExtensionRowCount', use: 'Use (SELECT COUNT(*) FROM name) with the name of the Data Extension.', docs: DOCS.AGG } }; var DATE_TRUNC_PARTS = { microsecond: 'MICROSECOND', microseconds: 'MICROSECOND', millisecond: 'MILLISECOND', milliseconds: 'MILLISECOND', second: 'SECOND', minute: 'MINUTE', hour: 'HOUR', day: 'DAY', week: 'ISO_WEEK', month: 'MONTH', quarter: 'QUARTER', year: 'YEAR' }; var CONDITION_LEVELS = 64; var OTHER_DIALECT_KEYWORDS = { IF: 1, TRUNCATE: 1, ROWCOUNT: 1 }; function isPositiveIntegerSpan(core, span) { return span.end === span.start + 1 && core[span.start].type === 'number' && /^\d+$/.test(core[span.start].value) && Number(core[span.start].value) > 0; }; function isConditionSpan(core, span, levelsLeft) { var levels = levelsLeft === undefined ? CONDITION_LEVELS : levelsLeft; if (span.end <= span.start || levels <= 0) return false; var depth = core[span.start].depth; var parts = []; var from = span.start; var isInBetween = false; var caseDepth = 0; for (var k = span.start; k < span.end; k++) { var t = core[k]; if (t.depth !== depth) continue; if (isKw(t, 'CASE')) caseDepth++; if (isKw(t, 'END') && caseDepth) caseDepth--; if (caseDepth) continue; if (isKw(t, 'BETWEEN')) { isInBetween = true; } else if (isKw(t, 'AND') && isInBetween) { isInBetween = false; } else if (isKwAny(t, ['AND', 'OR'])) { parts.push({ start: from, end: k }); from = k + 1; }; }; parts.push({ start: from, end: span.end }); return parts.every(function (part) { return isConditionPart(core, part, levels); }); }; function isConditionPart(core, part, levels) { var start = part.start; while (start < part.end && isKw(core[start], 'NOT')) start++; if (part.end <= start) return false; var first = core[start]; if (isKw(first, 'EXISTS')) return true; if (isPunctToken(first, '(') && parenMatchesOf(core)[start] === part.end - 1) { if (isKwAny(core[start + 1], ['SELECT', 'WITH'])) return false; return isConditionSpan(core, { start: start + 1, end: part.end - 1 }, levels - 1); }; var depth = first.depth; var caseDepth = 0; for (var k = start; k < part.end; k++) { var t = core[k]; if (t.depth !== depth) continue; if (isKw(t, 'CASE')) caseDepth++; if (isKw(t, 'END') && caseDepth) caseDepth--; if (caseDepth) continue; if (t.type === 'operator' && DATE_COMPARISON_OPERATORS.indexOf(t.value) !== -1) return true; if (isKwAny(t, ['IS', 'LIKE', 'IN', 'BETWEEN'])) return true; }; return false; }; function isSameKindSpans(facts, spans) { var kindClass = { literal: 'text', text: 'text', number: 'number', decimal: 'number', date: 'date', boolean: 'boolean' }; var seen = ''; for (var k = 0; k < spans.length; k++) { var span = spans[k]; if (span.end === span.start + 1 && isKw(facts.core[span.start], 'NULL')) continue; var value = valueKindAt(facts, span); var kind = value ? kindClass[value.kind] || '' : ''; if (!kind || (seen && kind !== seen)) return false; seen = kind; }; return !!seen; }; function otherDialectFix(sql, core, i, close, spec, facts) { var t = core[i]; var args = addedArguments(core, i, close); var fix = function (title, edits, isSafe) { return [{ title: title, edits: edits, isSafe: isSafe, isInFixAll: isSafe }]; }; var rename = function (name) { return { start: t.start, end: t.end, text: name }; }; var lastEnd = core[close - 1].end; if (args.some(function (arg) { return arg.end <= arg.start; })) return null; if (spec.rename) { if (spec.counts.indexOf(args.length) === -1) return null; var isSafe = spec.isSafe; if (spec.rename === 'IIF') isSafe = isConditionSpan(core, args[0]) && isSameKindSpans(facts, args.slice(1)); if (spec.rename === 'COALESCE') isSafe = isSameKindSpans(facts, args); if (spec.rename === 'CHARINDEX') isSafe = args[0].end === args[0].start + 1 && core[args[0].start].type === 'string' && String(stringLiteralContent(core[args[0].start])) !== ''; return fix('Use ' + spec.rename, [rename(spec.rename)], isSafe); }; if (spec.replace) { if (args.length) return null; var isArithmetic = function (tok) { return !!tok && tok.type === 'operator' && ['+', '-', '*', '/', '%'].indexOf(tok.value) !== -1; }; var outerFrom = i; var outerTo = close; while (isPunctToken(core[outerFrom - 1], '(') && isPunctToken(core[outerTo + 1], ')') && matchParen(core, outerFrom - 1) === outerTo + 1) { outerFrom--; outerTo++; }; if (spec.replace === 'CAST(GETDATE() AS DATE)' && (isArithmetic(core[outerFrom - 1]) || isArithmetic(core[outerTo + 1]))) { var dateTime = 'CAST(CAST(GETDATE() AS DATE) AS DATETIME)'; return fix('Use ' + dateTime, [{ start: t.start, end: core[close].end, text: dateTime }], false); }; return fix('Use ' + spec.replace, [{ start: t.start, end: core[close].end, text: spec.replace }], spec.isSafe); }; if (spec.datePart || spec.dateName) { if (args.length !== 1) return null; var callName = spec.datePart ? 'DATEPART' : 'DATENAME'; var part = spec.datePart || spec.dateName; return fix('Use ' + callName + '(' + part + ', x)', [rename(callName), { start: core[i + 1].end, end: core[i + 1].end, text: part + ', ' }], spec.isSafe); }; if (spec.kind === 'substr') { if (args.length !== 2 && args.length !== 3) return null; var edits = [rename('SUBSTRING')]; if (args.length === 2) { edits.push({ start: lastEnd, end: lastEnd, text: ', 2147483647' }); }; var isLengthKept = args.length === 2 || (args[2].end === args[2].start + 1 && core[args[2].start].type === 'number' && /^\d+$/.test(core[args[2].start].value)); return fix('Use SUBSTRING', edits, isPositiveIntegerSpan(core, args[1]) && isLengthKept); }; if (spec.kind === 'swap') { if (args.length !== 2) return null; return fix('Use CHARINDEX', [rename('CHARINDEX'), { start: core[args[0].start].start, end: core[args[1].end - 1].end, text: spanText(sql, core, args[1]) + ', ' + spanText(sql, core, args[0]) }], false); }; if (spec.kind === 'mod') { if (args.length !== 2) return null; return fix('Use %', [{ start: t.start, end: core[close].end, text: '(' + groupedText(sql, core, args[0]) + ' % ' + groupedText(sql, core, args[1]) + ')' }], false); }; if (spec.kind === 'groupConcat') { if (args.length !== 1) return null; for (var k = i + 2; k < close; k++) { if (core[k].depth === core[i + 1].depth + 1 && (core[k].type === 'keyword' || core[k].type === 'identifier') && ['DISTINCT', 'ORDER', 'SEPARATOR'].indexOf(core[k].upper) !== -1) return null; }; return fix('Use STRING_AGG', [rename('STRING_AGG'), { start: lastEnd, end: lastEnd, text: ', \',\'' }], false); }; if (spec.kind === 'listagg') { if (args.length !== 1 && args.length !== 2) return null; var listEdits = [rename('STRING_AGG')]; if (args.length === 1) { listEdits.push({ start: lastEnd, end: lastEnd, text: ', \'\'' }); }; return fix('Use STRING_AGG', listEdits, false); }; if (spec.kind === 'dateTrunc') { var trunc = dateTruncPart(core, i, close); if (!trunc) return null; return fix('Use DATETRUNC(' + trunc.part + ', x)', [rename('DATETRUNC'), { start: trunc.token.start, end: trunc.token.end, text: trunc.part }], false); }; if (spec.kind === 'nvl2') { if (args.length !== 3) return null; var text = 'IIF(' + groupedText(sql, core, args[0]) + ' IS NOT NULL, ' + spanText(sql, core, args[1]) + ', ' + spanText(sql, core, args[2]) + ')'; return fix('Use IIF', [{ start: t.start, end: core[close].end, text: text }], isSameKindSpans(facts, args.slice(1))); }; return null; }; function dateTruncPart(core, i, close) { var args = addedArguments(core, i, close); var token = args.length === 2 && args[0].end === args[0].start + 1 ? core[args[0].start] : null; var part = token && token.type === 'string' ? DATE_TRUNC_PARTS[String(stringLiteralContent(token)).trim().toLowerCase()] : null; return part ? { token: token, part: part } : null; }; function rule085(sql, tokens, ctx) { var findings = []; var core = ctx.getCoreTokens(); for (var i = 0; i < core.length; i++) { var spec = OTHER_DIALECT_FUNCTIONS[core[i].upper]; if (!spec) continue; if (i === 0 || isKw(core[i - 1], 'SET')) continue; var close = addedCallClose(core, i, OTHER_DIALECT_KEYWORDS); if (close === -1) continue; var t = core[i]; var fixes = hasStrictErrorInCall(ctx, core, i + 1, close) ? null : otherDialectFix(sql, core, i, close, spec, addedRuleFacts(ctx)); var origin = spec.ampscript ? ' ' + spec.ampscript + ' is an AMPscript function.' : ''; var use = spec.use; var trunc = spec.kind === 'dateTrunc' ? dateTruncPart(core, i, close) : null; if (trunc) use = 'Use DATETRUNC(' + trunc.part + ', x), with the date part as a word.'; findings.push(['MCE085', 'error', otherDialectAnswer(t) + origin + ' ' + use, spec.docs, t, t, fixes]); }; return findings; }; function otherDialectAnswer(t) { if (t.type === 'keyword' && OTHER_DIALECT_KEYWORDS[t.upper]) return 'SQL Server reads ' + t.raw + ' as a keyword: Incorrect syntax near the keyword \'' + t.raw + '\'.'; return '\'' + t.raw + '\' is not a recognized built-in function name.'; }; var CALL_ARGUMENT_COUNTS = { DATEADD: { counts: [3, 3], syntax: 'DATEADD(DAY, -7, GETDATE())', answer: true }, DATEDIFF: { counts: [3, 3], syntax: 'DATEDIFF(DAY, start, end)', answer: true, note: 'MySQL\'s DATEDIFF(end, start) is DATEDIFF(DAY, start, end) here.' }, DATEDIFF_BIG: { counts: [3, 3], syntax: 'DATEDIFF_BIG(SECOND, start, end)', answer: true }, DATEPART: { counts: [2, 2], syntax: 'DATEPART(MONTH, x)', answer: true }, DATENAME: { counts: [2, 2], syntax: 'DATENAME(MONTH, x)', answer: true }, DATEFROMPARTS: { counts: [3, 3], syntax: 'DATEFROMPARTS(year, month, day)', answer: true }, EOMONTH: { counts: [1, 2], syntax: 'EOMONTH(x) or EOMONTH(x, months)', answer: true }, GETDATE: { counts: [0, 0], syntax: 'GETDATE()', answer: true }, GETUTCDATE: { counts: [0, 0], syntax: 'GETUTCDATE()', answer: true }, SYSDATETIME: { counts: [0, 0], syntax: 'SYSDATETIME()', answer: true }, LEFT: { counts: [2, 2], syntax: 'LEFT(x, 5)', answer: true }, RIGHT: { counts: [2, 2], syntax: 'RIGHT(x, 5)', answer: true }, LEN: { counts: [1, 1], syntax: 'LEN(x)', answer: true }, CHARINDEX: { counts: [2, 3], syntax: 'CHARINDEX(find, text) or CHARINDEX(find, text, start)', answer: true }, CONCAT: { counts: [2, 254], syntax: 'CONCAT(a, b)', answer: true }, ISNULL: { counts: [2, 2], syntax: 'ISNULL(x, replacement)', answer: true, note: 'MySQL\'s ISNULL(x) is x IS NULL here.' }, IIF: { counts: [3, 3], syntax: 'IIF(condition, value if true, value if false)', answer: false }, NULLIF: { counts: [2, 2], syntax: 'NULLIF(x, y)', answer: false }, SUBSTRING: { counts: [3, 3], syntax: 'SUBSTRING(x, start, length)', answer: true } }; var CALL_CHECK_KEYWORDS = { LEFT: 1, RIGHT: 1, NULLIF: 1, CONVERT: 1, TRY_CONVERT: 1 }; var DATE_PARTS = ['YEAR', 'YY', 'YYYY', 'QUARTER', 'QQ', 'Q', 'MONTH', 'MM', 'M', 'DAYOFYEAR', 'DY', 'Y', 'DAY', 'DD', 'D', 'WEEK', 'WK', 'WW', 'WEEKDAY', 'DW', 'W', 'HOUR', 'HH', 'MINUTE', 'MI', 'N', 'SECOND', 'SS', 'S', 'MILLISECOND', 'MS', 'MICROSECOND', 'MCS', 'NANOSECOND', 'NS']; var DATE_PARTS_OF_PARTS = DATE_PARTS.concat(['TZOFFSET', 'TZ', 'ISO_WEEK', 'ISOWK', 'ISOWW']); var DATE_PART_FUNCTIONS = { DATEADD: DATE_PARTS, DATEDIFF: DATE_PARTS, DATEDIFF_BIG: DATE_PARTS, DATEPART: DATE_PARTS_OF_PARTS, DATENAME: DATE_PARTS_OF_PARTS }; var CONVERT_STYLES = (function () { var styles = {}; var add = function (from, to) { for (var s = from; s <= to; s++) styles[s] = true; }; add(0, 14); add(20, 25); add(100, 114); [120, 121, 126, 127, 128, 129, 130, 131].forEach(function (s) { styles[s] = true; }); return styles; }()); var SYSTEM_TYPE_NAMES = { BIGINT: 1, BINARY: 1, BIT: 1, CHAR: 1, CHARACTER: 1, DATE: 1, DATETIME: 1, DATETIME2: 1, DATETIMEOFFSET: 1, DEC: 1, DECIMAL: 1, FLOAT: 1, GEOGRAPHY: 1, GEOMETRY: 1, HIERARCHYID: 1, IMAGE: 1, INT: 1, INTEGER: 1, MONEY: 1, NATIONAL: 1, NCHAR: 1, NTEXT: 1, NUMERIC: 1, NVARCHAR: 1, REAL: 1, ROWVERSION: 1, SMALLDATETIME: 1, SMALLINT: 1, SMALLMONEY: 1, SQL_VARIANT: 1, SYSNAME: 1, TEXT: 1, TIME: 1, TIMESTAMP: 1, TINYINT: 1, UNIQUEIDENTIFIER: 1, VARBINARY: 1, VARCHAR: 1, XML: 1, JSON: 1, VECTOR: 1 }; var OTHER_TYPE_ADVICE = { BOOLEAN: 'Use BIT.', BOOL: 'Use BIT.', STRING: 'Use NVARCHAR(n).', VARCHAR2: 'Use VARCHAR(n).', NVARCHAR2: 'Use NVARCHAR(n).', NUMBER: 'Use INT, BIGINT or DECIMAL(18, 2).', SIGNED: 'Use BIGINT.', UNSIGNED: 'Use BIGINT.', INT64: 'Use BIGINT.', LONG: 'Use BIGINT.', DOUBLE: 'Use FLOAT.' }; var TYPE_LENGTH_LIMITS = { VARCHAR: { most: 8000, isMax: true }, CHAR: { most: 8000, isMax: false, use: 'VARCHAR(MAX) or NVARCHAR(MAX)' }, VARBINARY: { most: 8000, isMax: true }, BINARY: { most: 8000, isMax: false, use: 'VARBINARY(MAX)' }, NVARCHAR: { most: 4000, isMax: true }, NCHAR: { most: 4000, isMax: false, use: 'NVARCHAR(MAX)' } }; var TEXT_TYPE_NAMES = { CHAR: 1, NCHAR: 1, VARCHAR: 1, NVARCHAR: 1 }; var NUMBER_TYPE_NAMES = { BIGINT: 1, BIT: 1, DEC: 1, DECIMAL: 1, INT: 1, INTEGER: 1, NUMERIC: 1, SMALLINT: 1, TINYINT: 1 }; function castTypeSpan(core, i, close) { var name = core[i].upper; if (name === 'CONVERT' || name === 'TRY_CONVERT') { var args = addedArguments(core, i, close); return args.length >= 2 && args[0].end > args[0].start ? args[0] : null; }; var matches = parenMatchesOf(core); var depth = core[i + 1].depth + 1; for (var k = i + 2; k < close; k++) { if (isPunctToken(core[k], '(') && matches[k] !== undefined && matches[k] !== -1 && matches[k] < close) { k = matches[k]; continue; }; if (core[k].depth === depth && isKw(core[k], 'AS')) return k + 1 < close ? { start: k + 1, end: close } : null; }; return null; }; function typeFindings(sql, core, span) { var first = core[span.start]; if (!first || first.quote || (first.type !== 'identifier' && first.type !== 'keyword')) return []; var name = first.upper; var last = core[span.end - 1]; var hasSize = span.end - span.start >= 4 && isPunctToken(core[span.start + 1], '(') && isPunctToken(last, ')'); if (span.end - span.start > 1 && !hasSize) return []; if (!SYSTEM_TYPE_NAMES[name]) { return [['MCE086', 'error', 'Type ' + first.raw + ' is not a defined system type. ' + (OTHER_TYPE_ADVICE[name] || 'Use a SQL Server type such as INT, DECIMAL(18, 2), NVARCHAR(100), DATE or DATETIME.'), DOCS.CONV, first, last]]; }; if (!hasSize) return []; var sizeArgs = addedArguments(core, span.start, span.end - 1); var numberOf = function (arg) { var t = core[arg.start]; return arg.end === arg.start + 1 && t.type === 'number' && /^\d+$/.test(t.value) ? Number(t.value) : null; }; var typeText = sql.slice(first.start, last.end); var limit = TYPE_LENGTH_LIMITS[name]; if (limit && sizeArgs.length === 1) { var length = numberOf(sizeArgs[0]); var mostText = String(limit.most).replace(/\B(?=(\d{3})+(?!\d))/g, ','); if (length === 0) return [['MCE086', 'error', typeText + ' has no room for a character: Length or precision specification 0 is invalid. Give a length from 1 to ' + mostText + '.', DOCS.CONV, first, last]]; if (length !== null && length > limit.most) { var maxFix = limit.isMax ? [{ title: 'Use ' + name + '(MAX)', edits: [{ start: core[sizeArgs[0].start].start, end: core[sizeArgs[0].start].end, text: 'MAX' }], isSafe: false, isInFixAll: false }] : null; return [['MCE086', 'error', typeText + ' is longer than SQL Server allows, ' + mostText + ', and it refuses the query. Use ' + (limit.isMax ? name + '(MAX)' : limit.use) + '.', DOCS.CONV, first, last, maxFix]]; }; }; if ((name === 'DECIMAL' || name === 'NUMERIC' || name === 'DEC') && (sizeArgs.length === 1 || sizeArgs.length === 2)) { var precision = numberOf(sizeArgs[0]); var scale = sizeArgs.length === 2 ? numberOf(sizeArgs[1]) : 0; if (precision === 0) return [['MCE086', 'error', typeText + ' holds no digit: Length or precision specification 0 is invalid. Give a precision from 1 to 38.', DOCS.CONV, first, last]]; if (precision !== null && precision > 38) { return [['MCE086', 'error', typeText + ' is refused: Specified column precision ' + precision + ' is greater than the maximum precision of 38. Use 38 at most.', DOCS.CONV, first, last, [{ title: 'Use precision 38', edits: [{ start: core[sizeArgs[0].start].start, end: core[sizeArgs[0].start].end, text: '38' }], isSafe: false, isInFixAll: false }]]]; }; if (precision !== null && scale !== null && scale > precision) { return [['MCE086', 'error', typeText + ' is refused: Specified column scale ' + scale + ' is greater than the specified precision of ' + precision + '. The precision counts every digit, the scale those after the point.', DOCS.CONV, first, last]]; }; }; return []; }; function datePartFinding(sql, core, name, arg) { if (arg.end !== arg.start + 1) return null; var t = core[arg.start]; var parts = DATE_PART_FUNCTIONS[name]; var lowerName = name.toLowerCase(); if (t.type === 'string') { var content = stringLiteralContent(t); var isPart = parts.indexOf(String(content).toUpperCase()) !== -1; var refusal = name === 'DATEPART' ? ': Invalid parameter 1 specified for datepart.' : ', and SQL Server refuses ' + quotable(t.raw, 'it') + '.'; return ['MCE086', 'error', 'The date part of ' + name + ' is a word, not a text' + refusal + (isPart ? ' Write ' + content.toUpperCase() + '.' : ' Write a date part such as DAY or MONTH.'), DOCS.DATE, t, t, isPart ? [{ title: 'Write ' + content.toUpperCase(), edits: [{ start: t.start, end: t.end, text: content.toUpperCase() }], isSafe: true, isInFixAll: true }] : null]; }; if (t.quote || (t.type !== 'identifier' && t.type !== 'keyword') || parts.indexOf(t.upper) !== -1) return null; var singular = /S$/.test(t.upper) ? t.upper.slice(0, -1) : ''; var isPlural = singular.length > 1 && parts.indexOf(singular) !== -1; var isDayCount = singular === 'WEEKDAY' && !(name === 'DATEPART' || name === 'DATENAME'); var advice = isPlural ? ' Use ' + singular + '.' + (isDayCount ? ' It counts every day, as DAY does, and not working days.' : '') : ' Use a date part such as DAY, MONTH or YEAR.'; return ['MCE086', 'error', '\'' + t.raw + '\' is not a recognized ' + lowerName + ' option.' + advice, DOCS.DATE, t, t, isPlural ? [{ title: 'Use ' + singular, edits: [{ start: t.start, end: t.end, text: singular }], isSafe: !isDayCount, isInFixAll: !isDayCount }] : null]; }; function rule086(sql, tokens, ctx) { var findings = []; var core = ctx.getCoreTokens(); for (var i = 0; i < core.length; i++) { var name = core[i].upper; var isCast = name === 'CAST' || name === 'TRY_CAST' || name === 'CONVERT' || name === 'TRY_CONVERT'; if (!isCast && !CALL_ARGUMENT_COUNTS[name]) continue; var close = addedCallClose(core, i, CALL_CHECK_KEYWORDS); if (close === -1) continue; if (isCallUnread(ctx, core, close)) continue; var t = core[i]; var args = addedArguments(core, i, close); var spec = CALL_ARGUMENT_COUNTS[name]; if (name === 'SUBSTRING' && args.length === 2) continue; if (spec && (args.length < spec.counts[0] || args.length > spec.counts[1])) { var countText = spec.counts[0] === spec.counts[1] ? spec.counts[0] + ' argument(s).' : spec.counts[0] + ' to ' + spec.counts[1] + ' arguments.'; var answer = spec.answer ? 'The ' + name.toLowerCase() + ' function requires ' + countText + ' ' : name + ' takes ' + spec.counts[0] + ' arguments. '; findings.push(['MCE086', 'error', answer + 'Write ' + spec.syntax + '.' + (spec.note ? ' ' + spec.note : ''), DOCS.BASICS, t, t]); } else if (DATE_PART_FUNCTIONS[name] && args.length) { var partFinding = datePartFinding(sql, core, name, args[0]); if (partFinding) findings.push(partFinding); }; if (!isCast) continue; var typeSpan = castTypeSpan(core, i, close); if (typeSpan) typeFindings(sql, core, typeSpan).forEach(function (f) { findings.push(f); }); var isNumberTarget = !!typeSpan && !core[typeSpan.start].quote && !!NUMBER_TYPE_NAMES[core[typeSpan.start].upper]; if ((name === 'CONVERT' || name === 'TRY_CONVERT') && args.length === 3 && args[2].end === args[2].start + 1 && !isNumberTarget) { var style = core[args[2].start]; if (style.type === 'number' && /^\d+$/.test(style.value) && !CONVERT_STYLES[Number(style.value)]) { var isTextTarget = !!typeSpan && !core[typeSpan.start].quote && !!TEXT_TYPE_NAMES[core[typeSpan.start].upper]; var value = isTextTarget && name === 'CONVERT' ? valueKindAt(addedRuleFacts(ctx), args[1]) : null; if (value && value.kind === 'date') { findings.push(['MCE086', 'error', 'CONVERT has no style ' + style.value + ', so this date converted to text fails the run, with no reason given. Use 23 for yyyy-mm-dd, 120 with the time, or 126 for ISO 8601.', DOCS.CONV, style, style]); } else { findings.push(['MCE086', 'warning', name + ' has no style ' + style.value + '. SQL Server refuses it or reads it as 0. For a date, use 23 for yyyy-mm-dd, 120 with the time, or 126 for ISO 8601.', DOCS.CONV, style, style]); }; }; }; }; return findings; }; var INT_OVERFLOW_DATE_PARTS = { MILLISECOND: { plural: 'milliseconds', lasts: '25 days' }, MS: { plural: 'milliseconds', lasts: '25 days' }, MICROSECOND: { plural: 'microseconds', lasts: '36 minutes' }, MCS: { plural: 'microseconds', lasts: '36 minutes' }, NANOSECOND: { plural: 'nanoseconds', lasts: '2 seconds' }, NS: { plural: 'nanoseconds', lasts: '2 seconds' } }; var INTEGER_TYPE_NAMES = { BIGINT: 1, INT: 1, INTEGER: 1, SMALLINT: 1, TINYINT: 1 }; var UNIX_EPOCH_TEXT = /^(?:1970-01-01|19700101|0?1\/0?1\/1970)(?:[ T]00:00(?::00(?:\.0{1,7})?)?)?$/; var DATEADD_OVERFLOW_ERROR = 'Arithmetic overflow error converting expression to data type int.'; var DATEDIFF_OVERFLOW_ERROR = 'The datediff function resulted in an overflow.'; var NUMBER_FIELD_NOTE = 'An MCE Number field holds an int, so a bigint such as Unix milliseconds needs a Text or Decimal field.'; function isOutsideInt(isNegative, digits) { var plain = digits.replace(/^0+(?=\d)/, ''); var limit = isNegative ? '2147483648' : '2147483647'; return plain.length !== limit.length ? plain.length > limit.length : plain > limit; }; function wholeNumberLiteral(core, span) { var start = span.start; var end = span.end; var isNegative = false; while (end > start) { if (isPunctToken(core[start], '(') && matchParen(core, start) === end - 1) { start++; end--; continue; }; if (core[start].type === 'operator' && (core[start].value === '-' || core[start].value === '+')) { if (core[start].value === '-') isNegative = !isNegative; start++; continue; }; break; }; if (end !== start + 1 || core[start].type !== 'number' || !/^\d+$/.test(core[start].value)) return null; return { isNegative: isNegative, digits: core[start].value }; }; function castParts(core, span) { var name = core[span.start]; if (!name || name.quote || span.end - span.start < 5 || !isPunctToken(core[span.start + 1], '(')) return null; var close = span.end - 1; if (matchParen(core, span.start + 1) !== close) return null; if (name.upper === 'CAST' || name.upper === 'TRY_CAST') { var as = castOperandEnd(core, span.start + 1, close); if (as === -1 || as + 1 >= close || as <= span.start + 2) return null; return { value: { start: span.start + 2, end: as }, type: { start: as + 1, end: close }, count: 2 }; }; if (name.upper !== 'CONVERT' && name.upper !== 'TRY_CONVERT') return null; var args = addedArguments(core, span.start, close); if (args.length < 2 || args.length > 3) return null; return { value: args[1], type: args[0], count: args.length }; }; function isUnixEpochDate(core, span) { var isEpochText = function (s) { return s.end === s.start + 1 && core[s.start].type === 'string' && UNIX_EPOCH_TEXT.test(stringLiteralContent(core[s.start]).trim()); }; if (isEpochText(span)) return true; var parts = castParts(core, span); return !!parts && isEpochText(parts.value); }; function unixNumberColumn(core, span) { var ref = plainColumnRef(core, span.start, span.end); if (ref) return { ref: ref, isBigint: false }; var parts = castParts(core, span); if (!parts || parts.count !== 2 || parts.type.end !== parts.type.start + 1) return null; var type = core[parts.type.start]; var valueRef = type.quote ? null : plainColumnRef(core, parts.value.start, parts.value.end); if (!valueRef || !INTEGER_TYPE_NAMES[type.upper]) return null; return { ref: valueRef, isBigint: type.upper === 'BIGINT' }; }; function splitMillisecondFix(sql, ctx, core, numberSpan, dateSpan, millis, seconds) { var dateStart = core[dateSpan.start].start; var dateEnd = core[dateSpan.end - 1].end; return { title: 'Split into seconds and milliseconds', edits: formatLaidOutEdits(sql, [ { start: core[numberSpan.start].start, end: core[numberSpan.end - 1].end, text: millis }, { start: dateStart, end: dateStart, text: 'DATEADD(SECOND, ' + seconds + ', ' }, { start: dateEnd, end: dateEnd, text: ')' } ], ctx), isSafe: true, isInFixAll: true }; }; function rule102(sql, tokens, ctx) { var findings = []; var core = ctx.getCoreTokens(); for (var i = 0; i < core.length; i++) { var name = core[i].upper; if (name !== 'DATEADD' && name !== 'DATEDIFF') continue; var close = addedCallClose(core, i, CALL_CHECK_KEYWORDS); if (close === -1 || isCallUnread(ctx, core, close)) continue; var args = addedArguments(core, i, close); if (args.length !== 3 || args[0].end !== args[0].start + 1) continue; var unit = core[args[0].start]; var part = unit.quote ? null : INT_OVERFLOW_DATE_PARTS[unit.upper]; if (name === 'DATEDIFF') { if (!part || !(isUnixEpochDate(core, args[1]) || isUnixEpochDate(core, args[2]))) continue; findings.push(['MCE102', 'error', 'DATEDIFF returns an int, and ' + part.plural + ' from 1970 pass 2147483647 within ' + part.lasts + ': ' + DATEDIFF_OVERFLOW_ERROR + ' DATEDIFF_BIG returns a bigint, and an MCE Number field holds an int, so use a Text or Decimal field.', DOCS.DATE, core[i], core[i], [{ title: 'Use DATEDIFF_BIG', edits: formatLaidOutEdits(sql, [{ start: core[i].start, end: core[i].end, text: 'DATEDIFF_BIG' }], ctx), isSafe: true, isInFixAll: false }]]); continue; }; var numberSpan = args[1]; var dateSpan = args[2]; var first = core[numberSpan.start]; var last = core[numberSpan.end - 1]; var hasComment = /--|\/\*/.test(sql.slice(first.start, last.end)); var isMillisecond = !unit.quote && (unit.upper === 'MILLISECOND' || unit.upper === 'MS'); var fixes = null; var message; var literal = wholeNumberLiteral(core, numberSpan); if (literal) { if (!isOutsideInt(literal.isNegative, literal.digits)) continue; message = 'DATEADD takes an int, and this number is ' + (literal.isNegative ? 'below -2147483648' : 'past 2147483647') + ': ' + DATEADD_OVERFLOW_ERROR + ' ' + NUMBER_FIELD_NOTE; var digits = literal.digits.replace(/^0+(?=\d)/, ''); var seconds = digits.slice(0, -3); var millis = digits.slice(-3).replace(/^0+(?=\d)/, ''); var sign = literal.isNegative ? '-' : ''; if (isMillisecond && !hasComment && seconds !== '' && !isOutsideInt(literal.isNegative, seconds)) { fixes = millis === '0' ? [{ title: 'Use seconds', edits: formatLaidOutEdits(sql, [ { start: unit.start, end: unit.end, text: 'SECOND' }, { start: first.start, end: last.end, text: sign + seconds } ], ctx), isSafe: true, isInFixAll: true }] : [splitMillisecondFix(sql, ctx, core, numberSpan, dateSpan, sign + millis, sign + seconds)]; }; } else { if (!part || !isUnixEpochDate(core, dateSpan)) continue; var column = unixNumberColumn(core, numberSpan); if (!column) continue; var kind = addedFieldKind(addedFieldAt(addedRuleFacts(ctx), column.ref.start, column.ref.qualifier, column.ref.column)); if (kind === 'number' || kind === 'date' || kind === 'boolean') continue; message = 'DATEADD takes an int, and Unix ' + part.plural + ' are past 2147483647: ' + DATEADD_OVERFLOW_ERROR + ' ' + NUMBER_FIELD_NOTE; if (isMillisecond && !hasComment) { var big = column.isBigint ? spanText(sql, core, numberSpan) : 'CONVERT(BIGINT, ' + spanText(sql, core, column.ref) + ')'; fixes = [splitMillisecondFix(sql, ctx, core, numberSpan, dateSpan, big + ' % 1000', big + ' / 1000')]; }; }; findings.push(['MCE102', 'error', message, DOCS.DATE, first, last, fixes]); }; return findings; }; var SAMPLE_VALUES_WORDING = /\b(custom|configured|can be|such as|for example|besides|including)\b/i; function fieldValueList(field) { return (Array.isArray(field.values) ? field.values : []).map(function (v) { return String(v && v.value !== undefined ? v.value : v); }); }; function knownFieldValues(field) { return fieldValueList(field).filter(function (v) { return v.trim() !== '' && !/^null$/i.test(v.trim()); }); }; function isNeverNumber(field) { if (field.type === 'Email' || field.type === 'EmailAddress') return true; var values = knownFieldValues(field); return values.length > 0 && values.every(function (v) { return !/^\s*[+-]?(\d+\.?\d*|\.\d+)\s*$/.test(v); }); }; function isCertainNeverNumber(field) { if (!isNeverNumber(field)) return false; if (field.type === 'Email' || field.type === 'EmailAddress') return true; var values = fieldValueList(field); if (values.length !== knownFieldValues(field).length) return false; return !SAMPLE_VALUES_WORDING.test(String(field.description || '') + ' ' + values.join(' ')); }; function isCertainNeverNumberColumn(facts, ref) { var found = addedColumnAt(facts, ref.start, ref.qualifier, ref.column); if (!found || !isCertainNeverNumber(found.field)) return false; return neverNullColumnEnd(facts.core, ref.start, facts.byName, addedScopesAt(facts, ref.start)) === ref.end - 1; }; function neverNumberSubject(name, field, isComplete) { if (field.type === 'Email' || field.type === 'EmailAddress') return name + ' holds email addresses, none a number.'; var values = knownFieldValues(field); var quoted = values.map(function (v) { return '\'' + v + '\''; }); var known = isComplete ? '' : 'known '; var wordCount = function (text) { return text.split(/\s+/).filter(Boolean).length; }; var sentence = values.length === 1 ? name + ' is a Text field whose ' + (isComplete ? 'one value' : 'only known value') + ', ' + quoted[0] + ', is no number.' : name + ' is a Text field whose ' + known + 'values, ' + listWords(quoted) + ', are none a number.'; if (values.length <= 4 && wordCount(sentence) <= 17) return sentence; if (values.length === 1) return name + ' is a Text field whose ' + (isComplete ? 'one value' : 'only known value') + ' is no number.'; var example = values.filter(function (v) { return wordCount(v) <= 2; })[0]; return name + ' is a Text field whose ' + values.length + ' ' + known + 'values' + (example ? ', such as \'' + example + '\',' : '') + ' are none a number.'; }; function textNumberFinding(sql, facts, ref, field, numbers, anchor) { var core = facts.core; var name = ref.column; var numberText = numbers.length === 1 ? spanText(sql, core, numbers[0]) : 'numbers'; if (isNeverNumber(field)) { var isCertain = isCertainNeverNumberColumn(facts, ref); var subject = neverNumberSubject(name, field, isCertainNeverNumber(field)); var effect = isCertain ? 'and the run fails. Compare with a text value.' : 'and one that is no number fails the run. Compare with a text.'; return ['MCE087', isCertain ? 'error' : 'warning', subject + ' Comparing it with ' + numberText + ' converts each value to a number, ' + effect, DOCS.WHERE, anchor, anchor]; }; var title = numbers.length === 1 ? 'Compare with the text \'' + spanText(sql, core, numbers[0]) + '\'' : 'Write the numbers as texts'; var edits = numbers.map(function (n) { return { start: core[n.start].start, end: core[n.end - 1].end, text: '\'' + spanText(sql, core, n) + '\'' }; }); return ['MCE087', 'warning', name + ' is a Text field, so SQL Server converts every value to a number to compare it with ' + numberText + ', and a value that is no number fails the run. Compare with a text instead.', DOCS.WHERE, anchor, anchor, [{ title: title, edits: edits, isSafe: false, isInFixAll: false }]]; }; function textFieldAt(facts, ref) { if (!ref) return null; var field = addedFieldAt(facts, ref.start, ref.qualifier, ref.column); return addedFieldKind(field) === 'text' ? field : null; }; function simpleCaseAt(core, i) { if (!isKw(core[i], 'CASE') || !core[i + 1] || isKw(core[i + 1], 'WHEN')) return null; var shape = caseShapesOf(core)[i]; if (!shape || shape.end === -1) return null; var input = null; var whens = []; var openWhen = -1; shape.marks.forEach(function (k) { var word = core[k].upper; if (word === 'WHEN') { if (!input) { input = { start: i + 1, end: k }; }; openWhen = k; } else if (word === 'THEN' && openWhen !== -1) { whens.push({ whenIdx: openWhen, value: { start: openWhen + 1, end: k } }); openWhen = -1; }; }); return input ? { input: input, whens: whens, end: shape.end } : null; }; function caseShapesOf(core) { var kept = core.addedCaseShapes; if (kept && kept.length === core.length) return kept; var shapes = new Array(core.length); var open = []; for (var k = 0; k < core.length; k++) { var t = core[k]; while (open.length && open[open.length - 1].depth > t.depth) open.pop(); if (t.type !== 'keyword') continue; if (t.upper === 'CASE') { shapes[k] = { depth: t.depth, marks: [], end: -1 }; open.push(shapes[k]); continue; }; var top = open[open.length - 1]; if (!top || top.depth !== t.depth) continue; if (t.upper === 'WHEN' || t.upper === 'THEN' || t.upper === 'ELSE') { top.marks.push(k); } else if (t.upper === 'END') { top.end = k; open.pop(); }; }; try { Object.defineProperty(core, 'addedCaseShapes', { value: shapes, enumerable: false, configurable: true, writable: true }); } catch (e) { }; return shapes; }; function rule087(sql, tokens, ctx) { var findings = []; if (!Object.keys(ensureIndex(ctx.schema)).length) return findings; var facts = addedRuleFacts(ctx); var core = facts.core; for (var i = 0; i < core.length; i++) { var t = core[i]; if (t.type === 'operator' && DATE_COMPARISON_OPERATORS.indexOf(t.value) !== -1 && !facts.assignmentAt[t.start]) { var left = addedOperandBefore(core, i); var leftRef = left && startsComparison(core[left.start - 1]) ? plainColumnRef(core, left.start, left.end) : null; var numberEnd = numberLiteralEnd(core, i + 1); var leftField = leftRef && numberEnd !== -1 && endsComparison(core[numberEnd]) ? textFieldAt(facts, leftRef) : null; if (leftField) { findings.push(textNumberFinding(sql, facts, leftRef, leftField, [{ start: i + 1, end: numberEnd }], t)); continue; }; var numberStart = core[i - 1] && core[i - 1].type === 'number' ? (core[i - 2] && core[i - 2].type === 'operator' && (core[i - 2].value === '-' || core[i - 2].value === '+') && startsComparison(core[i - 3]) ? i - 2 : i - 1) : -1; if (numberStart !== -1 && startsComparison(core[numberStart - 1])) { var right = operandAfter(core, i + 1); var rightRef = right && endsComparison(core[right.end]) ? plainColumnRef(core, right.start, right.end) : null; var rightField = textFieldAt(facts, rightRef); if (rightField) findings.push(textNumberFinding(sql, facts, rightRef, rightField, [{ start: numberStart, end: i }], t)); }; continue; }; if ((isKw(t, 'IN') && isPunctToken(core[i + 1], '(')) || isKw(t, 'BETWEEN')) { var testedEnd = isKw(core[i - 1], 'NOT') ? i - 1 : i; var tested = addedOperandBefore(core, testedEnd); var testedRef = tested && startsComparison(core[tested.start - 1]) ? plainColumnRef(core, tested.start, tested.end) : null; if (!testedRef) continue; if (isKw(t, 'BETWEEN')) { var lowEnd = numberLiteralEnd(core, i + 1); var highEnd = lowEnd !== -1 && isKw(core[lowEnd], 'AND') ? numberLiteralEnd(core, lowEnd + 1) : -1; var betweenField = highEnd !== -1 && endsComparison(core[highEnd]) ? textFieldAt(facts, testedRef) : null; if (betweenField) findings.push(textNumberFinding(sql, facts, testedRef, betweenField, [{ start: i + 1, end: lowEnd }, { start: lowEnd + 1, end: highEnd }], t)); continue; }; if (isKw(core[i + 2], 'SELECT')) continue; var numbers = addedArguments(core, i, matchParen(core, i + 1)).filter(function (arg) { return numberLiteralEnd(core, arg.start) === arg.end; }); var listField = numbers.length ? textFieldAt(facts, testedRef) : null; if (listField) findings.push(textNumberFinding(sql, facts, testedRef, listField, numbers, t)); continue; }; var simple = isKw(t, 'CASE') ? simpleCaseAt(core, i) : null; if (simple) { var caseRef = plainColumnRef(core, simple.input.start, simple.input.end); var caseNumbers = simple.whens.map(function (w) { return w.value; }).filter(function (v) { return numberLiteralEnd(core, v.start) === v.end; }); var caseField = caseRef && caseNumbers.length ? textFieldAt(facts, caseRef) : null; if (caseField) findings.push(textNumberFinding(sql, facts, caseRef, caseField, caseNumbers, t)); }; }; return findings; }; function inSubqueryTypeFindings(ctx) { var facts = addedRuleFacts(ctx); var core = facts.core; var findings = []; for (var i = 0; i < core.length; i++) { if (!isKw(core[i], 'IN') || !isPunctToken(core[i + 1], '(') || !isKw(core[i + 2], 'SELECT')) continue; var tested = addedOperandBefore(core, isKw(core[i - 1], 'NOT') ? i - 1 : i); var testedRef = tested && startsComparison(core[tested.start - 1]) ? plainColumnRef(core, tested.start, tested.end) : null; var finding = testedRef ? inSubqueryTypeFinding(facts, core, testedRef, i) : null; if (finding) findings.push(finding); }; return findings; }; function inSubqueryTypeFinding(facts, core, testedRef, inIdx) { var scope = facts.scopeBySelect[inIdx + 2]; if (!scope || scope.selectList.items.length !== 1) return null; var item = scope.selectList.items[0]; if (item.kind !== 'column' || !item.sourceColumn) return null; var testedField = addedFieldAt(facts, testedRef.start, testedRef.qualifier, testedRef.column); var innerField = addedFieldAt(facts, inIdx + 2, item.sourceTable, item.sourceColumn); var testedKind = addedFieldKind(testedField); var innerKind = addedFieldKind(innerField); var isNumeric = function (kind) { return kind === 'number' || kind === 'decimal'; }; var innerSpan = itemValueSpan(core, item); var textRef = null; var textField = null; var numberName = ''; if (testedKind === 'text' && isNumeric(innerKind)) { textRef = testedRef; textField = testedField; numberName = item.sourceColumn; } else if (isNumeric(testedKind) && innerKind === 'text') { textRef = innerSpan ? plainColumnRef(core, innerSpan.start, innerSpan.end) : null; textField = innerField; numberName = testedRef.column; }; if (!textField) return null; var textName = textRef ? textRef.column : item.sourceColumn; var isError = !!textRef && isCertainNeverNumberColumn(facts, textRef); var anchor = core[inIdx]; return ['MCE017', isError ? 'error' : 'warning', 'This IN compares ' + textName + ', ' + textFieldLabel(textField) + ', with ' + numberName + ', a number, so SQL Server converts each value of ' + textName + ' to a number, and ' + (isError ? 'the run fails.' : 'one that is no number fails the run.') + ' Compare columns of one type.', DOCS.WHERE, anchor, anchor]; }; function textFieldLabel(field) { if (field.type === 'Email' || field.type === 'EmailAddress') return 'an ' + field.type + ' field'; return field.type === 'Phone' ? 'a Phone field' : 'a Text field'; }; function dateOnlyText(t) { if (!t || t.type !== 'string') return null; var content = String(stringLiteralContent(t)); var m = /^(\d{4})-(\d{2})-(\d{2})$/.exec(content) || /^(\d{4})(\d{2})(\d{2})$/.exec(content); if (!m) return null; var year = Number(m[1]); var month = Number(m[2]); var day = Number(m[3]); if (month < 1 || month > 12 || day < 1 || day > daysInMonth(year, month)) return null; var next = new Date(Date.UTC(year, month - 1, day + 1)); var y = String(next.getUTCFullYear()); var mm = padTwo(next.getUTCMonth() + 1); var dd = padTwo(next.getUTCDate()); return { next: content.indexOf('-') !== -1 ? y + '-' + mm + '-' + dd : y + mm + dd }; }; function isDateOnlyCall(core, span) { var t = core[span.start]; if (!t || !isPunctToken(core[span.start + 1], '(') || matchParen(core, span.start + 1) !== span.end - 1) return false; if (t.upper === 'EOMONTH') return true; var typeSpan = (t.upper === 'CAST' || t.upper === 'TRY_CAST' || t.upper === 'CONVERT' || t.upper === 'TRY_CONVERT') ? castTypeSpan(core, span.start, span.end - 1) : null; return !!typeSpan && typeSpan.end === typeSpan.start + 1 && core[typeSpan.start].upper === 'DATE'; }; function isNowExpression(core, span) { if (span.end === span.start + 3 && isNowCallAt(core, span.start)) return true; var t = core[span.start]; if (!t || t.upper !== 'DATEADD' || !isPunctToken(core[span.start + 1], '(') || matchParen(core, span.start + 1) !== span.end - 1) return false; var args = addedArguments(core, span.start, span.end - 1); return args.length === 3 && isNowExpression(core, args[2]); }; function upperBoundFinding(sql, core, ref, bound, replaced, lowerText, anchor) { var text = bound.end === bound.start + 1 ? dateOnlyText(core[bound.start]) : null; var isCall = !text && isDateOnlyCall(core, bound); if (!text && !isCall) return null; var boundText = spanText(sql, core, bound); var nextText = text ? '\'' + text.next + '\'' : 'DATEADD(DAY, 1, ' + boundText + ')'; var fieldText = spanText(sql, core, ref); var fixText = (lowerText ? '>= ' + lowerText + ' AND ' + fieldText + ' ' : '') + '< ' + nextText; var how = lowerText ? 'BETWEEN' : '<='; var end = text ? 'End the window with < ' + nextText + '.' : 'End the window before the next day, as the fix writes.'; return ['MCE088', 'warning', ref.column + ' holds a date and a time, and ' + quotable(boundText, 'the bound') + ' is that day at midnight, so ' + how + ' leaves out the rest of the day. ' + end, DOCS.DATE, anchor, core[bound.end - 1], [{ title: 'End before the next day', edits: [{ start: core[replaced.start].start, end: core[replaced.end - 1].end, text: fixText }], isSafe: false, isInFixAll: false }]]; }; function rule088(sql, tokens, ctx) { var findings = []; if (!Object.keys(ensureIndex(ctx.schema)).length) return findings; var facts = addedRuleFacts(ctx); var core = facts.core; var isDateField = function (ref) { return !!ref && addedFieldKind(addedFieldAt(facts, ref.start, ref.qualifier, ref.column)) === 'date'; }; var isViewDate = function (ref) { var found = ref ? addedColumnAt(facts, ref.start, ref.qualifier, ref.column) : null; return !!found && found.table.kind === 'dataview' && addedFieldKind(found.field) === 'date'; }; var utcSpans = {}; var utcSpanAt = function (k) { var u = core[k]; if (!u || u.type !== 'identifier' || u.quote || (u.upper !== 'GETUTCDATE' && u.upper !== 'SYSUTCDATETIME')) return null; if (utcSpans[k] !== undefined) return utcSpans[k]; var span = isNowCallAt(core, k) && !isServerTimeShift(core, k) ? serverTimeSpan(core, k, k + 3) : null; utcSpans[k] = span && isComparedWithServerDate(core, span, serverTimeFieldResolver(facts.byName, addedScopesAt(facts, k))) ? span : null; return utcSpans[k]; }; var takenUtc = {}; var utcFindings = []; for (var i = 0; i < core.length; i++) { var t = core[i]; if (isKw(t, 'BETWEEN') && !isKw(core[i - 1], 'NOT')) { var tested = addedOperandBefore(core, i); var testedRef = tested && startsComparison(core[tested.start - 1]) ? plainColumnRef(core, tested.start, tested.end) : null; if (!testedRef || isUnderNot(core, tested.start) || !isViewDate(testedRef)) continue; var andIdx = -1; for (var k = i + 1; k < core.length; k++) { if (core[k].depth < t.depth) break; if (isKw(core[k], 'AND') && core[k].depth === t.depth && betweenOfAnd(core, k) === i) { andIdx = k; break; }; }; if (andIdx <= i + 1) continue; var bound = boundSpanAt(core, andIdx + 1); if (!bound) continue; var betweenFinding = upperBoundFinding(sql, core, testedRef, bound, { start: i, end: bound.end }, spanText(sql, core, { start: i + 1, end: andIdx }), t); if (betweenFinding) findings.push(betweenFinding); continue; }; if (t.type === 'operator' && (t.value === '<=' || t.value === '=') && !facts.assignmentAt[t.start]) { var left = addedOperandBefore(core, i); if (!left || !startsComparison(core[left.start - 1]) || isUnderNot(core, left.start)) continue; var right = boundSpanAt(core, i + 1); if (!right) continue; var leftRef = plainColumnRef(core, left.start, left.end); if (t.value === '<=') { if (!isViewDate(leftRef)) continue; var lessFinding = upperBoundFinding(sql, core, leftRef, right, { start: i, end: right.end }, '', t); if (lessFinding) findings.push(lessFinding); continue; }; var nowFinding = sameMomentFinding(sql, core, left, right, leftRef, plainColumnRef(core, right.start, right.end), isDateField, t, utcSpanAt, takenUtc); if (nowFinding) findings.push(nowFinding); continue; }; var utcSpan = utcSpanAt(i); if (utcSpan) { var serverName = t.upper === 'GETUTCDATE' ? 'GETDATE' : 'SYSDATETIME'; var what = utcSpan.call ? 'this difference' : 'this window'; utcFindings.push({ idx: i, finding: ['MCE088', 'warning', 'Data View dates hold MCE server time, UTC-6 all year, and ' + t.upper + '() returns UTC, so ' + what + ' is 6 hours off. Use ' + serverName + '(), which returns server time.', DOCS.DATE, t, core[i + 2], [{ title: 'Use ' + serverName + '()', edits: [{ start: t.start, end: t.end, text: serverName }], isSafe: false, isInFixAll: false }]] }); }; }; utcFindings.forEach(function (u) { if (!takenUtc[u.idx]) findings.push(u.finding); }); return findings; }; function isServerTimeShift(core, i) { var open = i - 1; if (!isPunctToken(core[open], ',')) return false; var callOpen = -1; for (var k = i - 2; k >= 1 && k >= i - 6; k--) { if (isPunctToken(core[k], '(') && core[k].depth === core[i].depth - 1) { callOpen = k; break; }; }; if (callOpen < 1 || core[callOpen - 1].upper !== 'DATEADD') return false; var args = addedArguments(core, callOpen - 1, matchParen(core, callOpen)); if (args.length !== 3 || args[2].start !== i || args[2].end !== i + 3) return false; var part = core[args[0].start].upper; var numberEnd = numberLiteralEnd(core, args[1].start); if (args[0].end !== args[0].start + 1 || numberEnd !== args[1].end) return false; var n = Number(core[args[1].end - 1].value) * (isPunctToken(core[args[1].start], '-') || core[args[1].start].value === '-' ? -1 : 1); if (!/^\d+$/.test(core[args[1].end - 1].value)) return false; if (part === 'HOUR' || part === 'HH') return ((n % 24) + 24) % 24 === 18; if (part === 'MINUTE' || part === 'MI' || part === 'N') return ((n % 1440) + 1440) % 1440 === 1080; return false; }; function boundSpanAt(core, i) { var t = core[i]; if (!t) return null; if ((t.type === 'string' || t.type === 'number') && endsComparison(core[i + 1])) { return { start: i, end: i + 1 }; }; var span = operandAfter(core, i); return span && endsComparison(core[span.end]) ? span : null; }; function sameMomentFinding(sql, core, left, right, leftRef, rightRef, isDateField, anchor, utcSpanAt, takenUtc) { var nowSide = isNowExpression(core, right) ? right : (isNowExpression(core, left) ? left : null); if (!nowSide) return null; var other = nowSide === right ? left : right; var otherRef = nowSide === right ? leftRef : rightRef; var isDateOnly = isDateOnlyCall(core, other) && core[other.start].upper !== 'EOMONTH'; if (!isDateOnly && !isDateField(otherRef)) return null; var nowText = spanText(sql, core, nowSide); var otherText = spanText(sql, core, other); var serverText = nowText; var utcName = ''; var serverName = ''; for (var k = nowSide.end - 1; k >= nowSide.start; k--) { if (!utcSpanAt(k)) continue; takenUtc[k] = true; utcName = core[k].upper + '()'; serverName = (core[k].upper === 'GETUTCDATE' ? 'GETDATE' : 'SYSDATETIME') + '()'; var from = core[k].start - core[nowSide.start].start; serverText = serverText.slice(0, from) + serverName.slice(0, -2) + serverText.slice(from + core[k].end - core[k].start); }; var edits = [{ start: core[nowSide.start].start, end: core[nowSide.end - 1].end, text: 'CAST(' + serverText + ' AS DATE)' }]; if (!isDateOnly) { edits.push({ start: core[other.start].start, end: core[other.end - 1].end, text: 'CAST(' + otherText + ' AS DATE)' }); }; edits.sort(function (a, b) { return a.start - b.start; }); var build = function (isPlain) { var otherName = isPlain ? quotable(otherText, 'This date') : 'This date'; var nowName = isPlain ? quotable(nowText, 'the current time') : 'the current time'; var nowFirst = nowName.charAt(0).toUpperCase() + nowName.slice(1); var serverDate = isPlain ? quotable('CAST(' + serverText + ' AS DATE)', 'the server time cast to DATE') : 'the server time cast to DATE'; var notUtc = ', as Data View dates hold server time, not UTC.'; if (isDateOnly) { return otherName + ' is midnight of its day, and ' + nowName + ' holds a time, so the two are equal at midnight alone. ' + (utcName ? 'Compare with ' + serverDate + notUtc : 'Cast ' + nowName + ' to DATE too.'); }; return nowFirst + ' holds a time, so ' + (isPlain ? quotable(otherText, 'the date') : 'the date') + ' almost never equals it. Compare the days, with CAST(... AS DATE) on both sides' + (utcName ? ', and use ' + serverName + notUtc : '.'); }; var message = build(true); if (message.split(/\s+/).length > 40) message = build(false); return ['MCE088', 'warning', message, DOCS.DATE, anchor, anchor, [{ title: 'Compare the days', edits: edits, isSafe: false, isInFixAll: false }]]; }; function enclosingOpensOf(tokens) { var kept = tokens.enclosingOpens; if (kept && kept.length === tokens.length) return kept; var opens = new Array(tokens.length); var stack = []; for (var k = 0; k < tokens.length; k++) { if (isPunctToken(tokens[k], ')') && stack.length) stack.pop(); opens[k] = stack.length ? stack[stack.length - 1] : -1; if (isPunctToken(tokens[k], '(')) stack.push(k); }; try { Object.defineProperty(tokens, 'enclosingOpens', { value: opens, enumerable: false, configurable: true, writable: true }); } catch (e) { }; return opens; }; function isUnderNot(core, start) { var opens = enclosingOpensOf(core); var k = start; for (var guard = 0; guard < 1000 && k > 0; guard++) { var before = core[k - 1]; if (isKw(before, 'NOT')) return true; if (!startsComparison(before)) return false; var open = isPunctToken(before, '(') ? k - 1 : opens[k]; if (open <= 0 || isKwAny(core[open + 1], ['SELECT', 'WITH']) || callNameAt(core, open) || !startsComparison(core[open - 1])) return false; k = open; }; return false; }; function valueKindAt(facts, span) { var core = facts.core; if (span.end <= span.start) return null; var t = core[span.start]; var valueOf = function (kind, name) { return { kind: kind, name: name }; }; if (span.end === span.start + 1 && t.type === 'string') return valueOf('literal', ''); if (numberLiteralEnd(core, span.start) === span.end) { var number = core[span.end - 1].value; return valueOf(/^\d+$/.test(number) ? 'number' : 'decimal', number); }; if (span.end === span.start + 3 && isNowCallAt(core, span.start)) return valueOf('date', t.upper + '()'); var ref = plainColumnRef(core, span.start, span.end); var kind = ref ? addedFieldKind(addedFieldAt(facts, ref.start, ref.qualifier, ref.column)) : null; return kind ? valueOf(kind, ref.column) : null; }; function textMisfit(t, kind) { var content = String(stringLiteralContent(t)); if (/^\s*$/.test(content)) return kind === 'decimal' ? 'fails' : 'empty'; if (kind === 'number') return /^\s*[+-]?\d+\s*$/.test(content) ? '' : 'fails'; if (kind === 'decimal') return /^\s*[+-]?(\d+\.?\d*|\.\d+)\s*$/.test(content) ? '' : 'fails'; return isDateText(content) ? '' : 'fails'; }; var DATE_TEXT_TIME = '\\d{1,2}:\\d{2}(:\\d{2}([.:]\\d{1,7})?)?\\s*([AaPp][Mm])?Z?'; var DATE_TEXT_FORMS = [ new RegExp('^\\d{1,4}[-/.]\\d{1,2}[-/.]\\d{1,4}([ T]+' + DATE_TEXT_TIME + ')?$'), new RegExp('^(\\d{4}|\\d{6}|\\d{8})( +' + DATE_TEXT_TIME + ')?$'), new RegExp('^' + DATE_TEXT_TIME + '$') ]; var DATE_TEXT_MONTH = /\b(jan(uary)?|feb(ruary)?|mar(ch)?|apr(il)?|may|june?|july?|aug(ust)?|sep(t(ember)?)?|oct(ober)?|nov(ember)?|dec(ember)?)\b/i; function isDateText(content) { var text = String(content).trim(); if (DATE_TEXT_FORMS.some(function (re) { return re.test(text); })) return true; return DATE_TEXT_MONTH.test(text) && /\d/.test(text) && /^[A-Za-z0-9 ,.:-]+$/.test(text); }; function misfitFinding(t, value, misfit, where) { var kindName = value.kind === 'date' ? 'a date' : 'a number'; var beside = value.name ? value.name + ', ' + kindName : kindName; var effect = misfit === 'empty' ? 'SQL Server converts it to ' + (value.kind === 'date' ? '1900-01-01' : '0') + '.' : 'SQL Server cannot convert it, and the run fails.'; var place = where === 'UNION' ? 'in another branch of the same column' : 'in this ' + where; var cure = where === 'ISNULL' || where === 'COALESCE' ? 'Leave the NULL, or convert ' + (value.name || 'the value') + ' to text first.' : 'Use NULL, or convert the value to text in every place.'; return ['MCE089', 'warning', quotable(t.raw, 'This text') + ' sits beside ' + beside + ', ' + place + ', so ' + effect + ' ' + cure, value.kind === 'date' ? DOCS.DATE : DOCS.CONV, t, t]; }; function caseResults(core, i) { var shape = caseShapesOf(core)[i]; if (!shape || shape.end === -1) return []; var results = []; var resultStart = -1; shape.marks.concat([shape.end]).forEach(function (k) { var word = core[k].upper; if (word === 'THEN') { resultStart = k + 1; return; }; if (resultStart !== -1) { results.push({ start: resultStart, end: k }); }; resultStart = word === 'ELSE' ? k + 1 : -1; }); return results; }; function misfitsAmong(facts, spans, where) { var values = spans.map(function (span) { return { span: span, value: valueKindAt(facts, span) }; }); var rank = { number: 1, decimal: 2, date: 3 }; var typed = values.filter(function (v) { return v.value && rank[v.value.kind]; }); if (!typed.length) return []; var top = typed.reduce(function (a, b) { return rank[b.value.kind] > rank[a.value.kind] ? b : a; }); var findings = []; values.forEach(function (v) { if (!v.value || v.value.kind !== 'literal') return; var t = facts.core[v.span.start]; var misfit = textMisfit(t, top.value.kind); if (misfit) findings.push(misfitFinding(t, top.value, misfit, where)); }); return findings; }; function rule089(sql, tokens, ctx) { var findings = []; var facts = addedRuleFacts(ctx); var core = facts.core; var push = function (list) { list.forEach(function (f) { findings.push(f); }); }; for (var i = 0; i < core.length; i++) { var t = core[i]; var name = t.upper; if ((name === 'ISNULL' || name === 'COALESCE' || name === 'IIF') && !t.quote && isPunctToken(core[i + 1], '(') && !isPunctToken(core[i - 1], '.')) { var close = callClose(core, i); if (close === -1) continue; var args = addedArguments(core, i, close); if (name === 'IIF') { if (args.length === 3) push(misfitsAmong(facts, args.slice(1), 'IIF')); continue; }; var firstValue = args.length >= 2 ? valueKindAt(facts, args[0]) : null; var isTextFirst = !!firstValue && firstValue.kind === 'literal'; var isAllKnown = name === 'COALESCE' && isTextFirst && args.every(function (arg) { return !!valueKindAt(facts, arg); }); if (firstValue && (!isTextFirst || isAllKnown)) push(misfitsAmong(facts, args, name)); continue; }; if (isKw(t, 'CASE')) push(misfitsAmong(facts, caseResults(core, i), 'CASE')); }; push(unionMisfits(facts, ctx)); return findings; }; function unionMisfits(facts, ctx) { var nesting = ctx.getSelectNesting(); var chains = {}; Object.keys(facts.scopeBySelect).forEach(function (key) { var place = nesting[key]; if (!place) return; var head = place.head !== undefined && place.head !== -1 ? place.head : Number(key); (chains[head] = chains[head] || []).push(facts.scopeBySelect[key]); }); var findings = []; Object.keys(chains).forEach(function (head) { var branches = chains[head]; if (branches.length < 2) return; var width = branches[0].selectList.items.length; var isEven = branches.every(function (branch) { return branch.selectList.items.length === width && !branch.selectList.items.some(function (item) { return item.kind === 'star'; }); }); if (!isEven) return; for (var p = 0; p < width; p++) { var spans = []; branches.forEach(function (branch) { var item = branch.selectList.items[p]; var span = item ? itemValueSpan(facts.core, item) : null; if (span) spans.push(span); }); if (spans.length >= 2) misfitsAmong(facts, spans, 'UNION').forEach(function (f) { findings.push(f); }); }; }); return findings; }; function itemValueSpan(core, item) { var list = item.kind === 'alias' ? aliasItemExpressionTokens(item) : (item.tokens || null); if (!list) { if (item.kind !== 'column') return null; var from = tokenIndexAtOrAfter(core, item.start); var to = from; while (to < core.length && core[to].end <= item.end) to++; return to > from ? { start: from, end: to } : null; }; if (!list.length) return null; var start = tokenIndexAtOrAfter(core, list[0].start); return { start: start, end: start + list.length }; }; function rule026More(sql, tokens, ctx) { var findings = []; var core = ctx.getCoreTokens(); for (var i = 0; i < core.length; i++) { var t = core[i]; var op = core[i + 1]; if (isKw(t, 'NULL') && op && op.type === 'operator' && (op.value === '=' || op.value === '!=' || op.value === '<>') && !isKw(core[i + 2], 'NULL') && startsComparison(core[i - 1])) { var replacement = op.value === '=' ? 'IS NULL' : 'IS NOT NULL'; var right = operandAfter(core, i + 2); var isWhole = !!right && endsComparison(core[right.end]); findings.push(['MCE026', 'warning', 'Comparing with NULL using = or != is never true. Use IS NULL or IS NOT NULL instead.', DOCS.NULLF, t, op, isWhole ? [ { title: 'Use ' + replacement, edits: [{ start: t.start, end: core[right.end - 1].end, text: spanText(sql, core, right) + ' ' + replacement }], isSafe: true, isInFixAll: true } ] : []]); continue; }; var simple = isKw(t, 'CASE') ? simpleCaseAt(core, i) : null; if (!simple) continue; var nullWhens = simple.whens.filter(function (w) { return w.value.end === w.value.start + 1 && isKw(core[w.value.start], 'NULL'); }); if (!nullWhens.length) continue; var inputText = spanText(sql, core, simple.input); var groupedInput = groupedText(sql, core, simple.input); var edits = [{ start: core[simple.input.start].start, end: core[simple.input.end].start, text: '' }]; simple.whens.forEach(function (w) { var isNull = nullWhens.indexOf(w) !== -1; edits.push(isNull ? { start: core[w.value.start].start, end: core[w.value.start].end, text: groupedInput + ' IS NULL' } : { start: core[w.value.start].start, end: core[w.value.start].start, text: groupedInput + ' = ' }); }); var inputName = quotable(inputText, 'the value'); nullWhens.forEach(function (w) { findings.push(['MCE026', 'warning', 'A simple CASE compares with =, so WHEN NULL is never true. Write CASE WHEN ' + inputName + ' IS NULL THEN instead.', DOCS.NULLF, core[w.whenIdx], core[w.value.start], [{ title: inputName === inputText ? 'Use CASE WHEN ' + inputText + ' IS NULL' : 'Use a searched CASE', edits: edits, isSafe: false, isInFixAll: false }]]); }); }; return findings; }; function conditionSegments(core, start, end, depth) { var segs = []; formatterSplitAndOr(core, start, end, depth).forEach(function (seg) { var last = segs[segs.length - 1]; if (last && seg.connector === 'AND' && betweenOfAnd(core, seg.start - 1) !== -1) { last.end = seg.end; return; }; segs.push({ start: seg.start, end: seg.end, connector: seg.connector }); }); return segs; }; function nullRejectedColumn(core, seg, upperRef) { var isTableColumn = function (span) { var ref = span ? plainColumnRef(core, span.start, span.end) : null; return !!ref && !!ref.qualifier && ref.qualifier.replace(/^\[|\]$/g, '').toUpperCase() === upperRef; }; var chain = parseNameChain(core, seg.start, seg.end); if (chain.parts.length >= 2 && chain.end < seg.end && isTableColumn({ start: seg.start, end: chain.end })) { var next = core[chain.end]; var after = core[chain.end + 1]; if (next.type === 'operator' && DATE_COMPARISON_OPERATORS.indexOf(next.value) !== -1) return true; if (isKwAny(next, ['LIKE', 'IN', 'BETWEEN'])) return true; if (isKw(next, 'NOT') && isKw(after, 'IN') && isKw(core[chain.end + 3], 'SELECT')) return false; if (isKw(next, 'NOT') && isKwAny(after, ['LIKE', 'IN', 'BETWEEN'])) return true; if (isKw(next, 'IS') && isKw(after, 'NOT') && isKw(core[chain.end + 2], 'NULL')) return true; }; var tail = addedOperandBefore(core, seg.end); var op = tail ? core[tail.start - 1] : null; return !!op && tail.start - 1 > seg.start && op.type === 'operator' && DATE_COMPARISON_OPERATORS.indexOf(op.value) !== -1 && isTableColumn(tail); }; function rule090(sql, tokens, ctx) { var findings = []; var core = ctx.getCoreTokens(); ctx.getSelectScopes().forEach(function (scope) { if (!scope.where) return; var segs = conditionSegments(core, scope.where.start, scope.where.end, scope.depth); if (segs.some(function (seg) { return seg.connector === 'OR'; })) return; scope.ons.forEach(function (on) { var entry = on.entries[on.entries.length - 1]; if (!entry || joinKindOf(core, entry) !== 'left') return; var upperRef = entryReference(entry).toUpperCase(); var isOwn = function (part) { return !!part && part.value.replace(/^\[|\]$/g, '').toUpperCase() === upperRef; }; var laterRefs = scope.from.filter(function (f) { return f.start > entry.start; }).map(function (f) { return entryReference(f).toUpperCase(); }); var keys = {}; conditionSegments(core, on.start, on.end, on.depth).forEach(function (seg) { if (seg.connector === 'OR') { keys = null; return; }; var cmp = columnComparison(core, seg.start, seg.end); if (!keys || !cmp || cmp.operator !== '=') return; [cmp.left, cmp.right].forEach(function (side) { var parts = side.parts; if (isOwn(parts[parts.length - 2])) keys[parts[parts.length - 1].value.toUpperCase()] = true; }); }); if (!keys) return; var nullSeg = segs.filter(function (seg) { var ref = seg.end - seg.start >= 5 && isKw(core[seg.end - 2], 'IS') && isKw(core[seg.end - 1], 'NULL') ? plainColumnRef(core, seg.start, seg.end - 2) : null; return !!ref && !!ref.qualifier && ref.qualifier.replace(/^\[|\]$/g, '').toUpperCase() === upperRef && keys[ref.column.toUpperCase()]; })[0]; if (!nullSeg) return; var nullName = quotable(spanText(sql, core, nullSeg), 'The IS NULL test'); var tableName = quotable(entryReference(entry), 'the joined table'); var message = nullName + ' keeps the rows with no match in ' + tableName + ', whose columns are all NULL there, so this condition is never true and the query returns no rows. Move it into the ON.'; var laterText = nullName + ' keeps the rows with no match in ' + tableName + ', where this condition is never true. It names a table joined after ' + tableName + ', which ' + tableName + '\'s ON cannot read. Remove it, or test it in that table\'s ON.'; if (laterText.split(/\s+/).length > 40) laterText = 'The IS NULL test keeps the rows with no match in the joined table, where this condition is never true. It names a table joined later, so remove it or test it in that table\'s ON.'; segs.forEach(function (seg, k) { if (seg === nullSeg || !nullRejectedColumn(core, seg, upperRef)) return; var namesLater = false; for (var q = seg.start; q + 2 < seg.end; q++) { if (isName(core[q]) && !isPunctToken(core[q - 1], '.') && isPunctToken(core[q + 1], '.') && laterRefs.indexOf(core[q].value.replace(/^\[|\]$/g, '').toUpperCase()) !== -1) namesLater = true; }; var fixes = null; if (!namesLater) { var removal = k > 0 ? { start: core[segs[k - 1].end - 1].end, end: core[seg.end - 1].end, text: '' } : { start: core[seg.start].start, end: core[segs[k + 1].start].start, text: '' }; var edits = appendConditionEdits(core, on.start, on.end, on.depth, spanText(sql, core, seg)).concat([removal]); edits.sort(function (a, b) { return a.start - b.start; }); fixes = [{ title: 'Move the condition into the ON', edits: edits, isSafe: false, isInFixAll: false }]; }; findings.push(['MCE090', 'warning', namesLater ? laterText : message, DOCS.JOIN, core[seg.start], core[seg.end - 1], fixes]); }); }); }); return findings; }; var WHOLE_NUMBER_FUNCTIONS = { COUNT: 1, COUNT_BIG: 1, LEN: 1, DATALENGTH: 1, CHARINDEX: 1, PATINDEX: 1, DATEDIFF: 1, DATEDIFF_BIG: 1, DATEPART: 1, YEAR: 1, MONTH: 1, DAY: 1, ROW_NUMBER: 1, RANK: 1, DENSE_RANK: 1, NTILE: 1, ASCII: 1, UNICODE: 1, ISNUMERIC: 1, ISDATE: 1 }; var WHOLE_NUMBER_PASS_FUNCTIONS = { SUM: 1, MIN: 1, MAX: 1, ABS: 1, ISNULL: 1, COALESCE: 1 }; var WHOLE_NUMBER_TYPES = { INT: 1, INTEGER: 1, BIGINT: 1, SMALLINT: 1, TINYINT: 1 }; function factorStartBefore(core, endIdx) { var last = core[endIdx - 1]; if (!last) return -1; var start; if (isPunctToken(last, ')')) { var open = openOfClose(core)[endIdx - 1]; if (open === undefined) return -1; var before = core[open - 1]; start = before && !before.quote && (before.type === 'identifier' || before.type === 'keyword') && !isKwAny(before, ['AND', 'OR', 'NOT', 'WHERE', 'ON', 'WHEN', 'THEN', 'ELSE', 'SELECT', 'IN', 'BETWEEN', 'HAVING', 'BY', 'IS', 'LIKE', 'RETURN']) ? open - 1 : open; } else if (last.type === 'number') { start = endIdx - 1; } else if (isName(last)) { start = endIdx - 1; while (start >= 2 && isPunctToken(core[start - 1], '.') && isName(core[start - 2])) start -= 2; } else { return -1; }; var sign = core[start - 1]; var beforeSign = core[start - 2]; if (sign && sign.type === 'operator' && (sign.value === '-' || sign.value === '+') && (!beforeSign || beforeSign.type === 'operator' || isPunctToken(beforeSign, '(') || isPunctToken(beforeSign, ',') || beforeSign.type === 'keyword')) start--; return start; }; function factorEndAfter(core, startIdx) { var t = core[startIdx]; if (!t) return -1; if (t.type === 'operator' && (t.value === '-' || t.value === '+')) return factorEndAfter(core, startIdx + 1); if (isPunctToken(t, '(')) { var close = matchParen(core, startIdx); return isPunctToken(core[close], ')') ? close + 1 : -1; }; if (t.type === 'number') return startIdx + 1; if ((isName(t) || t.type === 'keyword') && isPunctToken(core[startIdx + 1], '(')) { var callEnd = matchParen(core, startIdx + 1); return isPunctToken(core[callEnd], ')') ? callEnd + 1 : -1; }; if (!isName(t)) return -1; var chain = parseNameChain(core, startIdx, core.length); return chain.parts.every(isName) ? chain.end : -1; }; function isWholeNumberSpan(facts, span, depth) { var core = facts.core; if (span.end <= span.start || depth > 20) return false; var parts = []; var k = span.start; while (k < span.end) { var end = factorEndAfter(core, k); if (end === -1 || end > span.end) return false; parts.push({ start: k, end: end }); if (end === span.end) break; var op = core[end]; if (!op || op.type !== 'operator' || ['+', '-', '*', '%'].indexOf(op.value) === -1) return false; k = end + 1; }; if (parts.length > 1) return parts.every(function (p) { return isWholeNumberSpan(facts, p, depth + 1); }); var t = core[span.start]; if (t.type === 'operator') return isWholeNumberSpan(facts, { start: span.start + 1, end: span.end }, depth + 1); if (t.type === 'number') return span.end === span.start + 1 && /^\d+$/.test(t.value) && Number(t.value) <= 2147483647; if (isPunctToken(t, '(')) return isWholeNumberSpan(facts, { start: span.start + 1, end: span.end - 1 }, depth + 1); if (isPunctToken(core[span.start + 1], '(') && !t.quote) { var close = span.end - 1; var name = t.upper; if (WHOLE_NUMBER_FUNCTIONS[name]) return true; var args = addedArguments(core, span.start, close); if (WHOLE_NUMBER_PASS_FUNCTIONS[name]) { return args.length > 0 && args.every(function (a) { var from = isKw(core[a.start], 'DISTINCT') || isKw(core[a.start], 'ALL') ? a.start + 1 : a.start; return isWholeNumberSpan(facts, { start: from, end: a.end }, depth + 1); }); }; if (name === 'CAST' || name === 'TRY_CAST' || name === 'CONVERT' || name === 'TRY_CONVERT') { var typeSpan = castTypeSpan(core, span.start, close); return !!typeSpan && typeSpan.end === typeSpan.start + 1 && !!WHOLE_NUMBER_TYPES[core[typeSpan.start].upper]; }; return false; }; var ref = plainColumnRef(core, span.start, span.end); return !!ref && addedFieldKind(addedFieldAt(facts, ref.start, ref.qualifier, ref.column)) === 'number'; }; function dividendStart(core, slashIdx) { var start = factorStartBefore(core, slashIdx); var steps = 0; while (start > 0) { var op = core[start - 1]; if (!op || op.type !== 'operator' || ['*', '/', '%'].indexOf(op.value) === -1 || op.depth !== core[slashIdx].depth) break; if (++steps > 32) return -1; var earlier = factorStartBefore(core, start - 1); if (earlier === -1) break; start = earlier; }; return start; }; function isScaledDividend(core, span, levels) { if (levels <= 0) return false; var k = span.start; var before = null; while (k < span.end) { var end = factorEndAfter(core, k); if (end === -1 || end > span.end) return false; var after = end < span.end ? core[end] : null; if (after && (after.type !== 'operator' || ['*', '/', '%'].indexOf(after.value) === -1)) return false; var factor = core[k]; var isMultiplied = before ? before.value === '*' : !!after && after.value === '*'; if (isMultiplied && factor.type === 'number' && end === k + 1 && /^\d+$/.test(factor.value) && Number(factor.value) >= 100) return true; if ((!before || before.value === '*') && isPunctToken(factor, '(') && matchParen(core, k) === end - 1 && isScaledDividend(core, { start: k + 1, end: end - 1 }, levels - 1)) return true; before = after; k = end + 1; }; return false; }; function stringAggFinding(sql, facts, i, close) { var core = facts.core; var args = addedArguments(core, i, close); if (args.length !== 2) return null; var ref = plainColumnRef(core, args[0].start, args[0].end); var field = ref ? addedFieldAt(facts, ref.start, ref.qualifier, ref.column) : null; var kind = addedFieldKind(field); if (!kind || (kind === 'text' && !(Number(field.length) > 0 && Number(field.length) <= 4000))) return null; if (varcharAggregateAt(facts, i)) return null; var refText = spanText(sql, core, args[0]); var isVarchar = isVarcharField(field); var capped = isVarchar ? 'VARCHAR(8000)' : 'NVARCHAR(4000)'; var wide = isVarchar ? 'VARCHAR(MAX)' : 'NVARCHAR(MAX)'; return ['MCE091', 'warning', 'STRING_AGG of ' + refText + ' returns ' + capped + ', and a group whose list passes ' + (isVarchar ? '8,000' : '4,000') + ' characters fails the run. Cast it: STRING_AGG(CAST(' + refText + ' AS ' + wide + '), ...).', DOCS.AGG, core[i], core[i], [{ title: 'Cast to ' + wide, edits: [{ start: core[args[0].start].start, end: core[args[0].end - 1].end, text: 'CAST(' + refText + ' AS ' + wide + ')' }], isSafe: true, isInFixAll: false }]]; }; function rule091(sql, tokens, ctx) { var findings = []; var facts = addedRuleFacts(ctx); var core = facts.core; var reportedDivisions = {}; for (var i = 0; i < core.length; i++) { var t = core[i]; if (t.type === 'identifier' && !t.quote && t.upper === 'STRING_AGG' && isPunctToken(core[i + 1], '(') && !isPunctToken(core[i - 1], '.')) { var close = callClose(core, i); var aggFinding = close === -1 || overAfterCall(core, close) !== -1 ? null : stringAggFinding(sql, facts, i, close); if (aggFinding) findings.push(aggFinding); continue; }; if (t.type !== 'operator' || t.value !== '/') continue; var divisorEnd = factorEndAfter(core, i + 1); if (divisorEnd === -1) continue; var inner = { start: i + 1, end: divisorEnd }; while (inner.end - inner.start > 2 && isPunctToken(core[inner.start], '(') && matchParen(core, inner.start) === inner.end - 1) { inner = { start: inner.start + 1, end: inner.end - 1 }; }; if (numberLiteralEnd(core, inner.start) === inner.end) continue; var start = dividendStart(core, i); if (start === -1) continue; if (!isWholeNumberSpan(facts, { start: i + 1, end: divisorEnd }, 0) || !isWholeNumberSpan(facts, { start: start, end: i }, 0)) continue; if (isScaledDividend(core, { start: start, end: i }, 8)) continue; var divisionKey = facts.ownSelect[i] + ' ' + core.slice(start, divisorEnd).map(function (tok) { return tok.type === 'string' ? tok.value : tok.upper || tok.value; }).join(' '); if (reportedDivisions[divisionKey]) continue; reportedDivisions[divisionKey] = true; findings.push(['MCE091', 'warning', 'Both sides of this / are whole numbers, so SQL Server drops the fraction, and a rate below 1 comes out as 0. Write 1.0 * before the left side to keep it.', DOCS.NUM, t, t, [{ title: 'Multiply by 1.0', edits: [{ start: core[start].start, end: core[start].start, text: '1.0 * ' }], isSafe: false, isInFixAll: false }]]); }; return findings; }; function relationKeyName(name) { var upper = String(name).replace(/^\[|\]$/g, '').toUpperCase(); return upper === 'SUBSCRIBERKEY' ? 'SUBSCRIBERID' : upper; }; function isTextAgainstNumber(a, b) { if (!a || !b) return false; var isNumber = function (f) { return f.type === 'Number' || f.type === 'Decimal'; }; return (addedFieldKind(a) === 'text' && isNumber(b)) || (isNumber(a) && addedFieldKind(b) === 'text'); }; function rule092(sql, tokens, ctx) { var findings = []; var data = typeof window !== 'undefined' && window.SQLStudioSQL ? window.SQLStudioSQL : null; if (!data || !Array.isArray(data.dataViewJoins) || !Array.isArray(data.dataViews)) return findings; var viewByLower = {}; var fieldsOfView = {}; data.dataViews.forEach(function (dv) { viewByLower[dv.name.toLowerCase()] = dv.name; fieldsOfView[dv.name] = {}; (dv.fields || []).forEach(function (f) { fieldsOfView[dv.name][String(f.name).toUpperCase()] = true; }); }); var isFieldOf = function (view, column) { return !!fieldsOfView[view][String(column).replace(/^\[|\]$/g, '').toUpperCase()]; }; var core = ctx.getCoreTokens(); ctx.getSelectScopes().forEach(function (scope) { scope.ons.forEach(function (on) { var joined = on.entries[on.entries.length - 1]; var joinedView = viewOfEntry(joined, viewByLower); if (!joinedView) return; var joinedRef = entryReference(joined).toUpperCase(); var segs = conditionSegments(core, on.start, on.end, on.depth); if (segs.some(function (seg) { return seg.connector === 'OR'; })) return; var pairsByOther = {}; segs.forEach(function (seg) { var cmp = columnComparison(core, seg.start, seg.end); if (!cmp || cmp.operator !== '=') return; var sideOf = function (chain) { var parts = chain.parts; return { qualifier: parts[parts.length - 2].value.replace(/^\[|\]$/g, '').toUpperCase(), column: parts[parts.length - 1].value }; }; var left = sideOf(cmp.left); var right = sideOf(cmp.right); var own = left.qualifier === joinedRef ? left : (right.qualifier === joinedRef ? right : null); var other = own === left ? right : left; if (!own || other.qualifier === joinedRef) return; (pairsByOther[other.qualifier] = pairsByOther[other.qualifier] || []).push({ joined: own.column, other: other.column }); }); if (Object.keys(pairsByOther).length !== 1) return; Object.keys(pairsByOther).forEach(function (otherRef) { var otherEntry = on.entries.slice(0, -1).filter(function (e) { return entryReference(e).toUpperCase() === otherRef; })[0]; var otherView = otherEntry ? viewOfEntry(otherEntry, viewByLower) : null; if (!otherView || otherView === joinedView || (TRACKING_VIEWS.indexOf(otherView) !== -1 && TRACKING_VIEWS.indexOf(joinedView) !== -1)) return; var relations = data.dataViewJoins.filter(function (r) { return (r.a === joinedView && r.b === otherView) || (r.a === otherView && r.b === joinedView); }); if (!relations.length) return; var joinedTable = ctx.schema && ctx.schema.byName ? fieldCheckTable(ctx.schema.byName, joined) : null; var otherTable = ctx.schema && ctx.schema.byName ? fieldCheckTable(ctx.schema.byName, otherEntry) : null; var fieldOf = function (table, column) { return table && Array.isArray(table.fields) ? findFieldNamed(table, String(column).replace(/^\[|\]$/g, '')) : null; }; var pairs = pairsByOther[otherRef].filter(function (p) { return !isTextAgainstNumber(fieldOf(joinedTable, p.joined), fieldOf(otherTable, p.other)); }); if (!pairs.length || !pairs.every(function (p) { return isFieldOf(joinedView, p.joined) && isFieldOf(otherView, p.other); })) return; var isRelated = pairs.some(function (p) { return relations.some(function (r) { return r.on.some(function (pair) { var joinedKey = relationKeyName(r.a === joinedView ? pair[0] : pair[1]); var otherKey = relationKeyName(r.a === joinedView ? pair[1] : pair[0]); return relationKeyName(p.joined) === joinedKey && relationKeyName(p.other) === otherKey; }); }); }); if (isRelated) return; var relation = relations[0]; var keys = relation.on.map(function (pair) { return pair[0] === pair[1] ? pair[0] : relation.a + '.' + pair[0] + ' = ' + relation.b + '.' + pair[1]; }); var onToken = core[on.start - 1]; findings.push(['MCE092', 'warning', relation.a + ' and ' + relation.b + ' are related by ' + listWords(keys) + ', and this join compares other columns, so it matches the wrong rows or none. Join on the related columns.', DOCS.JOIN, onToken, core[on.end - 1]]); }); }); }); return findings; }; var CHANGING_CALLS = { NEWID: 1, RAND: 1, CRYPT_GEN_RANDOM: 1 }; function caseTokenKey(core, span) { if (span.end <= span.start) return null; var words = []; for (var k = span.start; k < span.end; k++) { var t = core[k]; if (!t.quote && CHANGING_CALLS[t.upper] && isPunctToken(core[k + 1], '(')) return null; words.push(t.type === 'string' ? t.value : t.upper || t.value); }; return words.join(' '); }; function rule093(sql, tokens, ctx) { var findings = []; var core = ctx.getCoreTokens(); var shapes = caseShapesOf(core); for (var i = 0; i < core.length; i++) { var shape = shapes[i]; if (!shape || shape.end === -1) continue; var isSimple = !isKw(core[i + 1], 'WHEN'); var whens = []; var openWhen = -1; shape.marks.forEach(function (k) { if (isKw(core[k], 'WHEN')) { openWhen = k; } else if (isKw(core[k], 'THEN') && openWhen !== -1) { whens.push({ whenIdx: openWhen, span: { start: openWhen + 1, end: k } }); openWhen = -1; }; }); if (!whens.length || (isSimple && caseTokenKey(core, { start: i + 1, end: whens[0].whenIdx }) === null)) continue; var seen = {}; whens.forEach(function (w) { var key = caseTokenKey(core, w.span); if (key === null || (isSimple && key === 'NULL')) return; if (!seen[key]) { seen[key] = true; return; }; var what = isSimple ? 'value' : 'condition'; findings.push(['MCE093', 'warning', 'This WHEN repeats an earlier ' + what + ' of the same CASE, so it never matches, and the rows meant for it fall to a later WHEN or the ELSE. Write the ' + what + ' you meant, or remove it.', DOCS.CASE, core[w.whenIdx], core[w.span.end - 1]]); }); }; return findings; }; var DEFAULT_FRAME_CALLS = { LAST_VALUE: 'last', SUM: 'running', COUNT: 'running', AVG: 'running', MIN: 'running', MAX: 'running' }; function frameInsertEdit(sql, core, orderIdx, closeIdx, frame) { var lastEnd = core[closeIdx - 1].end; var gap = sql.slice(lastEnd, core[closeIdx].start); var orderLineStart = lineStartOf(sql, core[orderIdx].start); var indent = sql.slice(orderLineStart, core[orderIdx].start); if (/^[ \t]*$/.test(indent) && /^[ \t]*\r?\n[ \t\r\n]*$/.test(gap)) { return { start: lastEnd, end: lastEnd, text: '\n' + indent + frame }; }; return { start: lastEnd, end: lastEnd, text: ' ' + frame }; }; function rule094(sql, tokens, ctx) { var findings = []; var core = ctx.getCoreTokens(); for (var i = 0; i < core.length; i++) { var t = core[i]; var kind = isName(t) && !t.quote ? DEFAULT_FRAME_CALLS[t.upper] : undefined; if (!kind || isPunctToken(core[i - 1], '.') || isKw(core[i + 2], 'DISTINCT')) continue; var close = callClose(core, i); if (close === -1) continue; var over = overAfterCall(core, close); if (over === -1 || !isPunctToken(core[over + 1], '(')) continue; var overClose = matchParen(core, over + 1); if (!isPunctToken(core[overClose], ')') || core[overClose].depth !== core[over + 1].depth) continue; var depth = core[over + 1].depth + 1; var orderIdx = -1; var hasFrame = false; for (var k = over + 2; k < overClose; k++) { if (core[k].depth !== depth) continue; if (isKw(core[k], 'ORDER') && isKw(core[k + 1], 'BY') && orderIdx === -1) orderIdx = k; var next = core[k + 1]; if ((isKw(core[k], 'ROWS') || isKw(core[k], 'RANGE')) && !isPunctToken(core[k - 1], '.') && !!next && (next.type === 'number' || isKw(next, 'BETWEEN') || isKw(next, 'UNBOUNDED') || isKw(next, 'CURRENT'))) hasFrame = true; }; if (orderIdx === -1 || hasFrame || isName(core[over + 2])) continue; if (kind === 'last') { findings.push(['MCE094', 'warning', 'With ORDER BY and no frame, the frame stops at this row or the last row that ties with it, so LAST_VALUE gives that row\'s value, not the window\'s last. Add ROWS BETWEEN UNBOUNDED PRECEDING AND UNBOUNDED FOLLOWING.', DOCS.WINDOW, t, core[over], [{ title: 'Frame the whole window', edits: formatLaidOutEdits(sql, [frameInsertEdit(sql, core, orderIdx, overClose, 'ROWS BETWEEN UNBOUNDED PRECEDING AND UNBOUNDED FOLLOWING')], ctx), isSafe: false, isInFixAll: false }]]); continue; }; findings.push(['MCE094', 'info', 'With ORDER BY and no frame, ' + t.upper + ' uses RANGE, which gives the rows that tie on the ORDER BY one shared value. Add ROWS BETWEEN UNBOUNDED PRECEDING AND CURRENT ROW to go row by row.', DOCS.WINDOW, t, core[over], [{ title: 'Frame the rows up to this one', edits: formatLaidOutEdits(sql, [frameInsertEdit(sql, core, orderIdx, overClose, 'ROWS BETWEEN UNBOUNDED PRECEDING AND CURRENT ROW')], ctx), isSafe: false, isInFixAll: false }]]); }; return findings; }; function rule095(sql, tokens, ctx) { var findings = []; var core = ctx.getCoreTokens(); var fromCommas = null; for (var p = 0; p < core.length; p++) { if (!isPunctToken(core[p], '(') || !isKw(core[p + 1], 'SELECT') || isKw(core[p - 1], 'AS')) continue; var close = matchParen(core, p); if (!isPunctToken(core[close], ')')) continue; var depth = core[p].depth + 1; var from = -1; var isOneSelect = true; for (var k = p + 1; k < close; k++) { if (core[k].depth !== depth) continue; if (isKw(core[k], 'FROM')) { if (from !== -1) isOneSelect = false; from = k; }; if (core[k].type === 'keyword' && SET_OPERATOR_WORDS[core[k].upper]) isOneSelect = false; }; if (!isOneSelect || from === -1 || close - from < 2) continue; var isTableAlone = true; for (var m = from + 1; m < close; m++) { if (!(isName(core[m]) || isPunctToken(core[m], '.') || isKw(core[m], 'AS'))) { isTableAlone = false; break; }; }; if (!isTableAlone) continue; var comma = -1; for (var a = close + 1; a < core.length; a++) { var y = core[a]; if ((y.type === 'keyword' && !isKw(y, 'AS')) || isPunctToken(y, ';')) break; if (!isPunctToken(y, ',') || y.depth > core[p].depth) continue; if (!fromCommas) fromCommas = fromListCommas(core); if (fromCommas[a]) break; comma = a; break; }; if (comma === -1) continue; var fix = { title: 'Add WHERE 1 = 1', edits: formatLaidOutEdits(sql, [{ start: core[close - 1].end, end: core[close - 1].end, text: ' WHERE 1 = 1' }], ctx), isSafe: true, isInFixAll: true }; findings.push(['MCE095', 'error', 'Marketing Cloud Engagement\'s query check reads this subquery\'s FROM past its ) and refuses the comma after it: Old style JOIN (ANSI JOINs) syntax is not allowed. Add WHERE 1 = 1 before the ).', DOCS.JOIN, core[from], core[close], [fix]]); }; return findings; }; function rule101(sql, tokens, ctx) { var findings = []; var core = ctx.getCoreTokens(); var byName = ctx.schema && ctx.schema.byName ? ctx.schema.byName : null; for (var k = 1; k < core.length; k++) { if (!isKw(core[k], 'TABLESAMPLE')) continue; var start = -1; for (var b = k - 1; b >= 0; b--) { if (core[b].depth !== core[k].depth) break; if (isKw(core[b], 'FROM') || isKw(core[b], 'JOIN') || isPunctToken(core[b], ',')) { start = b + 1; break; }; if (core[b].type === 'keyword' && !isKw(core[b], 'AS')) break; }; if (start === -1) continue; var chain = parseNameChain(core, start, k); var parts = chain.parts; if (!parts.length || parts.length > 2 || (parts.length === 2 && parts[0].upper !== 'ENT')) continue; var after = chain.end; if (isKw(core[after], 'AS')) after++; if (after < k && isName(core[after])) after++; if (after !== k) continue; var nameTok = parts[parts.length - 1]; var table = byName ? lookupTable(byName, sql.slice(parts[0].start, nameTok.end)) : null; var isDataView = table ? table.kind === 'dataview' : nameTok.value.charAt(0) === '_'; if (!isDataView) continue; var last = k + 1; if (core[last] && !core[last].quote && core[last].upper === 'SYSTEM') last++; if (!isPunctToken(core[last], '(')) continue; last = matchParen(core, last); if (!isPunctToken(core[last], ')')) continue; if (core[last + 1] && !core[last + 1].quote && core[last + 1].upper === 'REPEATABLE' && isPunctToken(core[last + 2], '(')) last = matchParen(core, last + 2); if (!isPunctToken(core[last], ')')) continue; findings.push(['MCE101', 'error', 'A System Data View is no local table, so SQL Server refuses TABLESAMPLE on it: The TABLESAMPLE clause can only be used with local tables. Remove it, or keep a random share with WHERE ABS(CHECKSUM(NEWID())) % 100 < 10.', DOCS.DV, core[k], core[last], [{ title: 'Remove TABLESAMPLE', edits: [{ start: removalStart(sql, tokens, core[k].start), end: core[last].end, text: '' }], isSafe: false, isInFixAll: false }]]); }; return findings; }; function registerAddedRules() { RULES.push(rule085, rule086, rule087, rule088, rule089, rule026More, rule090, rule091, rule092, rule093, rule094, rule095, rule101, rule102); }; registerAddedRules(); var QUOTED_TEXT_ARGUMENTS = { CHARINDEX: { at: [0] }, PATINDEX: { at: [0] }, REPLACE: { at: [1, 2] }, STUFF: { at: [3] }, TRANSLATE: { at: [1, 2] }, CONCAT: { from: 0 }, CONCAT_WS: { from: 0 }, LTRIM: { at: [1] }, RTRIM: { at: [1] }, STRING_AGG: { at: [1] }, STRING_SPLIT: { at: [1] }, FORMAT: { at: [1, 2] }, HASHBYTES: { at: [0] }, JSON_VALUE: { at: [1] }, JSON_QUERY: { at: [1] }, JSON_MODIFY: { at: [1, 2] }, ISNULL: { at: [1] }, COALESCE: { from: 1 }, NULLIF: { at: [1] }, IIF: { at: [1, 2] }, CHOOSE: { from: 1 } }; var QUOTED_VALUE_END_KEYWORDS = ['FROM', 'WHEN', 'ELSE', 'END']; function isPivotInList(core, inAt) { var k = inAt - 1; if (!isName(core[k])) return false; while (isPunctToken(core[k - 1], '.') && isName(core[k - 2])) k -= 2; if (!isKw(core[k - 1], 'FOR')) return false; var open = findEnclosingOpenParen(core, k - 1); return open > 0 && isKwAny(core[open - 1], ['PIVOT', 'UNPIVOT']); }; function quotedTextPlaceAt(core, q) { var t = core[q]; if (!t || t.type !== 'identifier' || t.quote !== 'double' || t.unterminated) return false; var prev = core[q - 1]; var next = core[q + 1]; if (isPunctToken(prev, '.') || isPunctToken(next, '.') || isPunctToken(next, '(')) return false; var isValueEnd = endsComparison(next) || isKwAny(next, QUOTED_VALUE_END_KEYWORDS); var isComparison = function (tok) { return !!tok && tok.type === 'operator' && DATE_COMPARISON_OPERATORS.indexOf(tok.value) !== -1; }; var isLiteralAt = function (k) { return !!core[k] && (core[k].type === 'string' || numberLiteralEnd(core, k) !== -1 || isKw(core[k], 'NULL')); }; if (isKw(prev, 'LIKE') || isKw(prev, 'ESCAPE')) return true; if (isKw(prev, 'ZONE') && isKw(core[q - 2], 'TIME') && isKw(core[q - 3], 'AT')) return true; if (isKw(next, 'FROM')) { var trimOpen = findEnclosingOpenParen(core, q); var trimName = trimOpen > 0 ? core[trimOpen - 1] : null; var isSide = !!prev && !prev.quote && ['LEADING', 'TRAILING', 'BOTH'].indexOf(prev.upper) !== -1 && trimOpen === q - 2; if (trimName && !trimName.quote && trimName.upper === 'TRIM' && (trimOpen === q - 1 || isSide)) return true; }; if (isKwAny(prev, ['THEN', 'ELSE']) && isKwAny(next, ['WHEN', 'ELSE', 'END'])) return true; if (isKw(prev, 'WHEN') && isKw(next, 'THEN')) return true; if (isComparison(prev) && isValueEnd) return !isLiteralAt(q - 2); if (isComparison(next) && (isKwAny(prev, COMPARISON_START_KEYWORDS) || isPunctToken(prev, '('))) return !isLiteralAt(q + 2); if (!(isPunctToken(prev, '(') || isPunctToken(prev, ',')) || !(isPunctToken(next, ',') || isPunctToken(next, ')'))) return false; var open = findEnclosingOpenParen(core, q); if (open < 1 || core[open].depth + 1 !== t.depth) return false; var owner = core[open - 1]; if (isKw(owner, 'IN')) return !isKw(core[open + 1], 'SELECT') && !isPivotInList(core, open - 1); if (!owner || owner.quote || (owner.type !== 'identifier' && owner.type !== 'keyword') || isPunctToken(core[open - 2], '.')) return false; var spec = QUOTED_TEXT_ARGUMENTS[owner.upper]; if (!spec) return false; var index = 0; for (var k = open + 1; k < q; k++) { if (core[k].depth === t.depth && isPunctToken(core[k], ',')) index++; }; return spec.at ? spec.at.indexOf(index) !== -1 : index >= spec.from; }; function quotedNameScope(facts, q, ctes) { var core = facts.core; var lower = String(core[q].value).toLowerCase(); var answer = { isNamed: false, isAllKnown: true }; var cteNames = {}; Object.keys(ctes).forEach(function (name) { cteNames[name] = true; }); function weigh(names) { if (!names || !names.every(function (n) { return typeof n === 'string' && n !== ''; })) { answer.isAllKnown = false; return; }; if (names.some(function (n) { return n.toLowerCase() === lower; })) answer.isNamed = true; }; addedScopesAt(facts, q).forEach(function (scope) { tableEntriesOf(core, scope.from).forEach(function (entry) { if (isPivotEntry(core, entry)) { weigh(null); return; }; if (entry.kind === 'derived') { weigh(entry.columns || null); return; }; var cteName = entry.nameParts === 1 ? String(entry.name || '').replace(/^\[|\]$/g, '').toUpperCase() : ''; if (cteName && ctes[cteName]) { weigh(ctes[cteName]); return; }; var table = fieldsTableOf(facts.byName, entry, cteNames); weigh(table ? table.fields.map(function (f) { return f.name; }) : null); }); }); return answer; }; function rule096(sql, tokens, ctx) { var findings = []; var facts = addedRuleFacts(ctx); var core = facts.core; var ctes = null; for (var q = 0; q < core.length; q++) { if (!quotedTextPlaceAt(core, q)) continue; if (!ctes) { ctes = {}; var parsed = ctx.getParsed(); ((parsed && parsed.ctes) || []).forEach(function (c) { ctes[String(c.name).replace(/^\[|\]$/g, '').toUpperCase()] = c.columns || null; }); }; var scope = quotedNameScope(facts, q, ctes); if (scope.isNamed) continue; var t = core[q]; var text = "'" + String(t.value).replace(/'/g, "''") + "'"; var message = scope.isAllKnown ? t.raw + ' in double quotes is a column name to SQL Server, and no table here has it: Invalid column name \'' + t.value + '\'. Write ' + text + ' in single quotes for a text.' : t.raw + ' in double quotes is a column name to SQL Server, not a text, so the query fails unless a table here has that column. Write ' + text + ' in single quotes for a text.'; findings.push(['MCE096', scope.isAllKnown ? 'error' : 'warning', message, DOCS.WHERE, t, t, [{ title: 'Write ' + text, edits: [{ start: t.start, end: t.end, text: text }], isSafe: false, isInFixAll: false }]]); }; return findings; }; var DIVISOR_GUARDS = { NULLIF: 1, ISNULL: 1, COALESCE: 1, IIF: 1, CASE: 1 }; var DIVISOR_PASS_CALLS = { CAST: 1, TRY_CAST: 1, CONVERT: 1, TRY_CONVERT: 1 }; function divisorFactorEnd(core, k) { var end = factorEndAfter(core, k); if (end === -1 || !isPunctToken(core[end - 1], ')')) return end; var over = overAfterCall(core, end - 1); if (over === -1 || !isPunctToken(core[over + 1], '(')) return end; var close = matchParen(core, over + 1); return isPunctToken(core[close], ')') ? close + 1 : -1; }; function isZeroableDivisor(facts, span, depth) { var core = facts.core; if (span.end <= span.start || depth > 20) return false; var parts = []; var k = span.start; while (k < span.end) { var end = divisorFactorEnd(core, k); if (end === -1 || end > span.end) return false; parts.push({ start: k, end: end }); if (end === span.end) break; var op = core[end]; if (!op || op.type !== 'operator' || ['+', '-', '*', '/', '%'].indexOf(op.value) === -1) return false; k = end + 1; }; if (parts.length > 1) { var isAllRead = parts.every(function (p) { return numberLiteralEnd(core, p.start) === p.end || isZeroableDivisor(facts, p, depth + 1); }); return isAllRead && parts.some(function (p) { return numberLiteralEnd(core, p.start) !== p.end; }); }; var t = core[span.start]; if (t.type === 'operator') return isZeroableDivisor(facts, { start: span.start + 1, end: span.end }, depth + 1); if (t.type === 'number') return false; if (isPunctToken(t, '(')) return !isKw(core[span.start + 1], 'SELECT') && isZeroableDivisor(facts, { start: span.start + 1, end: span.end - 1 }, depth + 1); if (isPunctToken(core[span.start + 1], '(') && !t.quote) { var close = matchParen(core, span.start + 1); var name = t.upper; var args = addedArguments(core, span.start, close); if (name === 'SUM') return true; if (name === 'COUNT' || name === 'COUNT_BIG') { var isStar = args.length === 1 && args[0].end === args[0].start + 1 && core[args[0].start].value === '*'; var scope = facts.scopeBySelect[facts.ownSelect[span.start]]; var isGroupCount = overAfterCall(core, close) === -1 && !!scope && !!scope.clauses.groupBy; if (isStar) return overAfterCall(core, close) === -1 && !!scope && !scope.clauses.groupBy; if (isGroupCount && args.length === 1) { var from = isKw(core[args[0].start], 'DISTINCT') || isKw(core[args[0].start], 'ALL') ? args[0].start + 1 : args[0].start; if (neverNullColumnEnd(core, from, facts.byName, addedScopesAt(facts, span.start)) === args[0].end - 1) return false; }; return true; }; if (close !== span.end - 1) return false; if (DIVISOR_PASS_CALLS[name] && args.length) { if (name === 'CONVERT' || name === 'TRY_CONVERT') return args.length >= 2 && isZeroableDivisor(facts, args[1], depth + 1); var asAt = -1; for (var a = args[0].start; a < args[0].end && asAt === -1; a++) { if (isKw(core[a], 'AS') && core[a].depth === core[span.start + 1].depth + 1) asAt = a; }; return asAt !== -1 && isZeroableDivisor(facts, { start: args[0].start, end: asAt }, depth + 1); }; return false; }; return !!plainColumnRef(core, span.start, span.end); }; function conditionalSpans(core) { var inside = new Array(core.length); var cases = 0; var frames = []; var iifs = 0; for (var i = 0; i < core.length; i++) { var t = core[i]; if (isKw(t, 'CASE')) cases++; else if (isKw(t, 'END') && cases > 0) cases--; if (isPunctToken(t, ')') && frames.length) { if (frames.pop()) iifs--; }; inside[i] = cases > 0 || iifs > 0; if (isPunctToken(t, '(')) { var caller = core[i - 1]; var isIif = !!caller && !caller.quote && caller.upper === 'IIF'; frames.push(isIif); if (isIif) iifs++; }; }; return inside; }; function divisionIsGuarded(facts, i, divisor, memo) { var core = facts.core; for (var d = divisor.start; d < divisor.end; d++) { if ((core[d].type === 'keyword' || core[d].type === 'identifier') && !core[d].quote && DIVISOR_GUARDS[core[d].upper]) return true; }; var owner = facts.ownSelect[i]; var scope = facts.scopeBySelect[owner]; if (!scope) return false; if (!Object.prototype.hasOwnProperty.call(memo.clauseKeys, owner)) { memo.clauseKeys[owner] = [scope.where, scope.having].filter(Boolean).map(function (clause) { return ' ' + divisionTokensKey(core, clause.start, clause.end) + ' '; }); }; var divisorKey = ' ' + divisionTokensKey(core, divisor.start, divisor.end) + ' '; return memo.clauseKeys[owner].some(function (clauseKey) { return clauseKey.indexOf(divisorKey) !== -1; }); }; function divisionTokensKey(core, from, to) { return core.slice(from, to).map(function (tok) { return tok.type === 'string' ? tok.value : (tok.upper || tok.value); }).join(' '); }; function rule097(sql, tokens, ctx) { var findings = []; var facts = addedRuleFacts(ctx); var core = facts.core; var reported = {}; var isJudgedGuarded = {}; var memo = null; for (var i = 1; i < core.length; i++) { var t = core[i]; if (t.type !== 'operator' || (t.value !== '/' && t.value !== '%')) continue; var divisorEnd = divisorFactorEnd(core, i + 1); if (divisorEnd === -1) continue; var divisor = { start: i + 1, end: divisorEnd }; var reportKey = facts.ownSelect[i] + ' ' + divisionTokensKey(core, divisor.start, divisor.end); if (reported[reportKey]) continue; memo = memo || { inside: conditionalSpans(core), clauseKeys: {} }; if (memo.inside[i]) continue; if (!Object.prototype.hasOwnProperty.call(isJudgedGuarded, reportKey)) { isJudgedGuarded[reportKey] = !isZeroableDivisor(facts, divisor, 0) || divisionIsGuarded(facts, i, divisor, memo); }; if (isJudgedGuarded[reportKey]) continue; reported[reportKey] = true; var inner = divisor; while (inner.end - inner.start > 2 && isPunctToken(core[inner.start], '(') && matchParen(core, inner.start) === inner.end - 1) { inner = { start: inner.start + 1, end: inner.end - 1 }; }; var text = 'NULLIF(' + spanText(sql, core, inner) + ', 0)'; findings.push(['MCE097', 'info', 'A divisor of 0 in any row fails the whole run, as Marketing Cloud Engagement runs with ANSI_WARNINGS on, and the run gives no reason. Divide by NULLIF(x, 0) to get NULL for that row instead.', DOCS.NUM, core[divisor.start], core[divisor.end - 1], [{ title: 'Divide by ' + (text.length > 60 ? 'NULLIF(x, 0)' : text), edits: [{ start: core[divisor.start].start, end: core[divisor.end - 1].end, text: text }], isSafe: true, isInFixAll: false }]]); }; return findings; }; function isVarcharField(field) { return !!field && /^varchar\b/i.test(String(field.sqlType || '')); }; function isNvarcharSeparator(core, span) { if (span.end <= span.start) return false; var depth = core[span.start].depth; var partStart = span.start; for (var k = span.start; k <= span.end; k++) { if (k < span.end && !(core[k].depth === depth && core[k].type === 'operator' && core[k].value === '+')) continue; if (k === partStart + 1 && core[partStart].type === 'string' && !core[partStart].unterminated) return true; partStart = k + 1; }; return false; }; function varcharAggregateAt(facts, i) { var core = facts.core; var t = core[i]; if (!t || t.type !== 'identifier' || t.quote || t.upper !== 'STRING_AGG' || isPunctToken(core[i - 1], '.')) return null; var close = callClose(core, i); if (close === -1) return null; var args = addedArguments(core, i, close); if (args.length !== 2 || !isNvarcharSeparator(core, args[1])) return null; var ref = plainColumnRef(core, args[0].start, args[0].end); return ref && isVarcharField(addedFieldAt(facts, ref.start, ref.qualifier, ref.column)) ? { value: args[0] } : null; }; function rule098(sql, tokens, ctx) { var findings = []; var facts = addedRuleFacts(ctx); var core = facts.core; for (var i = 0; i < core.length; i++) { var found = varcharAggregateAt(facts, i); if (!found) continue; var valueText = spanText(sql, core, found.value); findings.push(['MCE098', 'error', valueText + ' is varchar, and Marketing Cloud Engagement writes the separator as nvarchar text, which STRING_AGG refuses: Argument data type nvarchar is invalid for argument 2 of string_agg function. Cast the value to NVARCHAR(MAX).', DOCS.AGG, core[found.value.start], core[found.value.end - 1], [{ title: 'Cast to NVARCHAR(MAX)', edits: formatLaidOutEdits(sql, [{ start: core[found.value.start].start, end: core[found.value.end - 1].end, text: 'CAST(' + valueText + ' AS NVARCHAR(MAX))' }], ctx), isSafe: true, isInFixAll: true }]]); }; return findings; }; var HASH_HEX_LENGTHS = { MD2: 32, MD4: 32, MD5: 32, SHA: 40, SHA1: 40, SHA2_256: 64, SHA2_512: 128 }; function binaryHexLength(core, start, end) { while (end - start > 2 && isPunctToken(core[start], '(') && matchParen(core, start) === end - 1) { start++; end--; }; var t = core[start]; if (!t || end <= start) return null; if (end === start + 1) return t.type === 'variable' && t.upper === '@@DBTS' ? 16 : null; if (t.quote || !isPunctToken(core[start + 1], '(') || callClose(core, start) !== end - 1) return null; var args = addedArguments(core, start, end - 1); var hexOf = function (bytesToken) { var bytes = bytesToken && bytesToken.type === 'number' && /^\d+$/.test(bytesToken.value) ? Number(bytesToken.value) : 0; return bytes > 0 && bytes <= 4000 ? bytes * 2 : 'MAX'; }; if (t.upper === 'HASHBYTES') { var algorithm = args.length && args[0].end === args[0].start + 1 && core[args[0].start].type === 'string' ? String(stringLiteralContent(core[args[0].start])).toUpperCase() : ''; return HASH_HEX_LENGTHS[algorithm] || 128; }; if (t.upper === 'CRYPT_GEN_RANDOM') return args.length && args[0].end === args[0].start + 1 ? hexOf(core[args[0].start]) : 'MAX'; if (t.upper === 'COMPRESS') return 'MAX'; if (!DIVISOR_PASS_CALLS[t.upper]) return null; var typeSpan = castTypeSpan(core, start, end - 1); var typeName = typeSpan ? core[typeSpan.start] : null; if (!typeName || typeName.quote || (typeName.upper !== 'BINARY' && typeName.upper !== 'VARBINARY')) return null; var lengthToken = typeSpan.end - typeSpan.start === 4 && isPunctToken(core[typeSpan.start + 1], '(') ? core[typeSpan.start + 2] : null; return hexOf(lengthToken); }; function rule099(sql, tokens, ctx) { var findings = []; var core = ctx.getCoreTokens(); var nesting = ctx.getSelectNesting(); ctx.getSelectScopes().forEach(function (scope) { var place = nesting[scope.selectIdx]; if (!place || place.parent !== -1 || place.kind === 'cte' || !scope.selectList) return; scope.selectList.items.forEach(function (item) { var aliasExpr = item.kind === 'alias' ? aliasItemExpressionTokens(item) : null; if (aliasExpr && !aliasExpr.length) return; var first = tokenIndexAtOrAfter(core, aliasExpr ? aliasExpr[0].start : item.start); var last = aliasExpr ? tokenIndexAtOrAfter(core, aliasExpr[aliasExpr.length - 1].start) : tokenIndexAtOrAfter(core, item.end) - 1; if (last < first || !core[last]) return; var hex = binaryHexLength(core, first, last + 1); if (hex === null) return; findings.push(['MCE099', 'warning', 'This value is binary, and Marketing Cloud Engagement writes its bytes into the Text field as unreadable characters, which can stop the rows from loading in SQL Studio and Contact Builder. Write it as hex: CONVERT(VARCHAR(n), x, 2).', DOCS.CONV, core[first], core[last], [{ title: 'Write it as hex', edits: [{ start: core[first].start, end: core[first].start, text: 'CONVERT(VARCHAR(' + hex + '), ' }, { start: core[last].end, end: core[last].end, text: ', 2)' }], isSafe: false, isInFixAll: false }]]); }); }); return findings; }; var LINE_COMMENT_MESSAGES = { leading: 'Marketing Cloud Engagement\'s save loses everything after a line comment, so this one hides the whole query, and the save fails with "Cannot find table 0." Write it as a block comment.', before: 'Marketing Cloud Engagement\'s save loses everything after a line comment, so this one hides the rest of the query, and the save fails, as with "Invalid column name" or "could not be bound". Write it as a block comment.', closing: 'Marketing Cloud Engagement\'s save loses everything after a line comment, so a query that ends with one fails at Run with no reason, even with a line break after it. Write it as a block comment.' }; function rule100(sql, tokens) { var findings = []; if (sql.indexOf('--') === -1) return findings; var firstCode = -1; var lastCode = -1; tokens.forEach(function (t, index) { if (t.type === 'comment') return; if (firstCode === -1) firstCode = index; lastCode = index; }); if (firstCode === -1) return findings; tokens.forEach(function (t, index) { if (t.type !== 'comment' || t.raw.slice(0, 2) !== '--') return; var message = LINE_COMMENT_MESSAGES[index < firstCode ? 'leading' : (index < lastCode ? 'before' : 'closing')]; if (/\/\*|\*\//.test(t.raw)) { findings.push(['MCE100', 'error', message, DOCS.BASICS, t, t]); return; }; var carriageReturn = /\r$/.test(t.raw) ? '\r' : ''; findings.push(['MCE100', 'error', message, DOCS.BASICS, t, t, [ { title: 'Write it as a block comment', edits: [{ start: t.start, end: t.end, text: formatterRenderComment(t) + carriageReturn }], isSafe: true, isInFixAll: true } ]]); }); return findings; }; function registerEngineRules() { RULES.push(rule096, rule097, rule098, rule099, rule100); }; registerEngineRules(); var LINT_MAX_SQL_LENGTH = 200000; var SENT_SQL_MAX_LENGTH = 100000; function sentSqlLength(text) { var blanks = ' \t\n\r\f\v\u00a0'; var start = 0; var end = text.length; while (start < end && blanks.indexOf(text.charAt(start)) !== -1) start++; while (end > start && blanks.indexOf(text.charAt(end - 1)) !== -1) end--; return end - start; }; function tooLongFindings(sql, layout, isRunTarget, isSkipped, isSqlStudio) { var parts = []; if (isRunTarget || !layout || layout.statements.length < 2) { parts.push({ text: sql, statement: layout && layout.statements.length ? layout.statements[0] : null }); } else { layout.statements.forEach(function (statement) { parts.push({ text: sql.slice(statement.start, statement.end), statement: statement }); }); }; var rows = []; parts.forEach(function (part) { var length = sentSqlLength(part.text); if (length <= SENT_SQL_MAX_LENGTH) return; var start = 0; var end = 0; if (part.statement) { start = part.statement.codeStart; end = part.statement.firstEnd; } else { var first = withoutComments(tokenize(part.text))[0]; if (first) { start = first.start; end = first.end; }; }; var count = String(length).replace(/\B(?=(\d{3})+(?!\d))/g, ','); var message = isSqlStudio ? 'This query has ' + count + ' characters, and SQL Studio runs, validates and saves 100,000 at most. Shorten it, for example by moving a long list of values into a Data Extension to join.' : 'This query has ' + count + ' characters, more than the 100,000 that SQL Studio runs, validates and saves. No limit of Marketing Cloud Engagement\'s own is known.'; rows.push(['MCE999', isSqlStudio ? 'error' : 'info', message + (isSkipped ? ' Its other checks are skipped at this length.' : ''), '', start, end]); }); return rows; }; function lint(sql, context) { sql = sql == null ? '' : String(sql); context = context || {}; var options = context.options || {}; var disabled = {}; (options.disabled || []).forEach(function (id) { disabled[id] = true; }); if (sql.length > LINT_MAX_SQL_LENGTH) { var tooLarge = []; var largeIndex = { starts: buildLineIndex(sql) }; var largeLayout = null; try { largeLayout = statementLayout(sql); } catch (e) { }; if (!disabled.MCE999) { var longRows = tooLongFindings(sql, largeLayout, options.isRunTarget === true, true, options.isSqlStudio === true); if (!longRows.length) longRows = [['MCE999', 'info', 'This query is too large for live checks. Marketing Cloud Engagement still validates it when you click Run.', '', 0, 0]]; longRows.forEach(function (r) { tooLarge.push(makeFinding(r[0], r[1], r[2], r[3], r[4], r[5], sql, largeIndex)); }); }; if (!disabled.MCE002 && largeLayout) { statementCountFindings(largeLayout, options.isRunTarget === true).forEach(function (r) { tooLarge.push(makeFinding(r[0], r[1], r[2], r[3], r[4], r[5], sql, largeIndex)); }); }; return tooLarge; }; var semis = leadingSemicolons(sql); var source = sql; if (semis.length) { var chars = sql.split(''); semis.forEach(function (t) { for (var c = t.start; c < t.end; c++) chars[c] = ' '; }); sql = chars.join(''); }; var layout = null; try { if (mayHoldSeveralStatements(sql)) layout = statementLayout(sql); } catch (e) { layout = null; }; var isOneStatement = !!layout && layout.statements.length < 2 && !layout.goLines.length; if (!layout || (isOneStatement && (!layout.statements.length || layout.statements[0].start === 0))) { var single = lintStatement(sql, context); if (source.length > SENT_SQL_MAX_LENGTH && !disabled.MCE999) { var longIndex = { starts: buildLineIndex(source) }; tooLongFindings(source, layout, options.isRunTarget === true, false, options.isSqlStudio === true).forEach(function (r) { single.push(makeFinding(r[0], r[1], r[2], r[3], r[4], r[5], source, longIndex)); }); single.sort(function (a, b) { return a.start - b.start; }); }; if (source.indexOf(';') === -1 || disabled.MCE002) return single; var singleIndex = { starts: buildLineIndex(source) }; semicolonFindings(source, semis, true).forEach(function (r) { single.push(makeFinding(r[0], r[1], r[2], r[3], r[4], r[5], source, singleIndex, r[6])); }); return single.sort(function (a, b) { return a.start - b.start; }); }; var lineStarts = { starts: buildLineIndex(sql) }; var findings = []; layout.statements.forEach(function (statement) { lintStatement(sql.slice(statement.start, statement.end), context).forEach(function (f) { findings.push(shiftFinding(f, statement.start, lineStarts)); }); }); if (!disabled.MCE002) { statementCountFindings(layout, options.isRunTarget === true).forEach(function (r) { findings.push(makeFinding(r[0], r[1], r[2], r[3], r[4], r[5], sql, lineStarts)); }); semicolonFindings(source, semis, layout.statements.length < 2).forEach(function (r) { findings.push(makeFinding(r[0], r[1], r[2], r[3], r[4], r[5], source, lineStarts, r[6])); }); }; if (source.length > SENT_SQL_MAX_LENGTH && !disabled.MCE999) { tooLongFindings(source, layout, options.isRunTarget === true, false, options.isSqlStudio === true).forEach(function (r) { findings.push(makeFinding(r[0], r[1], r[2], r[3], r[4], r[5], source, lineStarts)); }); }; findings.sort(function (a, b) { return a.start - b.start; }); return findings; }; function leadingSemicolons(sql) { if (sql.indexOf(';') === -1) return []; var core = withoutComments(tokenize(sql)); var semis = []; for (var i = 0; i < core.length && core[i].type === 'punct' && core[i].value === ';'; i++) semis.push(core[i]); return semis.length === core.length ? [] : semis; }; function semicolonFindings(sql, leading, isOneStatement) { var groups = []; if (leading.length) groups.push(leading); if (isOneStatement && sql.indexOf(';') !== -1) { var leadingStarts = leading.map(function (t) { return t.start; }); var core = withoutComments(tokenize(sql)); var others = core.filter(function (t) { return t.type === 'punct' && t.value === ';' && leadingStarts.indexOf(t.start) === -1; }); if (others.length && others.length + leading.length < core.length) groups.push(others); }; return groups.map(function (semis) { var isBefore = semis === leading; return ['MCE002', 'error', 'Marketing Cloud Engagement\'s query check refuses a ; ' + (isBefore ? 'before the query' : 'in the query, also at its end') + ': \';\' is a reserved word and may not appear in your query. Remove it.', DOCS.SFREF, semis[0].start, semis[semis.length - 1].end, [{ title: 'Remove the ;', edits: semis.map(function (t) { return { start: t.start, end: t.end, text: '' }; }), isSafe: true, isInFixAll: true }]]; }); }; function statementCountFindings(layout, isRunTarget) { var count = layout.statements.length; if (count >= 2) { var second = layout.statements[1]; var message = isRunTarget ? 'This text holds ' + count + ' queries, and a Query Activity runs one. Select the query to run, then click Run.' : 'This tab holds ' + count + ' queries, and a Query Activity runs one. Select the one to run: Run sends all of the text otherwise.'; return [['MCE002', isRunTarget ? 'error' : 'info', message, DOCS.SFREF, second.codeStart, second.firstEnd]]; }; if (layout.goLines.length) { var go = layout.goLines[0]; return [['MCE002', 'error', 'A GO line is not part of a query. Remove it. Marketing Cloud Engagement Query Activities run a single SELECT.', DOCS.SFREF, go.start, go.end]]; }; return []; }; function shiftFinding(finding, offset, lineIndexCache) { var start = lineColAt(lineIndexCache.starts, finding.start + offset); var end = lineColAt(lineIndexCache.starts, finding.end + offset); var moved = Object.assign({}, finding, { start: finding.start + offset, end: finding.end + offset, line: start.line, col: start.col, endLine: end.line, endCol: end.col }); if (finding.fixes) { moved.fixes = finding.fixes.map(function (fix) { return Object.assign({}, fix, { edits: fix.edits.map(function (e) { return Object.assign({}, e, { start: e.start + offset, end: e.end + offset }); }) }); }); moved.fix = moved.fixes[0]; }; return moved; }; var STRICT_ERROR_OWNERS = { MCE001: true, MCE004: true, MCE019: true, MCE020: true, MCE031: true, MCE060: true, MCE085: true }; function lintStatement(sql, context) { var options = context.options || {}; var disabled = {}; (options.disabled || []).forEach(function (id) { disabled[id] = true; }); var tokens; try { tokens = tokenize(sql); } catch (e) { tokens = []; }; var lineIndexCache = { starts: buildLineIndex(sql) }; var ctx = { schema: context.schema, targetKey: context.targetKey, targetName: context.targetName, targetAction: context.targetAction, options: options, ctes: {} }; var cache = {}; ctx.getCoreTokens = function () { if (!cache.coreTokens) cache.coreTokens = withoutComments(tokens); return cache.coreTokens; }; ctx.getParsed = function () { if (!cache.parsedDone) { cache.parsedDone = true; try { cache.parsed = parseFromTokens(tokens, sql, ctx.schema); } catch (e) { cache.parsed = null; }; }; return cache.parsed; }; ctx.getExtracted = function () { if (!cache.extractedDone) { cache.extractedDone = true; var p = ctx.getParsed(); cache.extracted = p ? extractColumnsFromParsed(p, sql) : { columns: [], items: [], issues: [{ code: 'NO_SELECT', itemIndex: -1, start: 0, end: 0, message: 'Unable to parse query.' }] }; }; return cache.extracted; }; ctx.getSelects = function () { if (!cache.selectsDone) { cache.selectsDone = true; try { cache.selects = collectAllSelects(tokens, sql, ctx.schema); } catch (e) { cache.selects = []; }; }; return cache.selects; }; ctx.getUnknownColumns = function () { if (!cache.unknownColumnsDone) { cache.unknownColumnsDone = true; try { cache.unknownColumns = withoutMisreadTables(collectUnknownColumns(ctx), ctx); } catch (e) { cache.unknownColumns = []; }; }; return cache.unknownColumns; }; ctx.getSelectScopes = function () { if (!cache.selectScopesDone) { cache.selectScopesDone = true; try { var parsedQuery = ctx.getParsed(); cache.selectScopes = collectSelectScopes(sql, ctx.getCoreTokens(), ctx.schema, parsedQuery && parsedQuery._ctx ? parsedQuery._ctx.ctes : null); } catch (e) { cache.selectScopes = []; }; }; return cache.selectScopes; }; ctx.getSelectNesting = function () { if (!cache.selectNestingDone) { cache.selectNestingDone = true; try { cache.selectNesting = collectSelectNesting(ctx.getCoreTokens(), ctx.getSelectScopes()); } catch (e) { cache.selectNesting = {}; }; }; return cache.selectNesting; }; ctx.getScopeReadings = function () { if (!cache.scopeReadingsDone) { cache.scopeReadingsDone = true; try { cache.scopeReadings = collectScopeReadings(ctx.getCoreTokens(), ctx.getSelectScopes(), ctx.getSelectNesting()); } catch (e) { cache.scopeReadings = { index: { bySelect: {}, owner: [], isComplete: false }, list: [], bySelect: {} }; }; }; return cache.scopeReadings; }; ctx.getSendLogJoins = function () { if (!cache.sendLogJoinsDone) { cache.sendLogJoinsDone = true; try { cache.sendLogJoins = collectSendLogJoins(ctx.getCoreTokens(), ctx.getSelectScopes()); } catch (e) { cache.sendLogJoins = []; }; }; return cache.sendLogJoins; }; ctx.getStrict = function () { if (!cache.strictDone) { cache.strictDone = true; try { cache.strict = parseStrictFromTokens(tokens, sql); } catch (e) { cache.strict = { errors: [] }; }; }; return cache.strict; }; ctx.formatLayoutChars = 0; ctx.isFormatLaidOut = function () { if (!cache.formatLaidOutDone) { cache.formatLaidOutDone = true; cache.formatLaidOut = false; try { var body = sql.replace(/^\s+|\s+$/g, ''); if (body.length <= FORMAT_LAYOUT_CHARS) { ctx.formatLayoutChars += body.length; var laidOut = formatSql(body); cache.formatLaidOut = !laidOut.reason && !laidOut.skipped && laidOut.sql === body; }; } catch (e) { cache.formatLaidOut = false; }; }; return cache.formatLaidOut; }; var findings = []; RULES.forEach(function (ruleFn) { try { var raw = ruleFn(sql, tokens, ctx) || []; raw.forEach(function (r) { var id = r[0]; if (disabled[id]) return; findings.push(makeFinding(r[0], r[1], r[2], r[3], r[4], r[5], sql, lineIndexCache, r[6], r[7])); }); } catch (e) { }; }); if (ctx.refusedTypos) { findings.forEach(function (f) { var reason = Object.prototype.hasOwnProperty.call(ctx.refusedTypos, f.start) ? ctx.refusedTypos[f.start] : null; if (f.id === 'MCE062' && reason && reason !== 'ALIAS') { f.severity = 'error'; f.message = f.message + ' ' + REFUSED_TYPO_TAILS[reason]; } else if (f.id === 'MCE016' && reason === 'ALIAS' && f.severity !== 'error') { f.severity = 'error'; f.message = 'Alias "' + sql.slice(f.start, f.end) + '" cannot be used in GROUP BY, as aliases only work in ORDER BY. The query fails either way: no table has that column, or this GROUP BY leaves out a column of the list. Repeat the expression.'; }; }); }; var sfmc000Findings = findings.filter(function (f) { return f.id === 'MCE000'; }); if (sfmc000Findings.length) { findings = findings.filter(function (f) { if (f.id !== 'MCE060' || f.message !== 'This ( is never closed. Add the missing ) to close it.') return true; return !sfmc000Findings.some(function (g) { return g.start <= f.end && f.start <= g.end; }); }); }; var sfmc012Findings = findings.filter(function (f) { return f.id === 'MCE012'; }); if (sfmc012Findings.length) { var isNextTo = function (a, b) { var from = Math.min(a.end, b.start); var to = Math.max(a.end, b.start); return b.start >= a.start && String(sql).slice(from, to).trim() === ''; }; var droppedIds = []; findings.forEach(function (f) { if (f.id !== 'MCE000') return; sfmc012Findings.forEach(function (g) { if (!isNextTo(g, f)) return; droppedIds.push(/^Two commas in a row/.test(f.message) ? g : f); }); }); if (droppedIds.length) { findings = findings.filter(function (f) { return droppedIds.indexOf(f) === -1; }); }; }; var strictFindings = findings.filter(function (f) { return f.id === 'MCE000'; }); if (strictFindings.length) { findings = findings.filter(function (f) { if (f.id !== 'MCE010') return true; return !strictFindings.some(function (g) { return g.start >= f.start && g.start < f.end; }); }); }; if (strictFindings.length) { var ownerFindings = findings.filter(function (f) { return f.severity === 'error' && STRICT_ERROR_OWNERS[f.id]; }); var isOwnedPlace = function (g) { return ownerFindings.some(function (f) { if (g.start >= f.start && g.start <= f.end) return true; var fix = f.fixes && f.fixes[0]; return !!fix && fix.edits.some(function (e) { return e.start <= g.start && g.start <= e.end; }); }); }; if (ownerFindings.length) findings = findings.filter(function (f) { return f.id !== 'MCE000' || !isOwnedPlace(f); }); }; findings.sort(function (a, b) { return a.start - b.start; }); return findings; }; var FORMATTER_BUILTIN_FUNCTIONS = [ 'LEN', 'LEFT', 'RIGHT', 'SUBSTRING', 'CHARINDEX', 'PATINDEX', 'REPLACE', 'STUFF', 'CONCAT', 'CONCAT_WS', 'LTRIM', 'RTRIM', 'TRIM', 'LOWER', 'UPPER', 'REVERSE', 'SPACE', 'REPLICATE', 'STRING_SPLIT', 'TRANSLATE', 'QUOTENAME', 'STR', 'UNICODE', 'ASCII', 'CHAR', 'NCHAR', 'STRING_ESCAPE', 'SOUNDEX', 'DIFFERENCE', 'GETDATE', 'GETUTCDATE', 'SYSDATETIME', 'SYSUTCDATETIME', 'SYSDATETIMEOFFSET', 'DATEADD', 'DATEDIFF', 'DATEDIFF_BIG', 'DATEPART', 'DATENAME', 'DATETRUNC', 'DAY', 'MONTH', 'YEAR', 'EOMONTH', 'DATEFROMPARTS', 'DATETIMEFROMPARTS', 'DATETIME2FROMPARTS', 'TIMEFROMPARTS', 'ISDATE', 'TODATETIMEOFFSET', 'SWITCHOFFSET', 'CAST', 'CONVERT', 'TRY_CAST', 'TRY_CONVERT', 'PARSE', 'TRY_PARSE', 'FORMAT', 'IIF', 'CHOOSE', 'ISNUMERIC', 'ISNULL', 'COALESCE', 'NULLIF', 'COUNT', 'COUNT_BIG', 'SUM', 'AVG', 'MIN', 'MAX', 'STDEV', 'STDEVP', 'VAR', 'VARP', 'GROUPING', 'GROUPING_ID', 'CHECKSUM_AGG', 'STRING_AGG', 'ROW_NUMBER', 'RANK', 'DENSE_RANK', 'NTILE', 'LAG', 'LEAD', 'FIRST_VALUE', 'LAST_VALUE', 'PERCENT_RANK', 'CUME_DIST', 'PERCENTILE_CONT', 'PERCENTILE_DISC', 'ABS', 'CEILING', 'FLOOR', 'ROUND', 'POWER', 'SQRT', 'SQUARE', 'SIGN', 'EXP', 'LOG', 'LOG10', 'PI', 'RAND', 'ACOS', 'ASIN', 'ATAN', 'ATN2', 'COS', 'SIN', 'TAN', 'COT', 'DEGREES', 'RADIANS', 'CURRENT_TIMESTAMP', 'SYSTEM_USER', 'USER_NAME', 'DB_NAME', 'HOST_NAME', 'DATALENGTH', 'SERVERPROPERTY', 'NEWID', 'ISJSON', 'JSON_VALUE', 'JSON_QUERY', 'JSON_MODIFY', 'OPENJSON', 'JSON_OBJECT', 'JSON_ARRAY', 'JSON_PATH_EXISTS', 'HASHBYTES', 'CHECKSUM', 'BINARY_CHECKSUM' ]; var FORMATTER_TYPE_NAMES = { CHAR: 1, VARCHAR: 1, NCHAR: 1, NVARCHAR: 1, TEXT: 1, NTEXT: 1, INT: 1, INTEGER: 1, BIGINT: 1, SMALLINT: 1, TINYINT: 1, BIT: 1, DECIMAL: 1, NUMERIC: 1, FLOAT: 1, REAL: 1, MONEY: 1, SMALLMONEY: 1, DATE: 1, DATETIME: 1, DATETIME2: 1, SMALLDATETIME: 1, DATETIMEOFFSET: 1, TIME: 1, UNIQUEIDENTIFIER: 1, BINARY: 1, VARBINARY: 1, IMAGE: 1, XML: 1 }; var FORMATTER_DATE_FUNCS = { DATEADD: 1, DATEDIFF: 1, DATEDIFF_BIG: 1, DATEPART: 1, DATENAME: 1, DATETRUNC: 1 }; var FORMATTER_DATE_PART_MAP = { YY: 'YEAR', YYYY: 'YEAR', QQ: 'QUARTER', Q: 'QUARTER', MM: 'MONTH', M: 'MONTH', DY: 'DAYOFYEAR', Y: 'DAYOFYEAR', DD: 'DAY', D: 'DAY', WK: 'WEEK', WW: 'WEEK', DW: 'WEEKDAY', HH: 'HOUR', MI: 'MINUTE', N: 'MINUTE', SS: 'SECOND', S: 'SECOND', MS: 'MILLISECOND' }; var FORMATTER_CAST_NAMES = { CAST: 1, TRY_CAST: 1, PARSE: 1, TRY_PARSE: 1 }; var FORMATTER_CONVERT_NAMES = { CONVERT: 1, TRY_CONVERT: 1 }; var FORMATTER_FUNCTION_LIKE_KEYWORDS = { CONVERT: 1, COALESCE: 1, NULLIF: 1, TRY_CONVERT: 1, CONTAINS: 1, CONTAINSTABLE: 1, FREETEXT: 1, FREETEXTTABLE: 1, OPENQUERY: 1, OPENROWSET: 1, OPENDATASOURCE: 1, OPENXML: 1, INDEX: 1, LEFT: 1, RIGHT: 1 }; var FORMATTER_SETOPS = { UNION: 1, EXCEPT: 1, INTERSECT: 1 }; var FORMATTER_JOIN_QUALIFIERS = { INNER: 1, LEFT: 1, RIGHT: 1, FULL: 1, OUTER: 1, CROSS: 1 }; var FORMATTER_JOIN_HINTS = { LOOP: 1, HASH: 1, MERGE: 1, REMOTE: 1 }; function getFormatterFunctionSet() { var set = {}; FORMATTER_BUILTIN_FUNCTIONS.forEach(function (n) { set[n] = true; }); try { if (typeof window !== 'undefined' && window.SQLStudioSQL && Array.isArray(window.SQLStudioSQL.functions)) { window.SQLStudioSQL.functions.forEach(function (f) { if (f && f.name) set[String(f.name).toUpperCase()] = true; }); }; } catch (e) { /* ignore */ } return set; }; function fmtSpaces(n) { if (n <= 0) return ''; var s = '', i; for (i = 0; i < n; i++) s += ' '; return s; }; function nextTabStop(endCol) { return (Math.floor(endCol / 4) + 1) * 4; }; var FORMATTER_ALIGN_RATIO = 2.5; var FORMATTER_ALIGN_GAP = 16; var FORMATTER_LONG_LINE = 100; function formatterTokensValid(tokens) { var i, depth = 0; for (i = 0; i < tokens.length; i++) { if (tokens[i].unterminated) return false; }; for (i = 0; i < tokens.length; i++) { var t = tokens[i]; if (t.type === 'punct' && t.value === '(') { depth++; } else if (t.type === 'punct' && t.value === ')') { depth--; if (depth < 0) return false; }; }; return depth === 0; }; function formatterRenderComment(t) { if (t.raw.slice(0, 2) === '--') { var content = t.raw.slice(2).replace(/^\s+/, '').replace(/\s+$/, ''); return content ? ('/* ' + content + ' */') : '/* */'; }; return t.raw; }; function formatterExtractComments(tokens) { var coreTokens = []; var commentsBefore = []; var commentCols = []; var sameLine = []; var pendingComments = []; var pendingCols = []; var pendingSameLine = 0; var prev = null; tokens.forEach(function (t) { if (t.type === 'comment') { var onSameLine = prev !== null && t.line === prev.line + String(prev.raw || '').split('\n').length - 1; if (onSameLine && pendingSameLine === pendingComments.length) pendingSameLine++; pendingComments.push(formatterRenderComment(t)); pendingCols.push(t.col); } else { coreTokens.push(t); commentsBefore.push(pendingComments); commentCols.push(pendingCols); sameLine.push(pendingSameLine); pendingComments = []; pendingCols = []; pendingSameLine = 0; }; prev = t; }); return { core: coreTokens, commentsBefore: commentsBefore, commentCols: commentCols, sameLine: sameLine, trailing: pendingComments, trailingSameLine: pendingSameLine }; }; function commentLines(ctx, idx, indent) { var arr = ctx.commentsBefore[idx]; if (!arr || !arr.length) return []; if (!ctx.emitted) ctx.emitted = {}; if (ctx.emitted[idx]) return []; ctx.emitted[idx] = true; return arr.map(function (c) { return fmtSpaces(indent) + c; }); }; function commentLinesRange(ctx, a, b, indent) { var lines = []; for (var i = Math.max(0, a); i < b; i++) lines = lines.concat(commentLines(ctx, i, indent)); return lines; }; function takeInlineComments(ctx, a, b) { var taken = []; if (!ctx.emitted) ctx.emitted = {}; for (var i = Math.max(0, a); i < b; i++) { var arr = ctx.commentsBefore[i]; if (!arr || !arr.length || ctx.emitted[i]) continue; ctx.emitted[i] = true; taken = taken.concat(arr); }; return taken.join(' '); }; function formatterTakeSameLineComments(ctx, idx) { var atEnd = idx >= ctx.core.length; if (!atEnd && ctx.emitted && ctx.emitted[idx]) return ''; var all = atEnd ? ctx.trailing : ctx.commentsBefore[idx]; var count = atEnd ? ctx.trailingSameLine : ctx.commentsSameLine[idx]; if (!all || !all.length || !count) return ''; var taken = all.slice(0, count); if (atEnd) { ctx.trailing = all.slice(count); ctx.trailingSameLine = 0; } else { ctx.commentsBefore[idx] = all.slice(count); if (ctx.commentCols && ctx.commentCols[idx]) ctx.commentCols[idx] = ctx.commentCols[idx].slice(count); ctx.commentsSameLine[idx] = 0; }; return taken.join(' '); }; function formatterTakeCommaTrail(ctx, commaIdx) { var core = ctx.core; var comma = core[commaIdx]; var before = core[commaIdx - 1]; var next = core[commaIdx + 1]; if (!comma || !before || !next || !isPunctToken(comma, ',')) return ''; var beforeEndLine = before.line + String(before.raw || '').split('\n').length - 1; if (beforeEndLine !== comma.line || next.line <= comma.line) return ''; return formatterTakeSameLineComments(ctx, commaIdx + 1); }; function formatterTakeListNotes(ctx, idx, indent) { var all = ctx.commentsBefore[idx]; var cols = ctx.commentCols && ctx.commentCols[idx]; var clause = ctx.core[idx]; if (!all || !all.length || !cols || !clause || (ctx.emitted && ctx.emitted[idx])) return []; var count = 0; while (count < all.length && cols[count] > clause.col) count++; if (!count) return []; ctx.commentsBefore[idx] = all.slice(count); ctx.commentCols[idx] = cols.slice(count); ctx.commentsSameLine[idx] = 0; return all.slice(0, count).map(function (c) { return fmtSpaces(indent + 4) + c; }); }; function formatterTakeRefComments(ctx, from, to) { var parts = []; var inside = takeInlineComments(ctx, from, to); if (inside) parts.push(inside); var after = formatterTakeSameLineComments(ctx, to); if (after) parts.push(after); return parts.join(' '); }; function formatterConditionGroup(core, start, end) { if (start >= end) return null; var isNot = isKw(core[start], 'NOT'); var open = isNot ? start + 1 : start; if (open >= end || !isPunctToken(core[open], '(') || isKw(core[open + 1], 'SELECT')) return null; var close = formatterMatchParenIdx(core, open); if (close !== end - 1) return null; var inner = formatterSplitAndOr(core, open + 1, close, core[open].depth + 1); if (inner.length < 2) return null; return { open: open, close: close, isNot: isNot, inner: inner }; }; function formatterGroupBreaks(groups, ci) { var group = groups[ci]; if (!group) return false; if (group.inner.length >= 3) return true; return groups.length >= 2 && groups.every(function (g) { return !!g; }); }; function formatterBetweenSplit(core, start, end) { if (start >= end) return null; var depth = core[start].depth; var caseLevel = 0; for (var i = start + 1; i < end; i++) { var t = core[i]; if (isKw(t, 'CASE')) { caseLevel++; } else if (isKw(t, 'END')) { if (caseLevel > 0) caseLevel--; } else if (caseLevel === 0 && t.depth === depth && isKw(t, 'BETWEEN')) { for (var k = i + 1; k < end; k++) { if (core[k].depth === depth && isKw(core[k], 'AND') && isBetweenAnd(core, k)) return { at: isKw(core[i - 1], 'NOT') && i - 1 > start ? i - 1 : i, and: k }; }; return null; }; }; return null; }; function formatterConditionNeedsLines(ctx, conds, indent) { if (conds.length > 1) return true; var c = conds[0]; if (!c || c.start >= c.end) return false; if (formatterExprNeedsBreak(ctx.core, c.start, c.end, ctx.core[c.start].depth)) return true; if (formatterGroupBreaks([formatterConditionGroup(ctx.core, c.start, c.end)], 0)) return true; return indent + formatterJoinFlat(ctx.core, c.start, c.end, ctx).length > FORMATTER_LONG_LINE && !!formatterBetweenSplit(ctx.core, c.start, c.end); }; function formatterRenderConditionLines(ctx, conds, indent, firstPrefix) { var lines = []; var groups = conds.map(function (c) { return formatterConditionGroup(ctx.core, c.start, c.end); }); conds.forEach(function (c, ci) { var prefix = ci === 0 ? firstPrefix : (c.connector + ' '); var takeTrail = function () { return ci < conds.length - 1 ? takeInlineComments(ctx, conds[ci + 1].start - 1, conds[ci + 1].start) : formatterTakeSameLineComments(ctx, c.end); }; if (formatterGroupBreaks(groups, ci)) { var group = groups[ci]; var groupLead = takeInlineComments(ctx, c.start, group.open + 1); var opener = (groupLead ? groupLead + ' ' : '') + (group.isNot ? 'NOT (' : '('); if (ci > 0 && !group.isNot) { lines.push(fmtSpaces(indent) + c.connector); lines.push(fmtSpaces(indent) + opener); } else { lines.push(fmtSpaces(indent) + prefix + opener); }; lines = lines.concat(formatterRenderConditionLines(ctx, group.inner, indent + 4, '')); var closeNotes = takeInlineComments(ctx, group.close, group.close + 1); if (closeNotes) lines[lines.length - 1] += ' ' + closeNotes; lines.push(fmtSpaces(indent) + ')'); var groupTrail = takeTrail(); if (groupTrail) lines[lines.length - 1] += ' ' + groupTrail; return; }; var segLines = formatterRenderExpr(ctx, c.start, c.end, indent); var lead = takeInlineComments(ctx, c.start, c.end); var trail = takeTrail(); var split = segLines.length === 1 && (fmtSpaces(indent) + prefix + segLines[0]).length > FORMATTER_LONG_LINE ? formatterBetweenSplit(ctx.core, c.start, c.end) : null; if (split) { lines.push(fmtSpaces(indent) + prefix + (lead ? lead + ' ' : '') + formatterJoinFlat(ctx.core, c.start, split.at, ctx)); lines.push(fmtSpaces(indent + 4) + formatterJoinFlat(ctx.core, split.at, split.and, ctx)); lines.push(fmtSpaces(indent + 4) + formatterJoinFlat(ctx.core, split.and, c.end, ctx)); } else { lines.push(fmtSpaces(indent) + prefix + (lead ? lead + ' ' : '') + segLines[0]); for (var q = 1; q < segLines.length; q++) lines.push(segLines[q]); }; if (trail) lines[lines.length - 1] += ' ' + trail; }); return lines; }; function formatterMatchParenIdx(coreTokens, i) { var d = 0, j; for (j = i; j < coreTokens.length; j++) { if (coreTokens[j].type === 'punct' && coreTokens[j].value === '(') { d++; } else if (coreTokens[j].type === 'punct' && coreTokens[j].value === ')') { d--; if (d === 0) return j; }; }; return coreTokens.length - 1; }; var FORMATTER_NAME_START_KEYWORDS = ['SELECT', 'DISTINCT', 'BY', 'WHERE', 'AND', 'OR', 'NOT', 'ON', 'WHEN', 'THEN', 'ELSE', 'HAVING', 'CASE', 'BETWEEN', 'LIKE', 'FOR', 'PERCENT', 'TIES', 'FROM', 'JOIN']; var FORMATTER_ALIAS_END_KEYWORDS = ['FROM', 'WHERE', 'GROUP', 'ORDER', 'HAVING', 'UNION', 'EXCEPT', 'INTERSECT', 'JOIN', 'INNER', 'LEFT', 'RIGHT', 'FULL', 'CROSS', 'OUTER', 'ON', 'WITH', 'OPTION', 'FOR', 'PIVOT', 'UNPIVOT', 'TABLESAMPLE']; function parenOpenBefore(core, closeIdx) { var close = core[closeIdx]; if (!isPunctToken(close, ')')) return -1; for (var j = closeIdx - 1; j >= 0; j--) { if (isPunctToken(core[j], '(') && core[j].depth === close.depth) return j; }; return -1; }; function formatterIsKeywordName(core, n) { var w = core[n]; if (!w || w.type !== 'keyword' || w.quote) return false; var prev = core[n - 1]; var next = core[n + 1]; var isDotted = isPunctToken(prev, '.') || isPunctToken(next, '.'); var isAfterAs = isKw(prev, 'AS'); if (RESERVED_SET[w.upper]) { var isAliasEnd = !next || isPunctToken(next, ',') || isPunctToken(next, ')') || isPunctToken(next, ';') || (next.type === 'keyword' && FORMATTER_ALIAS_END_KEYWORDS.indexOf(next.upper) !== -1); return isDotted || (isAfterAs && isAliasEnd) || isOrderByItemWord(core, n); }; if (isDotted || isAfterAs) return true; var isAfterTop = !!prev && ((prev.type === 'number' && isKw(core[n - 2], 'TOP')) || (isPunctToken(prev, ')') && isKw(core[parenOpenBefore(core, n - 1) - 1], 'TOP'))); var isValueStart = !!prev && (isPunctToken(prev, '(') || isPunctToken(prev, ',') || prev.type === 'operator' || isAfterTop || (prev.type === 'keyword' && FORMATTER_NAME_START_KEYWORDS.indexOf(prev.upper) !== -1)); if (w.upper === 'PARTITION' && isKw(next, 'BY')) return false; if (w.upper === 'AT' && isKw(next, 'TIME')) return false; if (w.upper === 'WITHIN' && isKw(next, 'GROUP')) return false; if (w.upper === 'UNBOUNDED' && (isKw(next, 'PRECEDING') || isKw(next, 'FOLLOWING'))) return false; if ((w.upper === 'ROWS' || w.upper === 'RANGE') && next && (next.type === 'number' || isKw(next, 'BETWEEN') || isKw(next, 'UNBOUNDED') || isKw(next, 'CURRENT'))) return false; if (isValueStart) return true; var isCountWord = ['ROW', 'ROWS', 'PRECEDING', 'FOLLOWING'].indexOf(w.upper) !== -1; var isAfterValue = !!prev && (isPunctToken(prev, ')') || prev.type === 'identifier' || prev.type === 'string' || (prev.type === 'number' && !isCountWord) || isKw(prev, 'END') || isKw(prev, 'NULL') || (isUnreservedKeyword(prev) && formatterIsKeywordName(core, n - 1))); var isNameEnd = !next || isPunctToken(next, ',') || isPunctToken(next, ')') || isPunctToken(next, ';') || (next.type === 'keyword' && FORMATTER_ALIAS_END_KEYWORDS.indexOf(next.upper) !== -1); var isFetchOnly = w.upper === 'ONLY' && !!prev && (prev.upper === 'ROW' || prev.upper === 'ROWS'); if (!isAfterValue || !isNameEnd || w.upper === 'APPLY' || isFetchOnly) return false; if (isCountWord && isPunctToken(prev, ')')) { var countOwner = core[parenOpenBefore(core, n - 1) - 1]; if (countOwner && ['OFFSET', 'NEXT', 'FIRST'].indexOf(String(countOwner.upper)) !== -1) return false; }; return !isInOverParens(core, n); }; function isInOverParens(core, n) { var depth = core[n].depth; if (!depth) return false; for (var j = n - 1; j >= 0; j--) { if (core[j].depth < depth) return isPunctToken(core[j], '(') && isKw(core[j - 1], 'OVER'); }; return false; }; function formatterComputeOverrides(coreTokens) { var overrides = new Array(coreTokens.length); var typeAt = {}; for (var i = 0; i < coreTokens.length; i++) { var t = coreTokens[i]; if (!((t.type === 'identifier' || t.type === 'keyword') && !t.quote)) continue; var upper = t.upper; var next = coreTokens[i + 1]; if (FORMATTER_DATE_FUNCS[upper] && next && next.type === 'punct' && next.value === '(') { var argTok = coreTokens[i + 2]; if (argTok && argTok.type === 'identifier' && !argTok.quote) { overrides[i + 2] = FORMATTER_DATE_PART_MAP[argTok.upper] || argTok.upper; }; }; if (FORMATTER_CAST_NAMES[upper] && next && next.type === 'punct' && next.value === '(') { var closeIdx = formatterMatchParenIdx(coreTokens, i + 1); for (var k = i + 2; k < closeIdx; k++) { if (coreTokens[k].type === 'keyword' && coreTokens[k].upper === 'AS' && coreTokens[k].depth === next.depth + 1) { typeAt[k + 1] = true; var typeTok = coreTokens[k + 1]; if (typeTok && typeTok.type === 'identifier' && !typeTok.quote && FORMATTER_TYPE_NAMES[typeTok.upper]) { overrides[k + 1] = typeTok.upper; }; break; }; }; }; if ((upper === 'ROLLUP' || upper === 'CUBE') && next && next.type === 'punct' && next.value === '(' && !(coreTokens[i - 1] && coreTokens[i - 1].type === 'punct' && coreTokens[i - 1].value === '.')) { overrides[i] = upper; }; if ((upper === 'ROLLUP' || upper === 'CUBE') && t.type === 'identifier' && !t.quote && isKw(coreTokens[i - 1], 'WITH')) { overrides[i] = upper; }; if (upper === 'GROUPING' && next && next.type === 'identifier' && !next.quote && next.upper === 'SETS' && coreTokens[i + 2] && coreTokens[i + 2].type === 'punct' && coreTokens[i + 2].value === '(') { overrides[i] = 'GROUPING'; overrides[i + 1] = 'SETS'; }; if (FORMATTER_CONVERT_NAMES[upper] && next && next.type === 'punct' && next.value === '(') { typeAt[i + 2] = true; var typeTok2 = coreTokens[i + 2]; if (typeTok2 && typeTok2.type === 'identifier' && !typeTok2.quote && FORMATTER_TYPE_NAMES[typeTok2.upper]) { overrides[i + 2] = typeTok2.upper; }; }; }; for (var n = 0; n < coreTokens.length; n++) { if (overrides[n] === undefined && !typeAt[n] && formatterIsKeywordName(coreTokens, n)) overrides[n] = coreTokens[n].raw; }; return overrides; }; function formatterTokenText(t, idx, overrides, funcSet, coreTokens) { if (overrides[idx] !== undefined) return overrides[idx]; if (t.type === 'keyword') return t.upper; if (t.type === 'operator') return t.value === '<>' ? '!=' : t.value; if (t.type === 'punct') return t.value; if (t.type === 'identifier') { if (!t.quote) { var next = coreTokens[idx + 1]; if (next && next.type === 'punct' && next.value === '(' && funcSet[t.upper]) return t.upper; }; return t.raw; }; return t.raw; }; function formatterIsOperandEnd(t) { if (!t) return false; if (t.type === 'identifier' || t.type === 'number' || t.type === 'string' || t.type === 'variable' || t.type === 'temp') return true; if (t.type === 'punct' && t.value === ')') return true; return false; }; function formatterNoSpaceAfter(prev) { return !!(prev.type === 'punct' && (prev.value === '(' || prev.value === '.')); }; function formatterNoSpaceBefore(prev, cur) { if (cur.type === 'punct' && (cur.value === ')' || cur.value === ',' || cur.value === ';' || cur.value === '.')) return true; if (cur.type === 'punct' && cur.value === '(') { if (prev.type === 'identifier') return true; if (prev.type === 'keyword' && FORMATTER_FUNCTION_LIKE_KEYWORDS[prev.upper]) return true; }; return false; }; function formatterSpaceBefore(coreTokens, idx) { if (idx <= 0) return false; var prev = coreTokens[idx - 1], cur = coreTokens[idx]; if (formatterNoSpaceAfter(prev)) return false; if (cur.type === 'punct' && cur.value === '(' && prev.type === 'identifier' && !prev.quote && prev.upper === 'SETS' && idx >= 2 && coreTokens[idx - 2].type === 'identifier' && !coreTokens[idx - 2].quote && coreTokens[idx - 2].upper === 'GROUPING') return true; if (cur.type === 'punct' && cur.value === '(' && prev.type === 'identifier' && !prev.quote && !(idx >= 2 && coreTokens[idx - 2].type === 'punct' && coreTokens[idx - 2].value === '.') && (prev.upper === 'OFFSET' || ((prev.upper === 'NEXT' || prev.upper === 'FIRST') && idx >= 2 && coreTokens[idx - 2].type === 'keyword' && coreTokens[idx - 2].upper === 'FETCH'))) return true; if (cur.type === 'unknown' && cur.value === ':') return false; if (prev.type === 'unknown' && prev.value === ':' && idx >= 2 && coreTokens[idx - 2].type === 'unknown' && coreTokens[idx - 2].value === ':') return false; if (formatterNoSpaceBefore(prev, cur)) return false; if (prev.type === 'operator' && (prev.value === '+' || prev.value === '-' || prev.value === '~')) { if (!formatterIsOperandEnd(coreTokens[idx - 2]) && !formatterIsKeywordName(coreTokens, idx - 2)) { return prev.value === '-' && cur.type === 'operator' && cur.value === '-'; }; }; return true; }; function formatterJoinFlat(coreTokens, start, end, ctx) { var out = ''; for (var i = start; i < end; i++) { var text = formatterTokenText(coreTokens[i], i, ctx.overrides, ctx.funcSet, coreTokens); if (i > start && formatterSpaceBefore(coreTokens, i)) out += ' '; out += text; }; return out; }; function formatterFindClauseBoundary(coreTokens, start, limit, baseDepth) { var i = start; while (i < limit) { var t = coreTokens[i]; if (t.depth === baseDepth && t.type === 'punct' && t.value === ';') return i; if (t.depth === baseDepth && formatterIsPagingOffset(coreTokens, i)) return i; if (t.depth === baseDepth && t.type === 'keyword') { var u = t.upper; if (u === 'WHERE' || u === 'HAVING' || FORMATTER_SETOPS[u]) return i; var isFunctionCall = !!(coreTokens[i + 1] && coreTokens[i + 1].type === 'punct' && coreTokens[i + 1].value === '('); if (u === 'JOIN' || u === 'APPLY' || (FORMATTER_JOIN_QUALIFIERS[u] && !isFunctionCall)) return i; if (u === 'FOR' || u === 'OPTION') return i; if ((u === 'GROUP' || u === 'ORDER') && coreTokens[i + 1] && coreTokens[i + 1].type === 'keyword' && coreTokens[i + 1].upper === 'BY') return i; }; i++; }; return limit; }; function formatterSplitAndOr(coreTokens, start, end, baseDepth) { var segs = []; var curStart = start; var connector = null; var caseLevel = 0; for (var i = start; i < end; i++) { var t = coreTokens[i]; if (t.type === 'keyword' && t.upper === 'CASE') { caseLevel++; continue; }; if (t.type === 'keyword' && t.upper === 'END') { if (caseLevel > 0) caseLevel--; continue; }; if (caseLevel === 0 && t.depth === baseDepth && t.type === 'keyword' && t.upper === 'AND' && isBetweenAnd(coreTokens, i)) continue; if (caseLevel === 0 && t.depth === baseDepth && t.type === 'keyword' && (t.upper === 'AND' || t.upper === 'OR')) { segs.push({ start: curStart, end: i, connector: connector }); connector = t.upper; curStart = i + 1; }; }; segs.push({ start: curStart, end: end, connector: connector }); return segs; }; function formatterExprNeedsBreak(coreTokens, start, end, baseDepth) { var caseLevel = 0; for (var i = start; i < end; i++) { var t = coreTokens[i]; if (t.type === 'keyword' && t.upper === 'CASE') { if (caseLevel === 0 && t.depth === baseDepth) return true; caseLevel++; continue; }; if (t.type === 'keyword' && t.upper === 'END') { if (caseLevel > 0) caseLevel--; continue; }; if (caseLevel === 0 && t.depth === baseDepth && t.type === 'punct' && t.value === '(' && coreTokens[i + 1] && coreTokens[i + 1].type === 'keyword' && coreTokens[i + 1].upper === 'SELECT') return true; if (caseLevel === 0 && t.depth === baseDepth && isKw(t, 'OVER')) { var over = formatterOverClauses(coreTokens, i); if (over && over.clauses.length >= 2 && over.close < end) return true; }; if (caseLevel === 0 && t.depth === baseDepth && formatterIsCallName(coreTokens, i)) { var call = formatterConditionCall(coreTokens, i); if (call && call.outerClose < end) return true; }; }; return false; }; function formatterOverClauses(core, overIdx) { var open = overIdx + 1; if (!isPunctToken(core[open], '(')) return null; var close = formatterMatchParenIdx(core, open); var depth = core[open].depth + 1; var starts = []; for (var k = open + 1; k < close; k++) { var t = core[k]; if (t.depth !== depth || t.type !== 'keyword') continue; var isListStart = (t.upper === 'PARTITION' || t.upper === 'ORDER') && isKw(core[k + 1], 'BY'); var isFrameStart = (t.upper === 'ROWS' || t.upper === 'RANGE') && !formatterIsKeywordName(core, k); if (isListStart || isFrameStart) starts.push(k); }; if (close > open + 1 && (!starts.length || starts[0] > open + 1)) starts.unshift(open + 1); return { open: open, close: close, clauses: starts.map(function (s, n) { return { start: s, end: n + 1 < starts.length ? starts[n + 1] : close }; }) }; }; function formatterIsCallName(core, idx) { var t = core[idx]; if (!t || !isPunctToken(core[idx + 1], '(')) return false; if (t.type === 'identifier') return true; return t.type === 'keyword' && !!FORMATTER_FUNCTION_LIKE_KEYWORDS[t.upper]; }; function formatterConditionCall(core, nameIdx) { var open = nameIdx + 1; var close = formatterMatchParenIdx(core, open); var outerClose = close; while (formatterIsCallName(core, open + 1) && formatterMatchParenIdx(core, open + 2) === close - 1) { open += 2; close -= 1; }; var depth = core[open].depth + 1; var argEnd = close; for (var k = open + 1; k < close; k++) { if (core[k].depth === depth && isPunctToken(core[k], ',')) { argEnd = k; break; }; }; if (argEnd <= open + 1 || isKw(core[open + 1], 'SELECT')) return null; var conds = formatterSplitAndOr(core, open + 1, argEnd, depth); if (conds.length < 3) return null; return { open: open, close: close, argEnd: argEnd, outerClose: outerClose, conds: conds }; }; function formatterSplitCommaList(coreTokens, start, end, baseDepth) { var items = []; var curStart = start; for (var i = start; i < end; i++) { var t = coreTokens[i]; if (t.depth === baseDepth && t.type === 'punct' && t.value === ',') { items.push({ start: curStart, end: i }); curStart = i + 1; }; }; items.push({ start: curStart, end: end }); return items; }; function formatterRenderExpr(ctx, start, end, lineIndent, closeIndent) { var coreTokens = ctx.core; var segs = ['']; var baseDepth = start < end ? coreTokens[start].depth : 0; var flatFrom = start; var closeAt = closeIndent === undefined ? lineIndent : closeIndent; function flush(uptoExclusive) { if (uptoExclusive <= flatFrom) { flatFrom = uptoExclusive; return; }; var text = formatterJoinFlat(coreTokens, flatFrom, uptoExclusive, ctx); var isSpaceNeeded = flatFrom > start && formatterSpaceBefore(coreTokens, flatFrom); segs[segs.length - 1] += (isSpaceNeeded ? ' ' : '') + text; flatFrom = uptoExclusive; }; var i = start; while (i < end) { var t = coreTokens[i]; if (t.depth === baseDepth && t.type === 'keyword' && t.upper === 'CASE') { flush(i); var isSpaceNeeded = i > start && formatterSpaceBefore(coreTokens, i); segs[segs.length - 1] += (isSpaceNeeded ? ' ' : '') + 'CASE'; i = formatterRenderCaseBody(ctx, i + 1, end, lineIndent, segs, closeAt); flatFrom = i; continue; }; if (t.depth === baseDepth && isKw(t, 'OVER')) { var over = formatterOverClauses(coreTokens, i); if (over && over.clauses.length >= 2 && over.close < end) { flush(i); var isSpaceNeededOver = i > start && formatterSpaceBefore(coreTokens, i); segs[segs.length - 1] += (isSpaceNeededOver ? ' ' : '') + 'OVER ('; over.clauses.forEach(function (clause) { segs.push(fmtSpaces(lineIndent + 4) + formatterJoinFlat(coreTokens, clause.start, clause.end, ctx)); }); segs.push(fmtSpaces(closeAt) + ')'); i = over.close + 1; flatFrom = i; continue; }; }; if (t.depth === baseDepth && formatterIsCallName(coreTokens, i)) { var call = formatterConditionCall(coreTokens, i); if (call && call.outerClose < end) { flush(i); var isSpaceNeededCall = i > start && formatterSpaceBefore(coreTokens, i); segs[segs.length - 1] += (isSpaceNeededCall ? ' ' : '') + formatterJoinFlat(coreTokens, i, call.open + 1, ctx); formatterRenderConditionLines(ctx, call.conds, lineIndent + 4, '').forEach(function (ln) { segs.push(ln); }); if (call.argEnd < call.close) segs.push(fmtSpaces(lineIndent + 4) + ', ' + formatterJoinFlat(coreTokens, call.argEnd + 1, call.close + 1, ctx)); var closeFrom = call.argEnd < call.close ? call.close + 1 : call.close; if (closeFrom <= call.outerClose) segs.push(fmtSpaces(closeAt) + formatterJoinFlat(coreTokens, closeFrom, call.outerClose + 1, ctx)); i = call.outerClose + 1; flatFrom = i; continue; }; }; if (t.depth === baseDepth && t.type === 'punct' && t.value === '(' && coreTokens[i + 1] && coreTokens[i + 1].type === 'keyword' && coreTokens[i + 1].upper === 'SELECT') { flush(i); var closeIdx = formatterMatchParenIdx(coreTokens, i); var isSpaceNeeded = i > start && formatterSpaceBefore(coreTokens, i); segs[segs.length - 1] += (isSpaceNeeded ? ' ' : '') + '('; var innerLines = formatterFormatStatement(ctx, i + 1, closeIdx, lineIndent + 4); innerLines.forEach(function (ln) { segs.push(ln); }); segs.push(fmtSpaces(closeAt) + ')'); i = closeIdx + 1; flatFrom = i; continue; }; i++; }; flush(end); return segs; }; function formatterRenderCaseBody(ctx, start, end, lineIndent, segs, closeIndent) { var coreTokens = ctx.core; var level = 1; var whenStarts = []; var elseStart = -1; var endIdx = -1; var operandEnd = -1; var i = start; while (i < end) { var t = coreTokens[i]; if (t.type === 'keyword' && t.upper === 'CASE') { level++; i++; continue; }; if (t.type === 'keyword' && t.upper === 'END') { level--; if (level === 0) { endIdx = i; break; }; i++; continue; }; if (level === 1 && t.type === 'keyword' && t.upper === 'WHEN') { if (operandEnd === -1) operandEnd = i; whenStarts.push(i); }; if (level === 1 && t.type === 'keyword' && t.upper === 'ELSE') elseStart = i; i++; }; if (endIdx === -1) endIdx = end; if (operandEnd > start) { segs[segs.length - 1] += ' ' + formatterJoinFlat(coreTokens, start, operandEnd, ctx); }; var branchBounds = whenStarts.concat(elseStart !== -1 ? [elseStart] : []).concat([endIdx]).sort(function (a, b) { return a - b; }); var thenIdxFor = {}; var hasOrWhen = false; var whenCondTexts = whenStarts.map(function (wIdx) { var k = wIdx + 1; var lvl = 0; while (k < endIdx) { var tk = coreTokens[k]; if (tk.type === 'keyword' && tk.upper === 'CASE') lvl++; else if (tk.type === 'keyword' && tk.upper === 'END') lvl--; else if (lvl === 0 && tk.type === 'keyword' && tk.upper === 'THEN') break; else if (lvl === 0 && tk.depth === coreTokens[wIdx].depth && isKw(tk, 'OR')) hasOrWhen = true; k++; }; thenIdxFor[wIdx] = k; return formatterJoinFlat(coreTokens, wIdx + 1, k, ctx); }); var shouldAlignThen = whenCondTexts.length > 1 && !hasOrWhen; var whenTextCol = lineIndent + 4 + 'WHEN '.length; var thenCol = 0; if (shouldAlignThen) { thenCol = nextTabStop(whenTextCol + Math.max.apply(null, whenCondTexts.map(function (s) { return s.length; }))); }; whenStarts.forEach(function (wIdx, idx2) { var thenIdx = thenIdxFor[wIdx]; var condText = whenCondTexts[idx2]; var line = fmtSpaces(lineIndent + 4) + 'WHEN ' + condText; line += shouldAlignThen ? (fmtSpaces(thenCol - whenTextCol - condText.length) + 'THEN') : ' THEN'; var nextBoundary = branchBounds[branchBounds.indexOf(wIdx) + 1]; var thenValLines = formatterRenderExpr(ctx, thenIdx + 1, nextBoundary, lineIndent + 4); line += thenValLines[0] ? (' ' + thenValLines[0]) : ''; segs.push(line); for (var q = 1; q < thenValLines.length; q++) segs.push(thenValLines[q]); }); if (elseStart !== -1) { var elseLine = fmtSpaces(lineIndent + 4) + 'ELSE'; var elseValLines = formatterRenderExpr(ctx, elseStart + 1, endIdx, lineIndent + 4); elseLine += elseValLines[0] ? (' ' + elseValLines[0]) : ''; segs.push(elseLine); for (var q2 = 1; q2 < elseValLines.length; q2++) segs.push(elseValLines[q2]); }; segs.push(fmtSpaces(closeIndent === undefined ? lineIndent : closeIndent) + 'END'); return endIdx + 1; }; function formatterDetectTrailingAlias(coreTokens, itStart, itEnd) { var n = itEnd - itStart; if (n < 1) return null; var last = coreTokens[itEnd - 1]; if (n < 2) return null; var prev = coreTokens[itEnd - 2]; if (last.type === 'keyword' && prev.type === 'keyword' && prev.upper === 'AS' && formatterIsKeywordName(coreTokens, itEnd - 1)) { return { exprEnd: itEnd - 2, aliasIdx: itEnd - 1 }; }; var isImplicitAlias = (prev.type === 'punct' && prev.value === ')') || prev.type === 'identifier' || prev.type === 'string' || prev.type === 'number' || (prev.type === 'keyword' && (prev.upper === 'END' || prev.upper === 'NULL')) || (isUnreservedKeyword(prev) && formatterIsKeywordName(coreTokens, itEnd - 2)); if (last.type === 'keyword' && isUnreservedKeyword(last) && last.depth === coreTokens[itStart].depth && isImplicitAlias && formatterIsKeywordName(coreTokens, itEnd - 1)) { return { exprEnd: itEnd - 1, aliasIdx: itEnd - 1 }; }; if (last.type !== 'identifier') return null; if (prev.type === 'keyword' && prev.upper === 'AS') { return { exprEnd: itEnd - 2, aliasIdx: itEnd - 1 }; }; if (prev.type === 'punct' && prev.value === '.') return null; if (isImplicitAlias) return { exprEnd: itEnd - 1, aliasIdx: itEnd - 1 }; return null; }; function formatterRenderSelectItem(ctx, itStart, itEnd, lineIndent, closeIndent) { var coreTokens = ctx.core; if (itEnd <= itStart) return { lines: [''], hasAlias: false }; var alias = formatterDetectTrailingAlias(coreTokens, itStart, itEnd); if (alias) { var exprLines = formatterRenderExpr(ctx, itStart, alias.exprEnd, lineIndent, closeIndent); var aliasText = coreTokens[alias.aliasIdx].raw; exprLines[exprLines.length - 1] += ' AS ' + aliasText; return { lines: exprLines, hasAlias: true, aliasText: aliasText }; }; return { lines: formatterRenderExpr(ctx, itStart, itEnd, lineIndent, closeIndent), hasAlias: false }; }; function formatterIsAliasAt(coreTokens, idx) { var t = coreTokens[idx]; if (!t) return false; return t.type === 'identifier' || (t.type === 'keyword' && (isKw(coreTokens[idx - 1], 'AS') || isUnreservedKeyword(t)) && formatterIsKeywordName(coreTokens, idx)); }; function formatterConsumeHintAndPivot(ctx, idx, indentForSub) { var coreTokens = ctx.core; var j = idx; var suffix = ''; var extraLines = []; if (coreTokens[j] && coreTokens[j].type === 'keyword' && coreTokens[j].upper === 'TABLESAMPLE') { suffix += ' TABLESAMPLE'; j++; if (coreTokens[j] && coreTokens[j].type === 'identifier' && !coreTokens[j].quote && coreTokens[j].upper === 'SYSTEM') { suffix += ' SYSTEM'; j++; }; if (coreTokens[j] && coreTokens[j].type === 'punct' && coreTokens[j].value === '(') { var sampleClose = formatterMatchParenIdx(coreTokens, j); suffix += ' ' + formatterJoinFlat(coreTokens, j, sampleClose + 1, ctx); j = sampleClose + 1; }; if (coreTokens[j] && coreTokens[j].type === 'identifier' && !coreTokens[j].quote && coreTokens[j].upper === 'REPEATABLE' && coreTokens[j + 1] && coreTokens[j + 1].type === 'punct' && coreTokens[j + 1].value === '(') { var seedClose = formatterMatchParenIdx(coreTokens, j + 1); suffix += ' REPEATABLE ' + formatterJoinFlat(coreTokens, j + 1, seedClose + 1, ctx); j = seedClose + 1; }; }; if (coreTokens[j] && coreTokens[j].type === 'punct' && coreTokens[j].value === '(') { var oldHintClose = formatterMatchParenIdx(coreTokens, j); suffix += ' ' + formatterJoinFlat(coreTokens, j, oldHintClose + 1, ctx); j = oldHintClose + 1; }; if (coreTokens[j] && coreTokens[j].type === 'keyword' && coreTokens[j].upper === 'WITH' && coreTokens[j + 1] && coreTokens[j + 1].type === 'punct' && coreTokens[j + 1].value === '(') { var hintClose = formatterMatchParenIdx(coreTokens, j + 1); suffix += ' ' + formatterJoinFlat(coreTokens, j, hintClose + 1, ctx); j = hintClose + 1; }; if (coreTokens[j] && coreTokens[j].type === 'keyword' && (coreTokens[j].upper === 'PIVOT' || coreTokens[j].upper === 'UNPIVOT')) { var kw = coreTokens[j].upper; var openIdx = j + 1; if (coreTokens[openIdx] && coreTokens[openIdx].type === 'punct' && coreTokens[openIdx].value === '(') { var closeIdx = formatterMatchParenIdx(coreTokens, openIdx); var inner = formatterJoinFlat(coreTokens, openIdx + 1, closeIdx, ctx); var k = closeIdx + 1; var pivotAlias = ''; if (coreTokens[k] && coreTokens[k].type === 'keyword' && coreTokens[k].upper === 'AS') k++; if (formatterIsAliasAt(coreTokens, k)) { pivotAlias = ' AS ' + coreTokens[k].raw; k++; }; extraLines.push(fmtSpaces(indentForSub + 4) + kw + ' (' + inner + ')' + pivotAlias); j = k; }; }; return { suffix: suffix, extraLines: extraLines, nextIdx: j }; }; function formatterRenderJoinGroup(ctx, open, close) { var coreTokens = ctx.core; var out = ''; for (var i = open; i <= close; i++) { var t = coreTokens[i]; var isHintWith = t.upper === 'WITH' && coreTokens[i + 1] && coreTokens[i + 1].type === 'punct' && coreTokens[i + 1].value === '('; if (t.type === 'keyword' && (t.upper === 'SELECT' || (t.upper === 'WITH' && !isHintWith) || t.upper === 'VALUES')) { var bail = new Error('formatter: a subquery in a join in parentheses'); bail.formatterReason = parenHoldsQuery(coreTokens, open, coreTokens.length) ? FORMATTER_PAREN_EXTRA_REASON : 'This query has a join in parentheses that holds a subquery, which Format does not lay out yet, so it left it as it is.'; throw bail; }; if (i > open && formatterSpaceBefore(coreTokens, i)) out += ' '; if (t.type === 'keyword' && t.upper === 'JOIN') { var before = coreTokens[i - 1]; var beforeJoinType = before && !before.quote && FORMATTER_JOIN_HINTS[before.upper] ? coreTokens[i - 2] : before; if (!(beforeJoinType && beforeJoinType.type === 'keyword' && FORMATTER_JOIN_QUALIFIERS[beforeJoinType.upper])) out += 'INNER '; }; var prevTok = coreTokens[i - 1], nextTok = coreTokens[i + 1]; var isHint = !t.quote && FORMATTER_JOIN_HINTS[t.upper] && prevTok && prevTok.type === 'keyword' && FORMATTER_JOIN_QUALIFIERS[prevTok.upper] && nextTok && nextTok.type === 'keyword' && nextTok.upper === 'JOIN'; out += isHint ? t.upper : formatterTokenText(t, i, ctx.overrides, ctx.funcSet, coreTokens); }; return out; }; function formatterRenderTableRef(ctx, idx, indentForSub) { var coreTokens = ctx.core; if (coreTokens[idx] && coreTokens[idx].type === 'punct' && coreTokens[idx].value === '(' && coreTokens[idx + 1] && coreTokens[idx + 1].type === 'keyword' && coreTokens[idx + 1].upper === 'VALUES') { var valuesClose = formatterMatchParenIdx(coreTokens, idx); var valuesText = formatterJoinFlat(coreTokens, idx, valuesClose + 1, ctx); var jv = valuesClose + 1; var valuesAliasFrom = jv; if (coreTokens[jv] && coreTokens[jv].type === 'keyword' && coreTokens[jv].upper === 'AS') jv++; var valuesTail = ''; if (formatterIsAliasAt(coreTokens, jv)) { valuesTail = 'AS ' + coreTokens[jv].raw; jv++; jv = formatterConsumeColumnAliasList(ctx, jv, function (text) { valuesTail += text; }); var valuesComments = formatterTakeRefComments(ctx, valuesAliasFrom, jv); if (valuesComments) valuesTail += ' ' + valuesComments; }; return { first: valuesText + (valuesTail ? ' ' + valuesTail : ''), rest: [], nextIdx: jv, head: valuesTail ? valuesText : null, aliasTail: valuesTail || null }; }; if (coreTokens[idx] && coreTokens[idx].type === 'punct' && coreTokens[idx].value === '(' && coreTokens[idx + 1] && (coreTokens[idx + 1].type === 'identifier' || (coreTokens[idx + 1].type === 'punct' && coreTokens[idx + 1].value === '('))) { var groupClose = formatterMatchParenIdx(coreTokens, idx); var groupText = formatterRenderJoinGroup(ctx, idx, groupClose); var groupComments = formatterTakeRefComments(ctx, idx + 1, groupClose + 1); return { first: groupText + (groupComments ? ' ' + groupComments : ''), rest: [], nextIdx: groupClose + 1, head: null, aliasTail: null }; }; if (coreTokens[idx] && coreTokens[idx].type === 'punct' && coreTokens[idx].value === '(') { var closeIdx = formatterMatchParenIdx(coreTokens, idx); var restLines = formatterFormatStatement(ctx, idx + 1, closeIdx, indentForSub + 4); var closeLine = fmtSpaces(indentForSub) + ')'; var j2 = closeIdx + 1; var derivedAliasFrom = j2; var derivedHasAlias = false; if (coreTokens[j2] && coreTokens[j2].type === 'keyword' && coreTokens[j2].upper === 'AS') j2++; if (formatterIsAliasAt(coreTokens, j2)) { closeLine += ' AS ' + coreTokens[j2].raw; derivedHasAlias = true; j2++; j2 = formatterConsumeColumnAliasList(ctx, j2, function (text) { closeLine += text; }); }; var extra1 = formatterConsumeHintAndPivot(ctx, j2, indentForSub); closeLine += extra1.suffix; restLines.push(closeLine); extra1.extraLines.forEach(function (ln) { restLines.push(ln); }); var derivedComments = derivedHasAlias ? formatterTakeRefComments(ctx, derivedAliasFrom, extra1.nextIdx) : ''; if (derivedComments) restLines[restLines.length - 1] += ' ' + derivedComments; return { first: '(', rest: restLines, nextIdx: extra1.nextIdx }; }; var chainTokens = []; var j3 = idx; while (coreTokens[j3] && (coreTokens[j3].type === 'identifier' || coreTokens[j3].type === 'number' || (isUnreservedKeyword(coreTokens[j3]) && coreTokens[j3].upper !== 'APPLY'))) { chainTokens.push(coreTokens[j3]); j3++; if (coreTokens[j3] && coreTokens[j3].type === 'punct' && coreTokens[j3].value === '.') { j3++; continue; }; break; }; var nameText = formatterJoinFlat(coreTokens, idx, j3, ctx); var j4 = j3; var isCall = false; if (coreTokens[j4] && coreTokens[j4].type === 'punct' && coreTokens[j4].value === '(') { var callClose = formatterMatchParenIdx(coreTokens, j4); nameText += formatterJoinFlat(coreTokens, j4, callClose + 1, ctx); j4 = callClose + 1; isCall = true; if (coreTokens[j4] && coreTokens[j4].type === 'keyword' && coreTokens[j4].upper === 'WITH' && coreTokens[j4 + 1] && coreTokens[j4 + 1].type === 'punct' && coreTokens[j4 + 1].value === '(') { var schemaClose = formatterMatchParenIdx(coreTokens, j4 + 1); nameText += ' ' + formatterJoinFlat(coreTokens, j4, schemaClose + 1, ctx); j4 = schemaClose + 1; }; }; var aliasFrom = j4; var aliasSuffix = ''; if (coreTokens[j4] && coreTokens[j4].type === 'keyword' && coreTokens[j4].upper === 'AS') j4++; if (formatterIsAliasAt(coreTokens, j4)) { aliasSuffix = ' AS ' + coreTokens[j4].raw; j4++; if (isCall) j4 = formatterConsumeColumnAliasList(ctx, j4, function (text) { aliasSuffix += text; }); }; var extra2 = formatterConsumeHintAndPivot(ctx, j4, indentForSub); var firstText = nameText + aliasSuffix + extra2.suffix; var aliasTail = aliasSuffix ? aliasSuffix.slice(1) + extra2.suffix : null; var restText = extra2.extraLines; var refComments = aliasSuffix ? formatterTakeRefComments(ctx, aliasFrom, extra2.nextIdx) : ''; if (refComments) { if (restText.length) { restText[restText.length - 1] += ' ' + refComments; } else { firstText += ' ' + refComments; aliasTail += ' ' + refComments; }; }; return { first: firstText, rest: restText, nextIdx: extra2.nextIdx, head: aliasSuffix ? nameText : null, aliasTail: aliasTail }; }; function formatterConsumeColumnAliasList(ctx, idx, append) { var coreTokens = ctx.core; if (!(coreTokens[idx] && coreTokens[idx].type === 'punct' && coreTokens[idx].value === '(')) return idx; var closeIdx = formatterMatchParenIdx(coreTokens, idx); append(formatterJoinFlat(coreTokens, idx, closeIdx + 1, ctx)); return closeIdx + 1; }; function formatterRenderConditionClause(ctx, label, start, end, indent) { var coreTokens = ctx.core; var baseDepth = start < end ? coreTokens[start].depth : indent; var conds = formatterSplitAndOr(coreTokens, start, end, baseDepth); var lines = []; var shouldForceBreak = formatterConditionNeedsLines(ctx, conds, indent + 4); if (!shouldForceBreak) { var r = formatterRenderExpr(ctx, start, end, indent); var lead = takeInlineComments(ctx, start, end); lines.push(fmtSpaces(indent) + label + (r[0] ? ' ' + (lead ? lead + ' ' : '') + r[0] : '')); for (var q = 1; q < r.length; q++) lines.push(r[q]); var trail = formatterTakeSameLineComments(ctx, end); if (trail) lines[lines.length - 1] += ' ' + trail; return lines; }; lines.push(fmtSpaces(indent) + label); return lines.concat(formatterRenderConditionLines(ctx, conds, indent + 4, '')); }; function formatterRollupList(ctx, start, end) { var core = ctx.core; var t = core[start]; if (!t || t.quote || (t.upper !== 'ROLLUP' && t.upper !== 'CUBE') || !isPunctToken(core[start + 1], '(')) return null; var close = formatterMatchParenIdx(core, start + 1); if (close !== end - 1) return null; var depth = core[start + 1].depth + 1; var items = formatterSplitCommaList(core, start + 2, close, depth); if (items.length < 2 || items.some(function (it) { return it.end <= it.start || formatterExprNeedsBreak(core, it.start, it.end, depth); })) return null; return { name: start, open: start + 1, close: close, items: items }; }; function formatterRenderRollupLines(ctx, rollup, lineIndent) { var last = rollup.items.length - 1; var rest = rollup.items.map(function (it, n) { var lead = takeInlineComments(ctx, n === 0 ? rollup.open : it.start, it.end); var trail = [ takeInlineComments(ctx, n === last ? rollup.close : it.end, n === last ? rollup.close + 1 : it.end + 1), n === last ? '' : formatterTakeCommaTrail(ctx, it.end) ].filter(function (note) { return !!note; }).join(' '); return (n === 0 ? fmtSpaces(lineIndent + 10) : fmtSpaces(lineIndent + 8) + ', ') + (lead ? lead + ' ' : '') + formatterJoinFlat(ctx.core, it.start, it.end, ctx) + (trail ? ' ' + trail : ''); }); rest.push(fmtSpaces(lineIndent + 4) + ')'); return { head: formatterJoinFlat(ctx.core, rollup.name, rollup.name + 1, ctx) + ' (', rest: rest }; }; function formatterRenderListClause(ctx, label, start, end, indent) { var coreTokens = ctx.core; var baseDepth = start < end ? coreTokens[start].depth : indent; var items = formatterSplitCommaList(coreTokens, start, end, baseDepth); var lines = []; var addTrail = function (at, nextItem) { var trail = formatterTakeSameLineComments(ctx, at); var commaTrail = nextItem && nextItem.end > nextItem.start ? formatterTakeCommaTrail(ctx, at) : ''; if (trail) lines[lines.length - 1] += ' ' + trail; if (commaTrail) lines[lines.length - 1] += ' ' + commaTrail; }; var soleRollup = label === 'GROUP BY' && items.length === 1 ? formatterRollupList(ctx, start, end) : null; if (soleRollup) { var soleLines = formatterRenderRollupLines(ctx, soleRollup, indent); lines.push(fmtSpaces(indent) + label + ' ' + soleLines.head); lines = lines.concat(soleLines.rest); addTrail(end); return lines; }; if (items.length <= 1 && !formatterExprNeedsBreak(coreTokens, start, end, baseDepth)) { var r = formatterRenderExpr(ctx, start, end, indent); lines.push(fmtSpaces(indent) + label + (r[0] ? ' ' + r[0] : '')); for (var q = 1; q < r.length; q++) lines.push(r[q]); addTrail(end); return lines; }; lines.push(fmtSpaces(indent) + label); items.forEach(function (it, idx) { var itemIndent = idx === 0 ? indent + 6 : indent + 4; var prefix = idx === 0 ? fmtSpaces(itemIndent) : (fmtSpaces(itemIndent) + ', '); lines = lines.concat(commentLinesRange(ctx, idx === 0 ? it.start : it.start - 1, it.end, itemIndent)); var rollup = label === 'GROUP BY' ? formatterRollupList(ctx, it.start, it.end) : null; if (rollup) { var rollupLines = formatterRenderRollupLines(ctx, rollup, indent + 4); lines.push(prefix + rollupLines.head); lines = lines.concat(rollupLines.rest); } else { var r2 = formatterRenderExpr(ctx, it.start, it.end, indent + 4, indent + 6); lines.push(prefix + r2[0]); for (var q2 = 1; q2 < r2.length; q2++) lines.push(r2[q2]); }; addTrail(it.end, items[idx + 1]); }); return lines; }; function formatterFormatOneSelect(ctx, start, end, indent) { var coreTokens = ctx.core; if (start >= end || !(coreTokens[start].type === 'keyword' && coreTokens[start].upper === 'SELECT')) { throw new Error('formatter: expected SELECT'); }; var baseDepth = coreTokens[start].depth; var lines = commentLines(ctx, start, indent); var i = start + 1; var selectMods = []; if (coreTokens[i] && coreTokens[i].type === 'keyword' && (coreTokens[i].upper === 'DISTINCT' || coreTokens[i].upper === 'ALL')) { selectMods.push(coreTokens[i].upper); i++; }; var hasTop = false; var topEndIdx = i; if (coreTokens[i] && coreTokens[i].type === 'keyword' && coreTokens[i].upper === 'TOP') { hasTop = true; i++; if (coreTokens[i] && coreTokens[i].type === 'punct' && coreTokens[i].value === '(') { i = formatterMatchParenIdx(coreTokens, i) + 1; } else { i++; }; if (coreTokens[i] && coreTokens[i].type === 'keyword' && coreTokens[i].upper === 'PERCENT') i++; if (coreTokens[i] && coreTokens[i].type === 'keyword' && coreTokens[i].upper === 'WITH' && coreTokens[i + 1] && coreTokens[i + 1].upper === 'TIES') i += 2; topEndIdx = i; }; var selectLinePrefix = 'SELECT'; if (hasTop || selectMods.length) { selectLinePrefix += ' ' + formatterJoinFlat(coreTokens, start + 1, topEndIdx, ctx); }; var listEnd = end; var items = []; var curStart = topEndIdx; var j = topEndIdx; for (; j < end; j++) { var xt = coreTokens[j]; if (xt.depth === baseDepth && xt.type === 'punct' && xt.value === ',') { items.push({ start: curStart, end: j }); curStart = j + 1; continue; }; if (xt.depth === baseDepth && xt.type === 'keyword' && !xt.isDistinctFrom && (xt.upper === 'FROM' || xt.upper === 'WHERE' || xt.upper === 'HAVING' || ((xt.upper === 'GROUP' || xt.upper === 'ORDER') && coreTokens[j + 1] && coreTokens[j + 1].type === 'keyword' && coreTokens[j + 1].upper === 'BY') || FORMATTER_SETOPS[xt.upper])) { break; }; if (xt.depth === baseDepth && !xt.quote && ((xt.upper === 'FOR' && coreTokens[j + 1] && ['JSON', 'XML', 'BROWSE'].indexOf(coreTokens[j + 1].upper) !== -1) || (xt.upper === 'OPTION' && coreTokens[j + 1] && coreTokens[j + 1].type === 'punct' && coreTokens[j + 1].value === '('))) { break; }; if (xt.depth === baseDepth && xt.type === 'punct' && xt.value === ';') break; }; listEnd = j; var fromIdx = (coreTokens[j] && coreTokens[j].type === 'keyword' && coreTokens[j].upper === 'FROM' && !coreTokens[j].isDistinctFrom) ? j : -1; items.push({ start: curStart, end: listEnd }); var isSingleLineNoMods = (items.length === 1 && !hasTop && !selectMods.length && !formatterExprNeedsBreak(coreTokens, items[0].start, items[0].end, baseDepth)); if (isSingleLineNoMods) { var only = formatterRenderSelectItem(ctx, items[0].start, items[0].end, indent); var onlyLead = takeInlineComments(ctx, items[0].start, items[0].end); lines.push(fmtSpaces(indent) + selectLinePrefix + (only.lines[0] ? ' ' + (onlyLead ? onlyLead + ' ' : '') + only.lines[0] : '')); for (var q0 = 1; q0 < only.lines.length; q0++) lines.push(only.lines[q0]); var onlyTrail = formatterTakeSameLineComments(ctx, items[0].end); if (onlyTrail) lines[lines.length - 1] += ' ' + onlyTrail; } else { lines.push(fmtSpaces(indent) + selectLinePrefix); var renderedItems = items.map(function (it) { return formatterRenderSelectItem(ctx, it.start, it.end, indent + 4, indent + 6); }); var commaTrails = items.map(function (it, idxItem) { var nextItem = items[idxItem + 1]; return nextItem && nextItem.end > nextItem.start ? formatterTakeCommaTrail(ctx, it.end) : ''; }); var itemLeads = items.map(function (it) { var lead = takeInlineComments(ctx, it.start, it.end); return lead ? lead + ' ' : ''; }); var itemTrails = items.map(function (it, idxItem) { var trail = idxItem === items.length - 1 ? formatterTakeSameLineComments(ctx, it.end) : takeInlineComments(ctx, it.end, it.end + 1); return [trail, commaTrails[idxItem]].filter(function (note) { return !!note; }).join(' '); }); var exprLens = {}; var eligibleIndexes = []; renderedItems.forEach(function (r, idx3) { if (r.hasAlias && r.lines.length === 1) { exprLens[idx3] = r.lines[0].length - (' AS ' + r.aliasText).length; if (indent + 6 + itemLeads[idx3].length + exprLens[idx3] <= FORMATTER_LONG_LINE) eligibleIndexes.push(idx3); }; }); var columnIndexes = eligibleIndexes.filter(function (idxLead) { return !itemLeads[idxLead]; }); if (columnIndexes.length >= 2) { var byLength = columnIndexes.slice().sort(function (a, b) { return exprLens[b] - exprLens[a]; }); var longestLen = exprLens[byLength[0]]; var nextLen = exprLens[byLength[1]]; if (longestLen >= FORMATTER_ALIGN_RATIO * nextLen && longestLen - nextLen >= FORMATTER_ALIGN_GAP) { columnIndexes.splice(columnIndexes.indexOf(byLength[0]), 1); eligibleIndexes.splice(eligibleIndexes.indexOf(byLength[0]), 1); }; }; var aliasCol = 0; if (columnIndexes.length >= 2) { aliasCol = nextTabStop(Math.max.apply(null, columnIndexes.map(function (idx4) { return indent + 6 + exprLens[idx4]; }))); }; var endComments = []; renderedItems.forEach(function (r, idx5) { var prefix = (idx5 === 0 ? fmtSpaces(indent + 6) : (fmtSpaces(indent + 4) + ', ')) + itemLeads[idx5]; var text0 = r.lines[0]; if (aliasCol && eligibleIndexes.indexOf(idx5) !== -1 && aliasCol - prefix.length - exprLens[idx5] >= 1) { var exprLen = exprLens[idx5]; var exprPart = text0.slice(0, exprLen); var rest = text0.slice(exprLen).replace(/^ /, fmtSpaces(aliasCol - prefix.length - exprLen)); text0 = exprPart + rest; }; lines.push(text0 ? prefix + text0 : prefix.replace(/ +$/, '')); for (var q1 = 1; q1 < r.lines.length; q1++) lines.push(r.lines[q1]); if (aliasCol && r.hasAlias && r.lines.length > 1) { var lastAt = lines.length - 1; var lastHead = lines[lastAt].slice(0, lines[lastAt].length - (' AS ' + r.aliasText).length); if (lastHead.length < aliasCol) lines[lastAt] = lastHead + fmtSpaces(aliasCol - lastHead.length) + 'AS ' + r.aliasText; }; if (itemTrails[idx5]) endComments.push({ at: lines.length - 1, text: itemTrails[idx5] }); }); var commentRows = endComments.filter(function (row) { return !/^\s*,?$/.test(lines[row.at]); }); if (commentRows.length >= 2) { var byLength = commentRows.slice().sort(function (a, b) { return lines[b.at].length - lines[a.at].length; }); var longestRow = lines[byLength[0].at].length; var nextRow = lines[byLength[1].at].length; if (longestRow >= FORMATTER_ALIGN_RATIO * nextRow && longestRow - nextRow >= FORMATTER_ALIGN_GAP) { commentRows = commentRows.filter(function (row) { return row !== byLength[0]; }); }; }; var commentCol = commentRows.length >= 2 ? nextTabStop(Math.max.apply(null, commentRows.map(function (row) { return lines[row.at].length; }))) : 0; endComments.forEach(function (row) { var isAligned = commentCol && commentRows.indexOf(row) !== -1; lines[row.at] += (isAligned ? fmtSpaces(commentCol - lines[row.at].length) : ' ') + row.text; }); }; if (listEnd < end && coreTokens[listEnd].type === 'keyword' && coreTokens[listEnd].depth === baseDepth) { lines = lines.concat(formatterTakeListNotes(ctx, listEnd, indent)); }; var fromEnd = listEnd; var fromMembers = []; if (fromIdx !== -1) { lines = lines.concat(commentLines(ctx, fromIdx, indent)); var pos = fromIdx + 1; var isFirstSource = true; for (;;) { var sourcePrefix = isFirstSource ? (fmtSpaces(indent) + 'FROM ') : (fmtSpaces(indent + 4) + ', '); if (!isFirstSource) lines = lines.concat(commentLines(ctx, pos - 1, indent + 4)); var ref = formatterRenderTableRef(ctx, pos, indent); if (ref.aliasTail) fromMembers.push({ lineIdx: lines.length, head: sourcePrefix + ref.head, tail: ref.aliasTail }); lines.push(sourcePrefix + ref.first); ref.rest.forEach(function (ln) { lines.push(ln); }); pos = ref.nextIdx; while (pos < end) { var jt = coreTokens[pos]; if (!(jt.depth === baseDepth && jt.type === 'keyword' && (jt.upper === 'JOIN' || jt.upper === 'APPLY' || FORMATTER_JOIN_QUALIFIERS[jt.upper]))) break; lines = lines.concat(commentLines(ctx, pos, indent + 4)); var joinWords = []; var jp = pos; while (coreTokens[jp] && coreTokens[jp].type === 'keyword' && FORMATTER_JOIN_QUALIFIERS[coreTokens[jp].upper]) { joinWords.push(coreTokens[jp].upper); jp++; }; if (joinWords.length && coreTokens[jp] && !coreTokens[jp].quote && FORMATTER_JOIN_HINTS[coreTokens[jp].upper] && coreTokens[jp + 1] && coreTokens[jp + 1].type === 'keyword' && coreTokens[jp + 1].upper === 'JOIN') { joinWords.push(coreTokens[jp].upper); jp++; }; var isApply = coreTokens[jp] && coreTokens[jp].upper === 'APPLY'; if (coreTokens[jp] && (coreTokens[jp].upper === 'JOIN' || coreTokens[jp].upper === 'APPLY')) jp++; var joinLabel; if (isApply) joinLabel = joinWords.length ? (joinWords.join(' ') + ' APPLY') : 'CROSS APPLY'; else joinLabel = joinWords.length ? (joinWords.join(' ') + ' JOIN') : 'INNER JOIN'; var ref2 = formatterRenderTableRef(ctx, jp, indent + 4); var joinPrefix = fmtSpaces(indent + 4) + joinLabel + ' '; if (ref2.aliasTail) fromMembers.push({ lineIdx: lines.length, head: joinPrefix + ref2.head, tail: ref2.aliasTail }); lines.push(joinPrefix + ref2.first); ref2.rest.forEach(function (ln) { lines.push(ln); }); pos = ref2.nextIdx; if (coreTokens[pos] && coreTokens[pos].type === 'keyword' && coreTokens[pos].upper === 'ON') { lines = lines.concat(commentLines(ctx, pos, indent + 8)); var onEnd = formatterFindClauseBoundary(coreTokens, pos + 1, end, baseDepth); var conds = formatterSplitAndOr(coreTokens, pos + 1, onEnd, baseDepth); lines = lines.concat(formatterRenderConditionLines(ctx, conds, indent + 8, 'ON ')); pos = onEnd; }; }; isFirstSource = false; if (pos < end && coreTokens[pos].depth === baseDepth && coreTokens[pos].type === 'punct' && coreTokens[pos].value === ',') { pos++; continue; }; break; }; fromEnd = pos; if (fromMembers.length >= 2) { var fromAliasCol = nextTabStop(Math.max.apply(null, fromMembers.map(function (m) { return m.head.length; }))); fromMembers.forEach(function (m) { lines[m.lineIdx] = m.head + fmtSpaces(fromAliasCol - m.head.length) + m.tail; }); }; }; var clauseStart = (fromIdx !== -1) ? fromEnd : listEnd; var cp = clauseStart; while (cp < end) { var ctok = coreTokens[cp]; if (ctok.depth === baseDepth && ctok.type === 'keyword' && FORMATTER_SETOPS[ctok.upper]) break; if (ctok.depth === baseDepth && ctok.type === 'punct' && ctok.value === ';') break; if (ctok.depth === baseDepth && ctok.type === 'keyword' && ctok.upper === 'WHERE') { var wEnd = formatterFindClauseBoundary(coreTokens, cp + 1, end, baseDepth); lines = lines.concat(commentLines(ctx, cp, indent)); lines = lines.concat(formatterRenderConditionClause(ctx, 'WHERE', cp + 1, wEnd, indent)); cp = wEnd; continue; }; if (ctok.depth === baseDepth && ctok.type === 'keyword' && ctok.upper === 'GROUP' && coreTokens[cp + 1] && coreTokens[cp + 1].upper === 'BY') { var gEnd = formatterFindClauseBoundary(coreTokens, cp + 2, end, baseDepth); lines = lines.concat(commentLines(ctx, cp, indent)); lines = lines.concat(formatterRenderListClause(ctx, 'GROUP BY', cp + 2, gEnd, indent)); cp = gEnd; continue; }; if (ctok.depth === baseDepth && ctok.type === 'keyword' && ctok.upper === 'HAVING') { var hEnd = formatterFindClauseBoundary(coreTokens, cp + 1, end, baseDepth); lines = lines.concat(commentLines(ctx, cp, indent)); lines = lines.concat(formatterRenderConditionClause(ctx, 'HAVING', cp + 1, hEnd, indent)); cp = hEnd; continue; }; if (ctok.depth === baseDepth && ctok.type === 'keyword' && ctok.upper === 'ORDER' && coreTokens[cp + 1] && coreTokens[cp + 1].upper === 'BY') { var oEnd = formatterFindClauseBoundary(coreTokens, cp + 2, end, baseDepth); lines = lines.concat(commentLines(ctx, cp, indent)); lines = lines.concat(formatterRenderListClause(ctx, 'ORDER BY', cp + 2, oEnd, indent)); cp = oEnd; continue; }; if (ctok.depth === baseDepth && formatterIsPagingOffset(coreTokens, cp)) { var pageEnd = formatterFindClauseBoundary(coreTokens, cp + 1, end, baseDepth); lines = lines.concat(commentLines(ctx, cp, indent)); var pageLine = fmtSpaces(indent) + formatterJoinFlat(coreTokens, cp, pageEnd, ctx); var pageTrail = formatterTakeSameLineComments(ctx, pageEnd); lines.push(pageLine + (pageTrail ? ' ' + pageTrail : '')); cp = pageEnd; continue; }; if (ctok.depth === baseDepth && ctok.type === 'keyword' && ctok.upper === 'FOR') { var forEnd = formatterFindClauseBoundary(coreTokens, cp + 1, end, baseDepth); lines = lines.concat(commentLines(ctx, cp, indent)); lines.push(fmtSpaces(indent) + formatterJoinFlat(coreTokens, cp, forEnd, ctx)); cp = forEnd; continue; }; if (ctok.depth === baseDepth && ctok.type === 'keyword' && ctok.upper === 'OPTION') { var optEnd = formatterFindClauseBoundary(coreTokens, cp + 1, end, baseDepth); lines = lines.concat(commentLines(ctx, cp, indent)); lines.push(fmtSpaces(indent) + formatterJoinFlat(coreTokens, cp, optEnd, ctx)); cp = optEnd; continue; }; cp++; }; return lines; }; function formatterIsLiteralType(core, start, end) { if (start >= end || !(core[start].type === 'identifier' || core[start].type === 'keyword') || isKw(core[start], 'SELECT')) return false; for (var k = start + 1; k < end; k++) { var t = core[k]; var isPart = t.type === 'identifier' || t.type === 'number' || (t.type === 'punct' && (t.value === '(' || t.value === ')' || t.value === ',')); if (!isPart) return false; }; return true; }; function formatterIsLiteralExpr(core, start, end) { if (start >= end) return false; var t = core[start]; if (end - start === 1) return t.type === 'string' || t.type === 'number' || isKw(t, 'NULL'); if (end - start === 2) return t.type === 'operator' && (t.value === '-' || t.value === '+') && core[start + 1].type === 'number'; if (t.quote || !(t.type === 'identifier' || t.type === 'keyword') || !isPunctToken(core[start + 1], '(') || formatterMatchParenIdx(core, start + 1) !== end - 1) return false; var depth = core[start + 1].depth + 1; if (t.upper === 'CAST' || t.upper === 'TRY_CAST') { var asIdx = -1; for (var k = start + 2; k < end - 1 && asIdx === -1; k++) { if (core[k].depth === depth && isKw(core[k], 'AS')) asIdx = k; }; return asIdx > start + 2 && formatterIsLiteralExpr(core, start + 2, asIdx) && formatterIsLiteralType(core, asIdx + 1, end - 1); }; if (t.upper === 'CONVERT' || t.upper === 'TRY_CONVERT') { var parts = formatterSplitCommaList(core, start + 2, end - 1, depth); if (parts.length < 2 || parts.length > 3) return false; return formatterIsLiteralType(core, parts[0].start, parts[0].end) && formatterIsLiteralExpr(core, parts[1].start, parts[1].end) && (parts.length === 2 || (parts[2].end - parts[2].start === 1 && core[parts[2].start].type === 'number')); }; return false; }; function formatterIsSampleRows(ctx, branches) { var core = ctx.core; if (branches.length < 2) return false; return branches.every(function (br, n) { if (n > 0 && br.op.text !== 'UNION ALL') return false; if (!isKw(core[br.start], 'SELECT')) return false; var items = formatterSplitCommaList(core, br.start + 1, br.end, core[br.start].depth); return items.every(function (it) { if (it.end <= it.start) return false; var alias = formatterDetectTrailingAlias(core, it.start, it.end); if (alias && n > 0) return false; return formatterIsLiteralExpr(core, it.start, alias ? alias.exprEnd : it.end); }); }); }; function formatterFormatStatement(ctx, start, end, indent, isOwnStatement) { var coreTokens = ctx.core; if (start >= end) return []; var baseDepth = coreTokens[start].depth; var branches = []; var segStart = start; var pendingOp = null; var i = start; while (i < end) { var t = coreTokens[i]; if (t.depth === baseDepth && t.type === 'keyword' && FORMATTER_SETOPS[t.upper]) { branches.push({ start: segStart, end: i, op: pendingOp }); var opText = t.upper; var opIdx = i; i++; if (opText === 'UNION' && coreTokens[i] && coreTokens[i].type === 'keyword' && coreTokens[i].upper === 'ALL') { opText = 'UNION ALL'; i++; }; pendingOp = { text: opText, idx: opIdx }; segStart = i; continue; }; i++; }; branches.push({ start: segStart, end: end, op: pendingOp }); var isRows = formatterIsSampleRows(ctx, branches); var lines = []; branches.forEach(function (br) { if (br.op && isRows) { lines = lines.concat(commentLines(ctx, br.op.idx, indent)); var rowNotes = [takeInlineComments(ctx, br.op.idx + 1, br.end), formatterTakeSameLineComments(ctx, br.end)].filter(function (note) { return !!note; }); lines.push(fmtSpaces(indent) + br.op.text + ' SELECT ' + formatterJoinFlat(coreTokens, br.start + 1, br.end, ctx) + (rowNotes.length ? ' ' + rowNotes.join(' ') : '')); return; }; if (br.op) { if (isOwnStatement) lines.push(''); lines = lines.concat(commentLines(ctx, br.op.idx, indent)); lines.push(fmtSpaces(indent) + br.op.text); if (isOwnStatement) lines.push(''); }; lines = lines.concat(formatterFormatOneSelect(ctx, br.start, br.end, indent)); }); return lines; }; function sameTokensBag(sqlStr) { var toks = tokenize(sqlStr); var coreTokens = []; for (var i = 0; i < toks.length; i++) { if (toks[i].type !== 'comment') coreTokens.push(toks[i]); }; var overrides = {}; for (var k = 0; k < coreTokens.length; k++) { var t = coreTokens[k]; if (!((t.type === 'identifier' || t.type === 'keyword') && !t.quote)) continue; var next = coreTokens[k + 1]; if (FORMATTER_DATE_FUNCS[t.upper] && next && next.type === 'punct' && next.value === '(') { var argTok = coreTokens[k + 2]; if (argTok && argTok.type === 'identifier' && !argTok.quote) { overrides[k + 2] = FORMATTER_DATE_PART_MAP[argTok.upper] || argTok.upper; }; }; }; var bag = {}; coreTokens.forEach(function (tok, idx) { var key; if (overrides[idx] !== undefined) { key = 'W:' + overrides[idx]; } else if (tok.type === 'keyword') { key = 'K:' + tok.upper; } else if (tok.type === 'identifier' && !tok.quote) { key = 'I:' + tok.upper; } else if (tok.type === 'identifier') { key = 'Q:' + tok.raw; } else if (tok.type === 'operator') { key = 'O:' + (tok.value === '<>' ? '!=' : tok.value); } else { key = tok.type.charAt(0).toUpperCase() + ':' + tok.raw; }; bag[key] = (bag[key] || 0) + 1; }); return bag; }; var SAME_TOKENS_ALLOWED_EXTRA = { 'K:AS': true, 'K:INNER': true }; function sameTokens(a, b) { try { var bagA = sameTokensBag(a == null ? '' : String(a)); var bagB = sameTokensBag(b == null ? '' : String(b)); var key; for (key in bagA) { if (!Object.prototype.hasOwnProperty.call(bagA, key)) continue; if ((bagB[key] || 0) < bagA[key]) return false; }; for (key in bagB) { if (!Object.prototype.hasOwnProperty.call(bagB, key)) continue; var diff = bagB[key] - (bagA[key] || 0); if (diff > 0 && !SAME_TOKENS_ALLOWED_EXTRA[key]) return false; }; return true; } catch (e) { return false; }; }; function sameQueryKeys(sqlStr) { return sameQueryKeyList(sqlStr).keys; }; function sameQueryTokenKeys(core) { var keys = []; var opens = []; for (var k = 0; k < core.length; k++) { var tok = core[k]; var next = core[k + 1]; var key; if (tok.type === 'keyword' || tok.type === 'identifier') { if (tok.type === 'keyword') { if (tok.upper === 'AS' && next && next.type === 'keyword' && next.upper === 'SELECT') { keys.push('W:AS'); continue; }; if (tok.upper === 'AS' || ((tok.upper === 'INNER' || tok.upper === 'OUTER') && next && next.type === 'keyword' && next.upper === 'JOIN')) { keys.push(null); continue; }; }; var isDatePart = tok.type === 'identifier' && !tok.quote && k >= 2 && core[k - 1].type === 'punct' && core[k - 1].value === '(' && (core[k - 2].type === 'identifier' || core[k - 2].type === 'keyword') && !core[k - 2].quote && !!FORMATTER_DATE_FUNCS[core[k - 2].upper]; key = 'W:' + (isDatePart ? (FORMATTER_DATE_PART_MAP[tok.upper] || tok.upper) : tok.upper); } else if (tok.type === 'operator') { key = isColumnStar(core, k) ? 'X:*' : 'O:' + (tok.value === '<>' ? '!=' : tok.value); } else if (tok.type === 'temp') { key = 'T:' + tok.upper; } else { key = tok.type.charAt(0).toUpperCase() + ':' + tok.raw; if (tok.type === 'punct' && tok.value === '(') opens.push(k > 0 && core[k - 1].type === 'keyword' && core[k - 1].upper === 'AS'); if (tok.type === 'punct' && tok.value === ')' && opens.pop() === true) key = 'P:)c'; }; keys.push(key); }; return keys; }; function isColumnStar(core, k) { if (core[k].value !== '*') return false; var prev = core[k - 1]; if (!prev) return false; if (prev.type === 'keyword' && (prev.upper === 'SELECT' || prev.upper === 'DISTINCT' || prev.upper === 'ALL' || prev.upper === 'PERCENT' || prev.upper === 'TIES')) return true; if (prev.type === 'punct' && (prev.value === ',' || prev.value === '.')) return true; if (prev.type === 'number') return !!core[k - 2] && core[k - 2].type === 'keyword' && core[k - 2].upper === 'TOP'; if (prev.type === 'punct' && prev.value === ')') { var balance = 0; for (var j = k - 1; j >= 0; j--) { if (core[j].type !== 'punct') continue; if (core[j].value === ')') balance++; if (core[j].value === '(' && --balance === 0) return j > 0 && core[j - 1].type === 'keyword' && core[j - 1].upper === 'TOP'; }; }; return false; }; function sameQueryKeyList(sqlStr) { var core = withoutComments(tokenize(sqlStr)); var tokenKeys = sameQueryTokenKeys(core); var keys = []; var starts = []; for (var k = 0; k < core.length; k++) { if (tokenKeys[k] === null) continue; keys.push(tokenKeys[k]); starts.push(core[k].start); }; while (keys.length && keys[keys.length - 1] === 'P:;') { keys.pop(); starts.pop(); }; return { keys: keys, starts: starts }; }; var SAME_QUERY_TEXT_START = '^'; var SAME_QUERY_TEXT_END = '$'; var SAME_QUERY_JOINS_SELECT = { 'P:(': true, 'P:)c': true, 'P:,': true, 'W:UNION': true, 'W:ALL': true, 'W:EXCEPT': true, 'W:INTERSECT': true, 'W:AS': true }; function isStatementBreakBefore(key, firstKey) { if (key === SAME_QUERY_TEXT_START || key === 'P:;') return true; if (firstKey !== 'W:SELECT' && firstKey !== 'W:WITH') return false; return !SAME_QUERY_JOINS_SELECT[key] && key.indexOf('O:') !== 0; }; function isStatementBreakAfter(key, nextKey) { return key === SAME_QUERY_TEXT_END || key === 'P:;' || key === 'W:GO' || isStatementStart(key, nextKey); }; function isStatementStart(key, nextKey) { return key === 'W:SELECT' || (key === 'W:WITH' && nextKey !== 'P:('); }; var STATEMENT_BODY_STARTERS = { INSERT: true, MERGE: true, CREATE: true, ALTER: true, DECLARE: true, IF: true, WHILE: true, ELSE: true, BEGIN: true, RETURN: true }; function isCteHead(core, idx) { var name = core[idx + 1]; var after = core[idx + 2]; if (!name || !after) return false; if (!(name.type === 'identifier' || (name.type === 'keyword' && name.upper !== 'TIES'))) return false; return (after.type === 'keyword' && after.upper === 'AS') || (after.type === 'punct' && after.value === '('); }; function statementSegments(sql, core) { var keys = sameQueryTokenKeys(core); var segments = []; var goLines = []; var open = -1; var depth = 0; var before = SAME_QUERY_TEXT_START; var isBodyAhead = false; for (var i = 0; i < core.length; i++) { var tok = core[i]; if (isGoLine(sql, core, i)) { if (open !== -1) segments.push({ start: open, end: i, hasSemi: false }); open = -1; depth = 0; before = SAME_QUERY_TEXT_START; goLines.push(i); continue; }; if (depth === 0 && tok.type === 'punct' && tok.value === ';') { segments.push({ start: open === -1 ? i : open, end: i, hasSemi: true }); open = -1; before = 'P:;'; continue; }; var isStartHere = open !== -1 && depth === 0 && tok.type === 'keyword' && isStatementStart(keys[i], keys[i + 1]) && (tok.upper === 'SELECT' || isCteHead(core, i)); if (isStartHere && isBodyAhead) { isBodyAhead = false; } else if (isStartHere && isStatementBreakBefore(before, keys[i])) { segments.push({ start: open, end: i, hasSemi: false }); open = -1; }; if (open === -1) { open = i; isBodyAhead = tok.type === 'keyword' && STATEMENT_BODY_STARTERS[tok.upper] === true; }; if (tok.type === 'punct') { if (tok.value === '(') depth++; else if (tok.value === ')' && depth > 0) depth--; }; if (keys[i] !== null) before = keys[i]; }; if (open !== -1) segments.push({ start: open, end: core.length, hasSemi: false }); return { segments: segments, goLines: goLines }; }; function mayHoldSeveralStatements(sql) { if (sql.indexOf(';') !== -1 || /\bgo\b/i.test(sql)) return true; var words = sql.match(/\b(?:select|with)\b/gi); if (!words) return false; if (words.length > 1) return true; var lead = /^(?:\s+|--[^\n]*|\/\*[\s\S]*?\*\/)*/.exec(sql)[0].length; return sql.search(/\b(?:select|with)\b/i) > lead; }; function statementLayout(sql) { sql = sql == null ? '' : String(sql); var tokens = tokenize(sql); var core = withoutComments(tokens); var cut = statementSegments(sql, core); var statements = []; cut.segments.forEach(function (seg) { if (seg.end <= seg.start) return; var lastTok = seg.hasSemi ? core[seg.end] : core[seg.end - 1]; statements.push({ start: core[seg.start].start, end: lastTok.end, codeStart: core[seg.start].start, codeEnd: lastTok.end, firstEnd: core[seg.start].end }); }); var goLines = cut.goLines.map(function (g) { return { start: core[g].start, end: core[g].end }; }); function isGoBetween(from, to) { return goLines.some(function (g) { return g.start >= from && g.start < to; }); }; var claims = statements.map(function (st) { return { start: st.start, end: st.end }; }); tokens.forEach(function (c) { if (c.type !== 'comment') return; var before = null; var after = null; var isInside = false; for (var i = 0; i < statements.length; i++) { var st = statements[i]; if (st.codeStart <= c.start && c.end <= st.codeEnd) { isInside = true; break; }; if (st.codeEnd <= c.start) before = i; if (st.codeStart >= c.end && after === null) after = i; }; if (isInside) return; var canBefore = before !== null && !isGoBetween(statements[before].codeEnd, c.start); var canAfter = after !== null && !isGoBetween(c.end, statements[after].codeStart); var owner = null; if (canBefore && canAfter) { owner = sql.slice(statements[before].codeEnd, c.start).indexOf('\n') === -1 ? before : after; } else if (canBefore) { owner = before; } else if (canAfter) { owner = after; }; if (owner === null) return; claims[owner].start = Math.min(claims[owner].start, c.start); claims[owner].end = Math.max(claims[owner].end, c.end); }); statements.forEach(function (st, i) { st.start = claims[i].start; st.end = claims[i].end; }); return { statements: statements, goLines: goLines }; }; function splitStatements(sql) { try { return statementLayout(sql).statements.map(function (st) { return { start: st.start, end: st.end, codeStart: st.codeStart, codeEnd: st.codeEnd }; }); } catch (e) { return []; }; }; function cursorScope(sql, offset) { var layout; try { if (!mayHoldSeveralStatements(sql)) return null; layout = statementLayout(sql); } catch (e) { return null; }; var list = layout.statements; if (list.length < 2 && !layout.goLines.length) return null; offset = Math.max(0, Math.min(offset | 0, sql.length)); var at = -1; for (var i = 0; i < list.length; i++) { if (list[i].start <= offset) at = i; }; var previous = at === -1 ? null : list[at]; var following = list[at + 1] || null; if (previous && offset <= previous.end) return { start: previous.start, end: previous.end }; var isGoBetween = function (from, to) { return layout.goLines.some(function (g) { return g.start >= from && g.start < to; }); }; if (previous && !isGoBetween(previous.end, offset)) return { start: previous.start, end: offset }; if (following && !isGoBetween(offset, following.start)) return { start: offset, end: following.end }; return { start: offset, end: offset }; }; function sameQuery(ranSql, editorSql, scope) { try { if (typeof ranSql !== 'string' || typeof editorSql !== 'string') return false; var ran = sameQueryKeys(ranSql); var editor = sameQueryKeys(editorSql); if (!ran.length) return !editor.length; var hasNeighbours = !!scope && typeof scope === 'object' && typeof scope.before === 'string' && typeof scope.after === 'string'; for (var start = 0; start + ran.length <= editor.length; start++) { var end = start + ran.length; var before = start > 0 ? editor[start - 1] : SAME_QUERY_TEXT_START; var after = end < editor.length ? editor[end] : SAME_QUERY_TEXT_END; var isBreakAfter = isStatementBreakAfter(after, editor[end + 1]); if (hasNeighbours) { if (before !== scope.before && !(isStatementBreakBefore(scope.before, ran[0]) && isStatementBreakBefore(before, ran[0]))) continue; if (after !== scope.after && !(isStatementBreakAfter(scope.after) && isBreakAfter)) continue; } else if (!scope) { if (!isStatementBreakBefore(before, ran[0]) || !isBreakAfter) continue; }; var at = 0; while (at < ran.length && editor[start + at] === ran[at]) at++; if (at === ran.length) return true; }; return false; } catch (e) { return false; }; }; function runScope(ranSql, text, offset) { try { if (typeof ranSql !== 'string' || typeof text !== 'string') return false; if (ranSql === text) return false; var ran = sameQueryKeys(ranSql); var list = sameQueryKeyList(text); var editor = list.keys; if (!ran.length) return true; var found = -1; for (var start = 0; start + ran.length <= editor.length; start++) { var at = 0; while (at < ran.length && editor[start + at] === ran[at]) at++; if (at !== ran.length) continue; if (found === -1) found = start; if (typeof offset === 'number' && list.starts[start] >= offset) { found = start; break; }; }; if (found === -1) return true; var end = found + ran.length; var before = found > 0 ? editor[found - 1] : SAME_QUERY_TEXT_START; var isBreakAfter = end >= editor.length || isStatementBreakAfter(editor[end], editor[end + 1]); if (isStatementBreakBefore(before, ran[0]) && isBreakAfter) return false; return { before: before, after: isBreakAfter ? SAME_QUERY_TEXT_END : editor[end] }; } catch (e) { return true; }; }; var FORMATTER_PAREN_OPERAND_REASON = 'This query has a SELECT in parentheses beside UNION, EXCEPT or INTERSECT, which Format does not lay out yet, so it left it as it is.'; var FORMATTER_PAREN_EXTRA_REASON = 'This query has a SELECT in extra parentheses, which Format does not lay out yet, so it left it as it is.'; function formatterParenQueryReason(core, segments) { var starts = {}; segments.forEach(function (seg) { starts[seg.start] = true; }); var isOperatorWord = function (t) { return !!t && t.type === 'keyword' && SET_OPERATOR_WORDS[t.upper] === 1; }; var hasExtra = false; for (var i = 0; i < core.length; i++) { var t = core[i]; if (t.type !== 'punct' || t.value !== '(' || parenRunToSelect(core, i, core.length) === -1) continue; var prev = core[i - 1]; var isStart = starts[i] === true; var isAfterOperator = isOperatorWord(prev) || (isKw(prev, 'ALL') && isKw(core[i - 2], 'UNION')); if (isAfterOperator) return FORMATTER_PAREN_OPERAND_REASON; var isInParen = !!prev && prev.type === 'punct' && prev.value === '('; if (!isStart && !isInParen) continue; var close = parenCloseIndex(core, i, i + 1, core.length); if (close !== -1 && isOperatorWord(core[close + 1])) return FORMATTER_PAREN_OPERAND_REASON; if (isStart) hasExtra = true; }; return hasExtra ? FORMATTER_PAREN_EXTRA_REASON : null; }; function formatterHasOrderByBefore(core, k) { var depth = core[k].depth; for (var j = k - 1; j > 0; j--) { if (core[j].depth !== depth) continue; if (isKw(core[j], 'SELECT')) return false; if (isKw(core[j], 'BY') && isKw(core[j - 1], 'ORDER')) return true; }; return false; }; function formatterIsPagingOffset(core, k) { var t = core[k]; if (!t || k < 1 || t.type !== 'identifier' || t.quote || t.upper !== 'OFFSET') return false; var before = core[k - 1]; if (!(formatterIsOperandEnd(before) || isKw(before, 'ASC') || isKw(before, 'DESC') || isKw(before, 'END') || isKw(before, 'NULL'))) return false; return formatterHasOrderByBefore(core, k); }; function formatterIsTopCount(core, k) { if (isKw(core[k - 1], 'TOP')) return true; if (!(core[k].type === 'punct' && core[k].value === ')')) return false; for (var j = k - 1; j >= 0; j--) { if (core[j].type === 'punct' && core[j].value === '(' && core[j].depth === core[k].depth) return isKw(core[j - 1], 'TOP'); }; return false; }; function formatSqlInner(sql) { if (sql === '') return { sql: '', changed: false }; var allTokens = tokenize(sql); if (!formatterTokensValid(allTokens)) return { sql: sql, changed: false, reason: 'This query has a text, a comment or a ( that is not closed, so Format left it as it is.' }; var extracted = formatterExtractComments(allTokens); var coreTokens = extracted.core; if (!coreTokens.length) return { sql: sql, changed: false }; for (var wi = 0; wi < coreTokens.length; wi++) { var wt = coreTokens[wi]; if (wt.type === 'identifier' && !wt.quote && wt.upper === 'WINDOW' && coreTokens[wi + 1] && coreTokens[wi + 1].type === 'identifier' && coreTokens[wi + 2] && coreTokens[wi + 2].type === 'keyword' && coreTokens[wi + 2].upper === 'AS' && coreTokens[wi + 3] && coreTokens[wi + 3].type === 'punct' && coreTokens[wi + 3].value === '(') return { sql: sql, changed: false, reason: 'This query has a WINDOW clause, which Marketing Cloud Engagement refuses, so Format left it as it is. Its MCE075 fix writes each window into its OVER.' }; }; for (var gi = 0; gi < coreTokens.length; gi++) { if (isGoLine(sql, coreTokens, gi)) return { sql: sql, changed: false, reason: 'This query has a GO line, which Format would turn into an alias, so it left it as it is.' }; }; var ctx = { core: coreTokens, overrides: formatterComputeOverrides(coreTokens), funcSet: getFormatterFunctionSet(), commentsBefore: extracted.commentsBefore, commentCols: extracted.commentCols, commentsSameLine: extracted.sameLine, trailing: extracted.trailing, trailingSameLine: extracted.trailingSameLine }; var segs = statementSegments(sql, coreTokens).segments; var nonEmptySegs = segs.filter(function (s) { return s.end > s.start; }); if (!nonEmptySegs.length) return { sql: sql, changed: false }; var parenQueryReason = formatterParenQueryReason(coreTokens, nonEmptySegs); for (var vi = 0; vi < nonEmptySegs.length; vi++) { if (!(coreTokens[nonEmptySegs[vi].start].type === 'keyword' && coreTokens[nonEmptySegs[vi].start].upper === 'SELECT')) { return { sql: sql, changed: false, reason: parenQueryReason || 'Format lays out a query that starts with SELECT, so it left this one as it is.' }; }; }; for (var si0 = 1; si0 < coreTokens.length; si0++) { var before0 = coreTokens[si0 - 1]; if (!(coreTokens[si0].type === 'punct' && coreTokens[si0].value === '(') || !isKw(coreTokens[si0 + 1], 'SELECT')) continue; var isAfterValue = ['identifier', 'number', 'string', 'variable'].indexOf(before0.type) !== -1 || (before0.type === 'punct' && before0.value === ')'); var isCountWord = before0.type === 'identifier' && !before0.quote && (before0.upper === 'OFFSET' || ((before0.upper === 'NEXT' || before0.upper === 'FIRST') && isKw(coreTokens[si0 - 2], 'FETCH'))) && formatterHasOrderByBefore(coreTokens, si0 - 1); if (!isAfterValue || isCountWord || formatterIsTopCount(coreTokens, si0 - 1)) continue; return { sql: sql, changed: false, reason: 'This query has a SELECT in parentheses right after a name, where a second query or a stray ( stands, so Format left it as it is.' }; }; var blocks = []; var shown = segs.filter(function (seg) { return seg.end > seg.start; }); shown.forEach(function (seg, si) { var segLines; try { segLines = formatterFormatStatement(ctx, seg.start, seg.end, 0, true); } catch (layoutError) { if (parenQueryReason) { var bail = new Error('formatter: a SELECT in parentheses'); bail.formatterReason = parenQueryReason; throw bail; }; throw layoutError; }; if (seg.hasSemi && segLines.length) segLines[segLines.length - 1] += ';'; if (segLines.length) { var notes = []; if (seg.hasSemi) notes.push(formatterTakeSameLineComments(ctx, seg.end)); if (si + 1 < shown.length) notes.push(formatterTakeSameLineComments(ctx, shown[si + 1].start)); else if (seg.hasSemi) notes.push(formatterTakeSameLineComments(ctx, coreTokens.length)); notes = notes.filter(function (note) { return !!note; }); if (notes.length) segLines[segLines.length - 1] += ' ' + notes.join(' '); }; blocks.push(segLines); }); var lines = []; blocks.forEach(function (blockLines, bi) { if (bi > 0) lines.push(''); lines = lines.concat(blockLines); }); var closing = commentLinesRange(ctx, 0, coreTokens.length, 0).concat(ctx.trailing || []); var closingRows = closing.reduce(function (rows, note) { return rows + note.split('\n').length; }, 0); if (closingRows >= 2 && lines.length) lines.push(''); lines = lines.concat(closing); var out = lines.map(function (l) { return l.replace(/[ \t]+$/, ''); }).join('\n'); return { sql: out, changed: out !== sql }; }; function formatSql(sql) { try { sql = sql == null ? '' : String(sql); var result = formatSqlInner(sql); if (!result.changed) return result; if (sameTokens(sql, result.sql)) return result; return { sql: sql, changed: false, skipped: true, reason: 'This query uses a construct the formatter does not support yet, so it was left unchanged.' }; } catch (e) { return { sql: sql, changed: false, reason: (e && e.formatterReason) || 'Format could not read this query, so it left it as it is.' }; }; }; var STRICT_DOCS_URL = 'https://help.salesforce.com/s/articleView?id=mktg.mc_as_using_the_query_activity.htm&type=5'; var STRICT_COMPARISON_OPS = { '=': 1, '<>': 1, '!=': 1, '<': 1, '>': 1, '<=': 1, '>=': 1, '!<': 1, '!>': 1 }; var STRICT_BIN_CONTINUATION = { AND: 1, OR: 1, NOT: 1, IN: 1, LIKE: 1, BETWEEN: 1 }; var STRICT_JOIN_QUALIFIERS = { INNER: 1, LEFT: 1, RIGHT: 1, FULL: 1, OUTER: 1, CROSS: 1 }; var STRICT_SETOPS = { UNION: 1, EXCEPT: 1, INTERSECT: 1 }; var STRICT_KEYWORD_FUNCTIONS = { LEFT: 1, RIGHT: 1, COALESCE: 1, NULLIF: 1 }; var STRICT_KEYWORD_LITERALS = { CURRENT_TIMESTAMP: 1, CURRENT_USER: 1, CURRENT_DATE: 1, CURRENT_TIME: 1, SESSION_USER: 1, SYSTEM_USER: 1, USER: 1 }; function StrictError(err) { this.err = err; } function strictNearDesc(tok) { if (!tok || tok.__eof) return 'the end of the query'; if (tok.type === 'keyword') return tok.upper; return "'" + tok.value + "'"; }; function strictMkErr(lineIndex, startTok, endTok, message, hint, fix) { var s = startTok.start, e = (endTok || startTok).end; if (e < s) e = s; var a = lineColAt(lineIndex, s), b = lineColAt(lineIndex, e); var err = { start: s, end: e, line: a.line, col: a.col, endLine: b.line, endCol: b.col, message: message, hint: hint || '' }; if (fix) err.fix = fix; return err; }; function StrictParser(tokens, start, end, sql, lineIndex) { this.tokens = tokens; this.pos = start; this.end = end; this.sql = sql; this.lineIndex = lineIndex; this._prevTok = null; this.exprFallback = 'An expression is missing here. Add a column, value or expression'; this.queryParenDepth = 0; }; StrictParser.prototype.isSubqueryStart = function () { return this.isKeyword('SELECT') || this.isKeyword('WITH') || (this.isPunct('(') && parenHoldsQuery(this.tokens, this.pos - 1, this.end)); }; StrictParser.prototype.cur = function () { return this.pos < this.end ? this.tokens[this.pos] : null; }; StrictParser.prototype.at = function (offset) { var i = this.pos + offset; return (i >= this.pos && i < this.end) ? this.tokens[i] : null; }; StrictParser.prototype.isWord = function (word) { var t = this.cur(); return !!t && t.upper === word && (t.type === 'keyword' || t.type === 'identifier'); }; StrictParser.prototype.isWordAny = function (words) { var t = this.cur(); if (!t || (t.type !== 'keyword' && t.type !== 'identifier')) return false; return words.indexOf(t.upper) !== -1; }; StrictParser.prototype.isWordAt = function (offset, word) { var t = this.at(offset); return !!t && t.upper === word && (t.type === 'keyword' || t.type === 'identifier'); }; StrictParser.prototype.isKeyword = function (word) { return this.isWord(word) && !this.cur().quote; }; StrictParser.prototype.isKeywordAny = function (words) { return this.isWordAny(words) && !this.cur().quote; }; StrictParser.prototype.isPunct = function (v) { var t = this.cur(); return !!t && t.type === 'punct' && t.value === v; }; StrictParser.prototype.isPunctAt = function (offset, v) { var t = this.at(offset); return !!t && t.type === 'punct' && t.value === v; }; StrictParser.prototype.isOp = function (v) { var t = this.cur(); return !!t && t.type === 'operator' && t.value === v; }; StrictParser.prototype.advance = function () { var t = this.cur(); if (t) { this.pos++; this._prevTok = t; }; return t; }; StrictParser.prototype.eofToken = function () { var t = this.tokens[this.end]; var pos, line, col; if (t) { pos = t.start; line = t.line; col = t.col; } else { var last = this.tokens[this.end - 1]; pos = last ? last.end : 0; var lc = lineColAt(this.lineIndex, pos); line = lc.line; col = lc.col; }; return { start: pos, end: pos, line: line, col: col, value: '', __eof: true }; }; StrictParser.prototype.setFallback = function (hint) { var prev = this.exprFallback; this.exprFallback = hint; return prev; }; StrictParser.prototype.restoreFallback = function (prev) { this.exprFallback = prev; }; StrictParser.prototype.fail = function (tok, hint) { var t = tok || this.eofToken(); var near = strictNearDesc(t); var full = (hint || 'This is not valid here. Fix the syntax') + ', near ' + near + '.'; throw new StrictError(strictMkErr(this.lineIndex, t, t, full, hint || '')); }; StrictParser.prototype.failWithFix = function (tok, hint, fix) { var t = tok || this.eofToken(); throw new StrictError(strictMkErr(this.lineIndex, t, t, hint, '', fix)); }; StrictParser.prototype.failBare = function (startTok, endTok, message) { throw new StrictError(strictMkErr(this.lineIndex, startTok, endTok || startTok, message, '')); }; StrictParser.prototype.failExpected = function (fallbackHint) { var tok = this.cur() || this.eofToken(); var prev = this._prevTok; var isOpLike = !!prev && (prev.type === 'operator' || (prev.type === 'keyword' && STRICT_BIN_CONTINUATION[prev.upper])); var hint = isOpLike ? ("The query ends right after '" + prev.value + "'. Add the missing value or column") : (fallbackHint || 'An expression is missing here. Add a column, value or expression'); this.fail(tok, hint); }; StrictParser.prototype.expectWord = function (word, hint) { if (this.isWord(word)) return this.advance(); this.fail(this.cur(), hint); }; StrictParser.prototype.failNonBoolean = function (tok) { var t = tok || this.eofToken(); var hint = 'Compare it to something, for example a = 1'; var full = 'This is a value, not a yes-or-no condition. ' + hint + ', near ' + strictNearDesc(t) + '.'; throw new StrictError(strictMkErr(this.lineIndex, t, t, full, hint)); }; StrictParser.prototype.expectCloseParen = function (openTok) { if (this.isPunct(')')) return this.advance(); var t = this.cur(); if (!t) this.failBare(openTok, openTok, 'This ( from line ' + openTok.line + ' is never closed. Add ) to close it, near the end of the query.'); if (t.type === 'keyword' || t.type === 'identifier') { this.failBare(t, t, 'SQL Studio did not expect ' + t.raw + ' here. Check the arguments before it, or add ) to close the ( from line ' + openTok.line + '.'); }; this.fail(t, 'This ( from line ' + openTok.line + ' is missing its closing ). Add )'); }; StrictParser.prototype.parseOrExpr = function (isEnforced) { var isBooleanShaped = this.parseAndExpr(isEnforced); while (this.isKeyword('OR')) { this.advance(); var r = this.parseAndExpr(isEnforced); isBooleanShaped = isEnforced ? true : (isBooleanShaped && r); }; return isBooleanShaped; }; StrictParser.prototype.parseAndExpr = function (isEnforced) { var isBooleanShaped = this.parseNotExpr(isEnforced); while (this.isKeyword('AND')) { this.advance(); var r = this.parseNotExpr(isEnforced); isBooleanShaped = isEnforced ? true : (isBooleanShaped && r); }; return isBooleanShaped; }; StrictParser.prototype.parseNotExpr = function (isEnforced) { if (this.isKeyword('NOT')) { this.advance(); this.parseNotExpr(isEnforced); return true; }; var tok = this.cur(); var isBooleanShaped = this.parsePredicate(isEnforced); if (isEnforced && !isBooleanShaped) this.failNonBoolean(tok); return isEnforced ? true : isBooleanShaped; }; StrictParser.prototype.parsePredicate = function (isEnforced) { var leftR = this.parseBitwiseOr(isEnforced); if (this.isKeyword('NOT') && (this.isWordAt(1, 'BETWEEN') || this.isWordAt(1, 'IN') || this.isWordAt(1, 'LIKE'))) { this.advance(); }; if (this.isKeyword('BETWEEN')) { this.advance(); var fb1 = this.setFallback('BETWEEN needs a value after it. Add a value'); this.parseBitwiseOr(false); this.restoreFallback(fb1); this.expectWord('AND', 'BETWEEN needs AND after the low value. Add AND'); var fb2 = this.setFallback('BETWEEN needs a high value after AND. Add a value'); this.parseBitwiseOr(false); this.restoreFallback(fb2); return true; }; if (this.isKeyword('IN')) { this.advance(); if (!this.isPunct('(')) this.fail(this.cur(), 'IN needs a parenthesized list or subquery. Add ('); var openTok = this.advance(); if (this.isSubqueryStart()) { this.parseStatementBody(); } else { var fb3 = this.setFallback('The IN list is missing a value. Add a value'); this.parseOrExpr(false); while (this.isPunct(',')) { this.advance(); this.parseOrExpr(false); }; this.restoreFallback(fb3); }; this.expectCloseParen(openTok); return true; }; if (this.isKeyword('LIKE')) { this.advance(); var fb4 = this.setFallback('LIKE needs a pattern to match. Add a pattern'); this.parseBitwiseOr(false); this.restoreFallback(fb4); if (this.isWord('ESCAPE')) { this.advance(); var fb5 = this.setFallback('ESCAPE needs the character it defines. Add an escape character'); this.parseBitwiseOr(false); this.restoreFallback(fb5); }; return true; }; if (this.isKeyword('IS')) { this.advance(); if (this.isWord('NOT')) this.advance(); if (this.isWord('DISTINCT') && this.isWordAt(1, 'FROM')) { this.advance(); this.advance(); var fbDistinct = this.setFallback('IS DISTINCT FROM needs a value after it. Add a value or column'); this.parseBitwiseOr(false); this.restoreFallback(fbDistinct); return true; }; this.expectWord('NULL', 'IS must be followed by NULL or NOT NULL. Add NULL'); return true; }; var t = this.cur(); if (t && t.type === 'operator' && STRICT_COMPARISON_OPS[t.value]) { this.advance(); if (this.isWordAny(['ALL', 'ANY', 'SOME']) && this.isPunctAt(1, '(')) { this.advance(); var openTok2 = this.advance(); if (this.isSubqueryStart()) { this.parseStatementBody(); } else { var fb6 = this.setFallback('ALL, ANY and SOME need a subquery. Add SELECT'); this.parseOrExpr(false); this.restoreFallback(fb6); }; this.expectCloseParen(openTok2); } else { var fb7 = this.setFallback('This comparison is missing its right side. Add a value or column'); this.parseBitwiseOr(false); this.restoreFallback(fb7); }; return true; }; return leftR.pure && leftR.bool; }; StrictParser.prototype.parseBitwiseOr = function (isEnforced) { var r = this.parseBitwiseXor(isEnforced); var isPureOperand = r.pure, isBooleanShaped = r.bool; while (this.isOp('|')) { this.advance(); this.parseBitwiseXor(false); isPureOperand = false; isBooleanShaped = false; }; return { pure: isPureOperand, bool: isBooleanShaped }; }; StrictParser.prototype.parseBitwiseXor = function (isEnforced) { var r = this.parseBitwiseAnd(isEnforced); var isPureOperand = r.pure, isBooleanShaped = r.bool; while (this.isOp('^')) { this.advance(); this.parseBitwiseAnd(false); isPureOperand = false; isBooleanShaped = false; }; return { pure: isPureOperand, bool: isBooleanShaped }; }; StrictParser.prototype.parseBitwiseAnd = function (isEnforced) { var r = this.parseAdditive(isEnforced); var isPureOperand = r.pure, isBooleanShaped = r.bool; while (this.isOp('&')) { this.advance(); this.parseAdditive(false); isPureOperand = false; isBooleanShaped = false; }; return { pure: isPureOperand, bool: isBooleanShaped }; }; StrictParser.prototype.parseAdditive = function (isEnforced) { var r = this.parseMultiplicative(isEnforced); var isPureOperand = r.pure, isBooleanShaped = r.bool; while (this.isOp('+') || this.isOp('-') || this.isOp('||')) { this.advance(); this.parseMultiplicative(false); isPureOperand = false; isBooleanShaped = false; }; return { pure: isPureOperand, bool: isBooleanShaped }; }; StrictParser.prototype.parseMultiplicative = function (isEnforced) { var r = this.parseUnary(isEnforced); var isPureOperand = r.pure, isBooleanShaped = r.bool; while (this.isOp('*') || this.isOp('/') || this.isOp('%')) { this.advance(); this.parseUnary(false); isPureOperand = false; isBooleanShaped = false; }; return { pure: isPureOperand, bool: isBooleanShaped }; }; StrictParser.prototype.parseUnary = function (isEnforced) { if (this.isOp('-') || this.isOp('+') || this.isOp('~')) { this.advance(); this.parseUnary(false); return { pure: false, bool: false }; } return this.parsePostfix(isEnforced); }; StrictParser.prototype.parsePostfix = function (isEnforced) { var r = this.parsePrimary(isEnforced); var isPureOperand = r.pure, isBooleanShaped = r.bool; for (;;) { if (this.isKeyword('COLLATE')) { this.advance(); var ct = this.cur(); if (!ct || ct.type !== 'identifier') this.fail(ct, 'COLLATE needs a collation name. Add a collation name'); this.advance(); isPureOperand = false; isBooleanShaped = false; continue; }; if (this.isWord('AT') && this.isWordAt(1, 'TIME') && this.isWordAt(2, 'ZONE')) { this.advance(); this.advance(); this.advance(); var fb = this.setFallback('AT TIME ZONE needs a time zone value. Add a time zone'); this.parseBitwiseOr(false); this.restoreFallback(fb); isPureOperand = false; isBooleanShaped = false; continue; }; break; }; return { pure: isPureOperand, bool: isBooleanShaped }; }; StrictParser.prototype.parsePrimary = function (isEnforced) { var t = this.cur(); if (!t) { this.failExpected(this.exprFallback); return { pure: true, bool: false }; } if (t.type === 'number') { this.advance(); return { pure: true, bool: false }; } if (t.type === 'string') { if (t.unterminated) this.failBare(t, t, 'This string starting on line ' + t.line + ' is never closed. Add the missing closing quote.'); this.advance(); return { pure: true, bool: false }; }; if (t.type === 'variable') { this.advance(); return { pure: true, bool: false }; } if (this.isWord('NULL')) { this.advance(); return { pure: true, bool: false }; } if (this.isKeyword('CASE')) { this.parseCaseExpr(); return { pure: true, bool: false }; } if (this.isKeyword('EXISTS')) { this.parseExistsExpr(); return { pure: true, bool: true }; } if (this.isWord('CAST') || this.isWord('TRY_CAST')) { this.parseCastExpr(); this.parseMethodCalls(); return { pure: true, bool: false }; } if (this.isWord('CONVERT') || this.isWord('TRY_CONVERT')) { this.parseConvertExpr(); this.parseMethodCalls(); return { pure: true, bool: false }; } if (this.isWord('PARSE') || this.isWord('TRY_PARSE')) { this.parseParseExpr(); return { pure: true, bool: false }; } if (t.type === 'punct' && t.value === '(') { var group = this.parseParenExpr(isEnforced); return this.parseMethodCalls() ? { pure: true, bool: false } : group; }; if (t.type === 'identifier' || isUnreservedKeyword(t)) { var isCondition = this.parseIdentifierPrimary(); return { pure: true, bool: isCondition }; }; if (t.type === 'keyword' && STRICT_KEYWORD_FUNCTIONS[t.upper] && this.isPunctAt(1, '(')) { this.parseIdentifierPrimary(); return { pure: true, bool: false }; } if (t.type === 'keyword' && STRICT_KEYWORD_LITERALS[t.upper]) { this.advance(); return { pure: true, bool: false }; } this.failExpected(this.exprFallback); return { pure: true, bool: false }; }; StrictParser.prototype.parseParenExpr = function (isEnforced) { var openTok = this.advance(); var isBooleanShaped = false; if (this.isSubqueryStart()) { this.parseStatementBody(); } else { var contentStart = this.pos; var fb = this.setFallback('This ( is missing its content. Add a value or expression'); isBooleanShaped = this.parseOrExpr(false); if (isEnforced && !isBooleanShaped && !this.isValueContinuationAfterClose()) { this.pos = contentStart; this._prevTok = openTok; isBooleanShaped = this.parseOrExpr(true); }; this.restoreFallback(fb); }; this.expectCloseParen(openTok); return { pure: true, bool: isBooleanShaped }; }; StrictParser.prototype.isValueContinuationAfterClose = function () { var next = this.pos + 1 < this.end ? this.tokens[this.pos + 1] : null; if (!next) return false; if (next.type === 'operator') return true; if (next.type === 'punct' && next.value === '.' && this.isMethodCallAt(1)) return true; return next.type === 'keyword' && ['IS', 'BETWEEN', 'IN', 'LIKE', 'NOT', 'COLLATE', 'AT'].indexOf(next.upper) !== -1; }; StrictParser.prototype.isMethodCallAt = function (offset) { var name = this.at(offset + 1); return this.isPunctAt(offset, '.') && !!name && name.type === 'identifier' && this.isPunctAt(offset + 2, '('); }; StrictParser.prototype.parseMethodCalls = function () { var isRead = false; while (this.isMethodCallAt(0)) { this.advance(); // . var name = this.advance(); var openTok = this.advance(); this.parseArgList(name.upper); this.expectCloseParen(openTok); isRead = true; }; return isRead; }; StrictParser.prototype.parseIdentifierPrimary = function () { var chainLast = this.advance(); var isQualified = false; while (this.isPunct('.')) { isQualified = true; this.advance(); var nt = this.cur(); if (!nt || !(nt.type === 'identifier' || nt.type === 'number' || isUnreservedKeyword(nt))) this.fail(nt, "A name is missing after '.'. Add the column or table name"); chainLast = this.advance(); }; var isPredicateCall = false; if (this.isPunct('(')) { var openTok = this.advance(); this.parseArgList(chainLast.upper); this.expectCloseParen(openTok); if (this.parseMethodCalls()) return false; isPredicateCall = !isQualified && chainLast.upper === 'REGEXP_LIKE'; if (this.isKeyword('WITHIN')) { this.advance(); this.expectWord('GROUP', 'WITHIN needs GROUP after it. Add GROUP'); if (!this.isPunct('(')) this.fail(this.cur(), 'WITHIN GROUP needs a parenthesized ORDER BY. Add ('); var wOpen = this.advance(); this.expectWord('ORDER', 'WITHIN GROUP needs an ORDER BY clause. Add ORDER BY'); this.expectWord('BY', 'ORDER must be followed by BY. Add BY'); this.parseOrderByList('ORDER BY needs a column or expression. Add a column or expression'); this.expectCloseParen(wOpen); }; if (this.isWordAny(['IGNORE', 'RESPECT']) && this.isWordAt(1, 'NULLS') && this.isWordAt(2, 'OVER')) { this.advance(); this.advance(); }; if (this.isKeyword('OVER')) { this.advance(); if (this.cur() && this.cur().type === 'identifier' && !this.cur().quote) { this.advance(); isPredicateCall = false; } else { if (!this.isPunct('(')) this.fail(this.cur(), 'OVER needs a parenthesized window definition. Add ('); var oOpen = this.advance(); this.parseOverBody(); this.expectCloseParen(oOpen); isPredicateCall = false; }; }; }; return isPredicateCall; }; StrictParser.prototype.parseArgList = function (functionName) { var isJsonObject = functionName === 'JSON_OBJECT' || functionName === 'JSON_OBJECTAGG'; var isJsonConstructor = isJsonObject || functionName === 'JSON_ARRAY' || functionName === 'JSON_ARRAYAGG'; var self = this; var skipNullClause = function () { if (!isJsonConstructor || !(self.isWord('NULL') || self.isWord('ABSENT')) || !self.isWordAt(1, 'ON') || !self.isWordAt(2, 'NULL')) return false; self.advance(); self.advance(); self.advance(); return true; }; var isReturningClause = function (isAfterArguments) { var typeTok = self.at(1); if (!JSON_RETURNING_FUNCTIONS[functionName] || !self.isWord('RETURNING') || !typeTok) return false; if ((typeTok.type === 'identifier' || typeTok.type === 'keyword') && RETURNING_TYPES[typeTok.upper]) return true; return isAfterArguments && typeTok.type === 'punct' && typeTok.value === ')'; }; var skipReturning = function () { if (!isReturningClause(true)) return; self.advance(); self.parseTypeName(); }; if (this.isPunct(')')) return; if (isReturningClause(false)) { skipReturning(); return; }; if (skipNullClause()) { skipReturning(); return; }; if (functionName === 'TRIM') { if (this.isKeywordAny(['LEADING', 'TRAILING', 'BOTH']) && !this.isPunctAt(1, ')') && !this.isPunctAt(1, ',')) this.advance(); if (this.isKeyword('FROM')) this.advance(); var fbTrim = this.setFallback('TRIM needs the text to trim. Add a value or column'); this.parseOrExpr(); this.restoreFallback(fbTrim); if (this.isWord('FROM')) { this.advance(); var fbTrimFrom = this.setFallback('TRIM needs the text after FROM. Add a value or column'); this.parseOrExpr(); this.restoreFallback(fbTrimFrom); }; return; }; var isFirstArgument = true; for (;;) { if (this.isOp('*') && this.isPunctAt(1, ')')) { this.advance(); } else if (isFirstArgument && (this.isKeyword('DISTINCT') || this.isKeyword('ALL'))) { var quantifier = this.advance(); var fb1 = this.setFallback(quantifier.upper + ' needs an argument after it. Add an expression'); this.parseOrExpr(); this.restoreFallback(fb1); if (this.isPunct(',')) this.fail(this.cur(), (functionName ? functionName + '(' + quantifier.upper + ' ...)' : quantifier.upper) + ' takes one value. Remove the values after the comma'); } else { var fb2 = this.setFallback('This function call is missing an argument. Add an argument or )'); this.parseOrExpr(); this.restoreFallback(fb2); var colon = this.cur(); if (isJsonObject && colon && colon.value === ':') { this.advance(); var fb3 = this.setFallback('This ' + functionName + ' key has no value after its colon. Add a value'); this.parseOrExpr(); this.restoreFallback(fb3); }; if (functionName === 'JSON_ARRAYAGG' && this.isWord('ORDER') && this.isWordAt(1, 'BY')) { this.advance(); this.advance(); this.parseOrderByList('ORDER BY needs a column or expression. Add a column or expression'); }; }; if (skipNullClause()) break; isFirstArgument = false; if (this.isPunct(',')) { var argComma = this.advance(); if (this.isPunct(')')) { this.failWithFix(argComma, 'This argument list has a trailing comma before the closing parenthesis. Remove the trailing comma, near \')\'.', { title: 'Remove trailing comma', edits: [{ start: argComma.start, end: argComma.end, text: '' }], isSafe: true, isInFixAll: true }); }; continue; }; break; }; skipReturning(); }; StrictParser.prototype.isWindowDefinitionAt = function (offset) { var name = this.at(offset); var open = this.at(offset + 2); return !!name && name.type === 'identifier' && !name.quote && this.isWordAt(offset + 1, 'AS') && !!open && open.type === 'punct' && open.value === '('; }; StrictParser.prototype.isWindowClauseStart = function () { var t = this.cur(); return !!t && t.type === 'identifier' && !t.quote && t.upper === 'WINDOW' && this.isWindowDefinitionAt(1); }; StrictParser.prototype.parseOverBody = function () { if (this.cur() && this.cur().type === 'identifier' && !this.cur().quote) this.advance(); if (this.isWord('PARTITION')) { this.advance(); this.expectWord('BY', 'PARTITION must be followed by BY. Add BY'); this.parseExprListReq('PARTITION BY needs a column or expression. Add a column or expression'); }; if (this.isWord('ORDER')) { this.advance(); this.expectWord('BY', 'ORDER must be followed by BY. Add BY'); this.parseOrderByList('ORDER BY needs a column or expression. Add a column or expression'); }; if (this.isWordAny(['ROWS', 'RANGE'])) { this.parseFrameClause(); }; }; StrictParser.prototype.parseFrameClause = function () { this.advance(); // ROWS or RANGE if (this.isWord('BETWEEN')) { this.advance(); this.parseFrameBound(); this.expectWord('AND', 'BETWEEN in a window frame needs AND. Add AND'); this.parseFrameBound(); } else { this.parseFrameBound(); }; }; StrictParser.prototype.parseFrameBound = function () { if (this.isWord('UNBOUNDED')) { this.advance(); if (this.isWordAny(['PRECEDING', 'FOLLOWING'])) { this.advance(); return; }; this.fail(this.cur(), 'UNBOUNDED needs PRECEDING or FOLLOWING after it. Add PRECEDING or FOLLOWING'); return; }; if (this.isWord('CURRENT')) { this.advance(); this.expectWord('ROW', 'CURRENT must be followed by ROW. Add ROW'); return; }; var fb = this.setFallback('This window frame is missing a bound. Add a row count or UNBOUNDED'); this.parseBitwiseOr(); this.restoreFallback(fb); if (this.isWordAny(['PRECEDING', 'FOLLOWING'])) { this.advance(); return; }; this.fail(this.cur(), 'This frame bound needs PRECEDING or FOLLOWING. Add PRECEDING or FOLLOWING'); }; StrictParser.prototype.parseCaseExpr = function () { var caseTok = this.advance(); // CASE var isSearched = this.isKeyword('WHEN'); if (!isSearched) { var fb0 = this.setFallback('CASE needs WHEN, or a value to compare before WHEN. Add WHEN or a value'); this.parseOrExpr(false); this.restoreFallback(fb0); }; var hasWhen = false; while (this.isWord('WHEN')) { hasWhen = true; this.advance(); var fb1 = this.setFallback(isSearched ? 'WHEN needs a condition after it. Add a condition' : 'WHEN needs a value to compare after it. Add a value'); this.parseOrExpr(isSearched); this.restoreFallback(fb1); this.expectWord('THEN', 'WHEN needs THEN after its condition. Add THEN'); var fb2 = this.setFallback('THEN needs a result value. Add a value or expression'); this.parseOrExpr(false); this.restoreFallback(fb2); }; if (!hasWhen) this.fail(this.cur(), 'CASE needs at least one WHEN. Add WHEN'); if (this.isWord('ELSE')) { this.advance(); var fb3 = this.setFallback('ELSE needs a result value. Add a value or expression'); this.parseOrExpr(false); this.restoreFallback(fb3); }; if (!this.isWord('END')) { this.failBare(caseTok, this._prevTok || caseTok, 'This CASE starting on line ' + caseTok.line + ' is missing END. Add END to close it.'); }; this.advance(); // END }; StrictParser.prototype.parseExistsExpr = function () { this.advance(); // EXISTS if (!this.isPunct('(')) this.fail(this.cur(), 'EXISTS needs a parenthesized subquery. Add ('); var openTok = this.advance(); if (this.isSubqueryStart()) { this.parseStatementBody(); } else { this.fail(this.cur(), 'EXISTS needs a SELECT inside its parentheses. Add SELECT'); }; this.expectCloseParen(openTok); }; StrictParser.prototype.parseCastExpr = function () { this.advance(); // CAST or TRY_CAST if (!this.isPunct('(')) this.fail(this.cur(), 'CAST needs its argument in parentheses. Add ('); var openTok = this.advance(); var fb = this.setFallback('This ( is missing its content. Add a value or expression'); this.parseOrExpr(); this.restoreFallback(fb); this.expectWord('AS', 'CAST needs AS before the target type. Add AS'); this.parseTypeName(); this.expectCloseParen(openTok); }; StrictParser.prototype.parseConvertExpr = function () { this.advance(); // CONVERT or TRY_CONVERT if (!this.isPunct('(')) this.fail(this.cur(), 'CONVERT needs its arguments in parentheses. Add ('); var openTok = this.advance(); this.parseTypeName(); if (!this.isPunct(',')) this.fail(this.cur(), 'CONVERT needs a comma after the type. Add ,'); this.advance(); var fb = this.setFallback('CONVERT needs the value to convert. Add a value or expression'); this.parseOrExpr(); if (this.isPunct(',')) { this.advance(); this.parseOrExpr(); }; this.restoreFallback(fb); this.expectCloseParen(openTok); }; StrictParser.prototype.parseParseExpr = function () { this.advance(); // PARSE or TRY_PARSE if (!this.isPunct('(')) this.fail(this.cur(), 'PARSE needs its argument in parentheses. Add ('); var openTok = this.advance(); var fb = this.setFallback('This ( is missing its content. Add a value or expression'); this.parseOrExpr(); this.restoreFallback(fb); this.expectWord('AS', 'PARSE needs AS before the target type. Add AS'); this.parseTypeName(); if (this.isWord('USING')) { this.advance(); var fb2 = this.setFallback('USING needs a culture value. Add a culture value'); this.parseOrExpr(); this.restoreFallback(fb2); }; this.expectCloseParen(openTok); }; StrictParser.prototype.skipBalancedParenContent = function (openTok) { var depth = 1; for (;;) { var t = this.cur(); if (!t) { this.failBare(openTok, openTok, 'This ( from line ' + openTok.line + ' is never closed. Add ) to close it, near the end of the query.'); return; }; if (t.type === 'punct' && t.value === '(') { depth++; this.advance(); continue; }; if (t.type === 'punct' && t.value === ')') { this.advance(); depth--; if (depth === 0) return; continue; }; this.advance(); }; }; StrictParser.prototype.parseTypeName = function () { if (this.isWord('DOUBLE') && this.isWordAt(1, 'PRECISION')) { this.advance(); this.advance(); return; }; if (this.isWord('NATIONAL') && (this.isWordAt(1, 'CHAR') || this.isWordAt(1, 'CHARACTER') || this.isWordAt(1, 'TEXT'))) this.advance(); var t = this.cur(); if (!t || !(t.type === 'identifier' || (t.type === 'keyword' && RETURNING_TYPES[t.upper]))) this.fail(t, 'A data type is missing here. Add a data type such as VARCHAR or INT'); this.advance(); if ((t.upper === 'CHAR' || t.upper === 'CHARACTER' || t.upper === 'BINARY') && !t.quote && this.isWord('VARYING')) this.advance(); if (this.isPunct('(')) { var openTok = this.advance(); if (this.isWord('MAX')) { this.advance(); } else { var nt = this.cur(); if (!nt || nt.type !== 'number') this.fail(nt, 'This type needs a length, precision or MAX. Add a number or MAX'); this.advance(); if (this.isPunct(',')) { this.advance(); var st = this.cur(); if (!st || st.type !== 'number') this.fail(st, 'This type needs a scale after the comma. Add a number'); this.advance(); }; }; this.expectCloseParen(openTok); }; }; StrictParser.prototype.isListTerminator = function () { var t = this.cur(); if (!t) return true; if (t.type === 'punct' && (t.value === ')' || t.value === ';')) return true; return isBoundaryKeyword(t, this.at(1)); }; StrictParser.prototype.failTrailingListComma = function (commaTok) { this.failWithFix(commaTok, 'This list has a trailing comma. Remove the trailing comma, near \',\'.', { title: 'Remove trailing comma', edits: [{ start: commaTok.start, end: commaTok.end, text: '' }], isSafe: true, isInFixAll: true }); }; StrictParser.prototype.parseExprListReq = function (fallbackHint) { var fb = this.setFallback(fallbackHint); this.parseOrExpr(); while (this.isPunct(',')) { var listComma = this.advance(); if (this.isListTerminator()) { this.restoreFallback(fb); this.failTrailingListComma(listComma); }; this.parseOrExpr(); }; this.restoreFallback(fb); }; StrictParser.prototype.parseOrderByList = function (fallbackHint) { var fb = this.setFallback(fallbackHint || 'ORDER BY needs a column or expression. Add a column or expression'); this.parseOrExpr(); if (this.isWordAny(['ASC', 'DESC'])) this.advance(); while (this.isPunct(',')) { var listComma = this.advance(); if (this.isListTerminator()) { this.restoreFallback(fb); this.failTrailingListComma(listComma); }; this.parseOrExpr(); if (this.isWordAny(['ASC', 'DESC'])) this.advance(); }; this.restoreFallback(fb); }; StrictParser.prototype.isGroupingComposite = function () { var close = matchParen(this.tokens, this.pos); if (close + 1 >= this.end) return true; var after = this.tokens[close + 1]; return (after.type === 'punct' && (after.value === ',' || after.value === ')' || after.value === ';')) || isBoundaryKeyword(after, this.tokens[close + 2]); }; StrictParser.prototype.parseGroupingElements = function (openTok, parseElement) { parseElement.call(this); while (this.isPunct(',')) { var elementComma = this.advance(); if (this.isPunct(')')) this.failTrailingListComma(elementComma); parseElement.call(this); }; this.expectCloseParen(openTok); }; StrictParser.prototype.parseGroupingExpression = function () { if (this.isPunct('(') && this.isGroupingComposite()) { var compositeOpen = this.advance(); var fb = this.setFallback('This ( is missing its content. Add a value or expression'); this.parseGroupingElements(compositeOpen, this.parseGroupingColumn); this.restoreFallback(fb); return; }; this.parseGroupingColumn(); }; StrictParser.prototype.parseGroupingColumn = function () { var t = this.cur(); var isNested = !!t && !t.quote && ((this.isWordAny(['ROLLUP', 'CUBE']) && this.isPunctAt(1, '(')) || (this.isWord('GROUPING') && this.isWordAt(1, 'SETS') && this.isPunctAt(2, '('))); if (isNested) this.fail(t, 'T-SQL takes only columns inside ROLLUP, CUBE or a set of columns, not another ' + (t.upper === 'GROUPING' ? 'GROUPING SETS' : t.upper) + '. List its columns instead'); this.parseOrExpr(); }; StrictParser.prototype.parseRollupOrCube = function () { this.advance(); // ROLLUP or CUBE var openTok = this.advance(); if (this.isPunct(')')) { this.advance(); return; }; var fb = this.setFallback('This function call is missing an argument. Add an argument or )'); this.parseGroupingElements(openTok, this.parseGroupingExpression); this.restoreFallback(fb); }; StrictParser.prototype.parseGroupingSet = function () { if (this.isPunct('(') && this.isPunctAt(1, ')')) { this.advance(); this.advance(); return; }; if (this.isPunct('(') && this.isGroupingComposite()) { var setOpen = this.advance(); this.parseGroupingElements(setOpen, this.parseGroupingSetItem); return; }; this.parseGroupingSetItem(); }; StrictParser.prototype.parseGroupingSetItem = function () { if (this.isWordAny(['ROLLUP', 'CUBE']) && this.isPunctAt(1, '(')) { this.parseRollupOrCube(); return; }; if (this.isWord('GROUPING') && this.isWordAt(1, 'SETS') && this.isPunctAt(2, '(')) { this.fail(this.cur(), 'T-SQL takes no GROUPING SETS inside GROUPING SETS. List its sets in the outer one'); }; this.parseGroupingExpression(); }; StrictParser.prototype.parseGroupingSets = function () { this.advance(); // GROUPING this.advance(); // SETS var openTok = this.advance(); if (this.isPunct(')')) this.fail(this.cur(), 'GROUPING SETS needs at least one set in its parentheses. Add a column, or () for the grand total'); var fb = this.setFallback('This GROUPING SETS list is missing a set. Add a column, or () for the grand total'); this.parseGroupingElements(openTok, this.parseGroupingSet); this.restoreFallback(fb); }; StrictParser.prototype.parseGroupByItem = function () { if (this.isWordAny(['ROLLUP', 'CUBE']) && this.isPunctAt(1, '(')) { this.parseRollupOrCube(); return; }; if (this.isWord('GROUPING') && this.isWordAt(1, 'SETS') && this.isPunctAt(2, '(')) { this.parseGroupingSets(); return; }; if (this.isPunct('(') && this.isPunctAt(1, ')')) { var after = this.at(2); if (!after || (after.type === 'punct' && (after.value === ',' || after.value === ')' || after.value === ';')) || isBoundaryKeyword(after, this.at(3))) { this.advance(); this.advance(); return; }; }; this.parseOrExpr(); }; StrictParser.prototype.isGroupingConstructAt = function () { return (this.isWordAny(['ROLLUP', 'CUBE']) && this.isPunctAt(1, '(')) || (this.isWord('GROUPING') && this.isWordAt(1, 'SETS') && this.isPunctAt(2, '(')) || (this.isPunct('(') && this.isPunctAt(1, ')')); }; StrictParser.prototype.parseGroupByList = function () { var fb = this.setFallback('GROUP BY needs a column or expression. Add a column or expression'); var allTok = this.isWord('ALL') ? this.advance() : null; var hasConstruct = false; var parseItem = function (self) { if (self.isGroupingConstructAt()) { if (allTok) self.fail(self.cur(), 'GROUP BY ALL takes no ROLLUP, CUBE or GROUPING SETS. Remove ALL'); hasConstruct = true; }; self.parseGroupByItem(); }; parseItem(this); while (this.isPunct(',')) { var listComma = this.advance(); if (this.isListTerminator()) { this.restoreFallback(fb); this.failTrailingListComma(listComma); }; parseItem(this); }; this.restoreFallback(fb); if (this.isWord('WITH') && (this.isWordAt(1, 'ROLLUP') || this.isWordAt(1, 'CUBE'))) { if (allTok) this.fail(this.cur(), 'GROUP BY ALL takes no WITH ROLLUP or WITH CUBE. Remove ALL'); if (hasConstruct) this.fail(this.cur(), 'WITH ROLLUP and WITH CUBE take no ROLLUP, CUBE or GROUPING SETS in the list. Remove the WITH'); this.advance(); this.advance(); }; }; StrictParser.prototype.parseSelectItem = function () { if (this.isOp('*')) { this.advance(); return; }; if (this.cur() && (this.cur().type === 'identifier' || isUnreservedKeyword(this.cur())) && this.isPunctAt(1, '.') && this.at(2) && this.at(2).type === 'operator' && this.at(2).value === '*') { this.advance(); this.advance(); this.advance(); return; }; if (this.cur() && (this.cur().type === 'identifier' || isUnreservedKeyword(this.cur()) || this.cur().type === 'string') && this.at(1) && this.at(1).type === 'operator' && this.at(1).value === '=') { this.advance(); // alias name this.advance(); // '=' var fb = this.setFallback("This alias assignment is missing its value after '='. Add an expression"); this.parseOrExpr(); this.restoreFallback(fb); return; }; this.parseOrExpr(); if (this.isKeyword('AS')) { this.advance(); var t = this.cur(); if (!t || !(t.type === 'identifier' || t.type === 'keyword' || (t.type === 'string' && !t.unterminated))) this.fail(t, 'AS needs an alias name after it. Add an alias'); this.advance(); } else { var t2 = this.cur(); if (t2 && (t2.type === 'identifier' || isUnreservedKeyword(t2))) this.advance(); }; }; StrictParser.prototype.parseSelectList = function () { this.parseSelectItem(); while (this.isPunct(',')) { this.advance(); if (this.isPunct(',')) { var secondComma = this.cur(); this.failWithFix(secondComma, 'Two commas in a row leave an empty column here. Remove the extra comma, near \',\'.', { title: 'Remove extra comma', edits: [{ start: secondComma.start, end: secondComma.end, text: '' }], isSafe: true, isInFixAll: true }); }; this.parseSelectItem(); }; }; StrictParser.prototype.parseTableSource = function (afterText) { if (this.isPunct('(')) { var openTok = this.advance(); var isQuery = false; if (this.isSubqueryStart()) { this.parseStatementBody(); isQuery = true; } else if (this.isKeyword('VALUES')) { this.parseValuesRows(); } else if (this.isNestedJoinStart()) { this.parseNestedJoin(openTok, afterText); return; } else { this.fail(this.cur(), 'A derived table needs a SELECT inside its parentheses. Add SELECT'); }; this.expectCloseParen(openTok); var asTok = this.isKeyword('AS') ? this.advance() : null; var t = this.cur(); if (t && (t.type === 'identifier' || isUnreservedKeyword(t))) { this.advance(); if (this.isPunct('(')) this.parseColumnAliasList(); } else if (isQuery || asTok) { this.fail(t, asTok ? 'AS needs a table alias after it. Add an alias' : 'A derived table needs a name after its closing parenthesis. Add AS and a name, such as AS d'); }; return; }; var nt = this.cur(); if (!nt || !(nt.type === 'identifier' || nt.type === 'number' || isUnreservedKeyword(nt))) { this.fail(nt, afterText + ' needs a table name after it. Add a table or Data Extension name'); }; this.advance(); while (this.isPunct('.')) { this.advance(); var dt = this.cur(); if (!dt || !(dt.type === 'identifier' || dt.type === 'number' || isUnreservedKeyword(dt))) this.fail(dt, "A name is missing after '.'. Add the column or table name"); this.advance(); }; var isFunctionCall = false; if (this.isPunct('(')) { isFunctionCall = true; var fOpen = this.advance(); this.parseArgList(); this.expectCloseParen(fOpen); if (this.isWord('WITH') && this.isPunctAt(1, '(')) { this.advance(); var wOpen = this.advance(); this.skipBalancedParenContent(wOpen); }; }; var hasAlias = false; if (this.isKeyword('AS')) { this.advance(); var at = this.cur(); if (!at || !(at.type === 'identifier' || isUnreservedKeyword(at))) this.fail(at, 'AS needs a table alias after it. Add an alias'); this.advance(); hasAlias = true; } else { var at2 = this.cur(); if (at2 && (at2.type === 'identifier' || (isUnreservedKeyword(at2) && at2.upper !== 'APPLY')) && !this.isWindowClauseStart()) { this.advance(); hasAlias = true; }; }; var hasSample = false; if (!isFunctionCall && this.isWord('TABLESAMPLE')) { this.parseTableSample(); hasSample = true; }; if (hasAlias && !hasSample && this.isPunct('(')) { var oldHintOpen = this.advance(); this.parseTableHintList(); this.expectCloseParen(oldHintOpen); }; if (this.isWord('WITH') && this.isPunctAt(1, '(')) { this.advance(); var hOpen = this.advance(); this.parseTableHintList(); this.expectCloseParen(hOpen); }; }; StrictParser.prototype.isNestedJoinStart = function () { var t = this.cur(); if (!t) return false; return t.type === 'identifier' || (t.type === 'punct' && t.value === '('); }; StrictParser.prototype.parseNestedJoin = function (openTok, afterText) { var isInnerGroup = this.isPunct('(') && !!this.at(1) && (this.at(1).type === 'identifier' || (this.at(1).type === 'punct' && this.at(1).value === '(')); this.parseTableSource(afterText); this.parsePivotOrUnpivotOpt(); if (isInnerGroup && this.isPunct(')')) { this.expectCloseParen(openTok); return; }; if (!this.isJoinStart()) this.fail(this.cur(), 'Tables in parentheses need a JOIN between them. Add a join, or remove the parentheses'); this.parseJoinChain(); this.expectCloseParen(openTok); }; StrictParser.prototype.parseTableSample = function () { this.advance(); // TABLESAMPLE if (this.isWord('SYSTEM')) this.advance(); if (!this.isPunct('(')) this.fail(this.cur(), 'TABLESAMPLE needs its size in parentheses. Add ( and a number'); var sizeOpen = this.advance(); var fbSize = this.setFallback('TABLESAMPLE needs a number of percent or rows. Add a number'); this.parseOrExpr(); this.restoreFallback(fbSize); if (this.isWord('PERCENT') || this.isWord('ROWS')) this.advance(); this.expectCloseParen(sizeOpen); if (this.isWord('REPEATABLE')) { this.advance(); if (!this.isPunct('(')) this.fail(this.cur(), 'REPEATABLE needs its seed in parentheses. Add ( and a number'); var seedOpen = this.advance(); var fbSeed = this.setFallback('REPEATABLE needs a seed number. Add a number'); this.parseOrExpr(); this.restoreFallback(fbSeed); this.expectCloseParen(seedOpen); }; }; StrictParser.prototype.parseValuesRows = function () { this.advance(); // VALUES for (;;) { if (!this.isPunct('(')) this.fail(this.cur(), 'VALUES needs each row in parentheses. Add ('); var rowOpen = this.advance(); this.parseExprListReq('This VALUES row is missing a value. Add a value'); this.expectCloseParen(rowOpen); if (this.isPunct(',')) { this.advance(); continue; }; break; }; }; StrictParser.prototype.parseColumnAliasList = function () { var listOpen = this.advance(); // ( for (;;) { var ct = this.cur(); if (!ct || ct.type !== 'identifier') this.fail(ct, 'This column list is missing a name. Add a column name'); this.advance(); if (this.isPunct(',')) { this.advance(); continue; }; break; }; this.expectCloseParen(listOpen); }; StrictParser.prototype.parseTableHintList = function () { for (;;) { if (this.isWord('INDEX') && this.isPunctAt(1, '(')) { this.advance(); var openTok = this.advance(); this.skipBalancedParenContent(openTok); } else { var t = this.cur(); if (!t || !(t.type === 'identifier' || t.type === 'keyword')) this.fail(t, 'This table hint is missing its name. Add a hint such as NOLOCK'); this.advance(); }; if (this.isPunct(',')) { this.advance(); continue; }; break; }; }; StrictParser.prototype.isJoinStart = function () { return this.isWordAny(['INNER', 'LEFT', 'RIGHT', 'FULL', 'OUTER', 'CROSS', 'JOIN', 'APPLY']); }; StrictParser.prototype.parsePivotOrUnpivotOpt = function () { if (!(this.isWord('PIVOT') || this.isWord('UNPIVOT'))) return; this.advance(); if (!this.isPunct('(')) this.fail(this.cur(), 'PIVOT and UNPIVOT need their body in parentheses. Add ('); var openTok = this.advance(); this.skipBalancedParenContent(openTok); if (this.isWord('AS')) this.advance(); var t = this.cur(); if (t && t.type === 'identifier') this.advance(); }; var STRICT_JOIN_TYPES = { '': 1, INNER: 1, LEFT: 1, 'LEFT OUTER': 1, RIGHT: 1, 'RIGHT OUTER': 1, FULL: 1, 'FULL OUTER': 1, CROSS: 1 }; var STRICT_APPLY_TYPES = { CROSS: 1, OUTER: 1 }; var STRICT_NESTED_JOIN_MAX = 32; StrictParser.prototype.parseJoinChain = function (isOne) { while (this.isJoinStart()) { var firstTok = this.cur(); var qualifiers = []; while (this.isWordAny(['INNER', 'LEFT', 'RIGHT', 'FULL', 'OUTER', 'CROSS'])) { qualifiers.push(this.advance().upper); }; var typeWords = qualifiers.join(' '); if (this.isWordAny(['LOOP', 'HASH', 'MERGE', 'REMOTE']) && !this.cur().quote && this.isWordAt(1, 'JOIN') && (qualifiers.indexOf('INNER') !== -1 || qualifiers.indexOf('LEFT') !== -1 || qualifiers.indexOf('RIGHT') !== -1 || qualifiers.indexOf('FULL') !== -1)) { var hintTok = this.cur(); if (hintTok.upper === 'LOOP' && (qualifiers.indexOf('RIGHT') !== -1 || qualifiers.indexOf('FULL') !== -1)) { this.fail(hintTok, 'LOOP cannot go with a RIGHT or FULL join. Use HASH or MERGE, or remove the hint'); }; if (hintTok.upper === 'REMOTE' && qualifiers.indexOf('INNER') === -1) { this.fail(hintTok, 'REMOTE goes with INNER JOIN only. Remove REMOTE, or make the join INNER'); }; qualifiers.push(this.advance().upper); }; var kind; if (this.isWord('JOIN')) { this.advance(); kind = 'JOIN'; } else if (this.isWord('APPLY')) { this.advance(); kind = 'APPLY'; } else { this.fail(this.cur(), qualifiers.join(' ') + ' needs JOIN or APPLY after it. Add JOIN or APPLY'); return; }; if (kind === 'JOIN' && typeWords === 'OUTER') this.fail(firstTok, 'OUTER JOIN needs LEFT, RIGHT or FULL before OUTER. Add LEFT, RIGHT or FULL'); if (kind === 'JOIN' && !STRICT_JOIN_TYPES[typeWords]) this.fail(firstTok, typeWords + ' JOIN is not a join type. Use INNER JOIN, LEFT JOIN, RIGHT JOIN, FULL JOIN or CROSS JOIN'); if (kind === 'APPLY' && !STRICT_APPLY_TYPES[typeWords]) this.fail(firstTok, (typeWords ? typeWords + ' ' : '') + 'APPLY is not a join type. Use CROSS APPLY or OUTER APPLY'); var text = qualifiers.length ? (qualifiers.join(' ') + ' ' + kind) : kind; this.parseTableSource(text); var doesRequireOn = kind === 'JOIN' && qualifiers.indexOf('CROSS') === -1; if (doesRequireOn) { if (!this.isWord('ON') && !this.parseNestedJoins()) { var onTok = this.cur(); this.fail(onTok, text + ' needs an ON condition before ' + strictNearDesc(onTok) + '. Add ON with the join condition'); }; this.advance(); var fb = this.setFallback('ON needs a join condition. Add a condition such as a.Key = b.Key'); this.parseOrExpr(true); this.restoreFallback(fb); }; if (isOne) return; }; }; StrictParser.prototype.parseNestedJoins = function () { var depth = this.nestedJoinDepth || 0; if (!this.isJoinStart() || depth >= STRICT_NESTED_JOIN_MAX) return false; var pos = this.pos; var prevTok = this._prevTok; var fallback = this.exprFallback; var parenDepth = this.queryParenDepth; var isRead = false; this.nestedJoinDepth = depth + 1; try { while (!this.isWord('ON') && this.isJoinStart()) this.parseJoinChain(true); isRead = this.isWord('ON'); } catch (e) { if (!(e instanceof StrictError)) { this.nestedJoinDepth = depth; throw e; }; }; this.nestedJoinDepth = depth; if (isRead) return true; this.pos = pos; this._prevTok = prevTok; this.exprFallback = fallback; this.queryParenDepth = parenDepth; return false; }; StrictParser.prototype.parseFromClause = function () { this.parseTableSource('FROM'); this.parsePivotOrUnpivotOpt(); this.parseJoinChain(); while (this.isPunct(',')) { this.advance(); this.parseTableSource('FROM'); this.parsePivotOrUnpivotOpt(); this.parseJoinChain(); }; }; StrictParser.prototype.constantCountAt = function (from) { var i = from; var j = this.pos; var sign = 1; var signTok = null; for (;;) { var first = this.tokens[i]; var last = this.tokens[j - 1]; if (j - i >= 3 && first.type === 'punct' && first.value === '(' && last.type === 'punct' && last.value === ')') { var isOneGroup = true; for (var k = i + 1; k < j - 1; k++) { if (this.tokens[k].depth <= first.depth) isOneGroup = false; }; if (!isOneGroup) return null; i++; j--; continue; }; if (j - i >= 2 && first.type === 'operator' && (first.value === '-' || first.value === '+')) { if (first.value === '-') sign = -sign; if (!signTok) signTok = first; i++; continue; }; break; }; var tok = this.tokens[i]; if (j - i !== 1 || tok.type !== 'number' || !/^(?:\d+\.?\d*|\.\d+)$/.test(tok.value)) return null; return { value: sign * parseFloat(tok.value), tok: signTok || tok }; }; StrictParser.prototype.parseSelectStatement = function () { if (!this.isWord('SELECT')) this.fail(this.cur(), 'This query is missing SELECT. Add SELECT'); this.advance(); if (this.isKeywordAny(['DISTINCT', 'ALL'])) this.advance(); var topTok = null; var tiesTok = null; if (this.isKeyword('TOP')) { topTok = this.advance(); var countFrom = this.pos; if (this.isPunct('(')) { var openTok = this.advance(); if (this.isSubqueryStart()) { this.parseStatementBody(); } else { var fb1 = this.setFallback('TOP ( needs a number of rows. Add a number'); this.parseOrExpr(); this.restoreFallback(fb1); }; this.expectCloseParen(openTok); } else { var fb2 = this.setFallback('TOP needs a number of rows. Add a number'); this.parseUnary(); this.restoreFallback(fb2); }; if (this.isKeyword('PERCENT')) { var percent = this.constantCountAt(countFrom); if (percent && (percent.value < 0 || percent.value > 100)) this.fail(percent.tok, 'Percent values must be from 0 to 100. Use a TOP PERCENT value in that range'); this.advance(); }; if (this.isKeyword('WITH') && this.isWordAt(1, 'TIES')) { tiesTok = this.advance(); this.advance(); }; }; var fbList = this.setFallback('The select list is missing a column or expression. Add a column or expression'); this.parseSelectList(); this.restoreFallback(fbList); if (this.isWord('FROM')) { this.advance(); this.parseFromClause(); }; if (this.isWord('WHERE')) { this.advance(); var fb3 = this.setFallback('WHERE needs a condition. Add a condition'); this.parseOrExpr(true); this.restoreFallback(fb3); }; if (this.isWord('GROUP')) { this.advance(); this.expectWord('BY', 'GROUP must be followed by BY. Add BY'); this.parseGroupByList(); }; if (this.isWord('HAVING')) { this.advance(); var fb4 = this.setFallback('HAVING needs a condition. Add a condition'); this.parseOrExpr(true); this.restoreFallback(fb4); }; if (this.isWindowClauseStart()) { this.advance(); for (;;) { this.advance(); // the window's name this.advance(); // AS var winOpen = this.advance(); this.parseOverBody(); this.expectCloseParen(winOpen); if (!(this.isPunct(',') && this.isWindowDefinitionAt(1))) break; this.advance(); }; }; var tail = this.parseQueryTail(); this.lastSelect = { topTok: topTok, tiesTok: tiesTok, orderTok: tail.orderTok, offsetTok: tail.offsetTok }; }; StrictParser.prototype.parseQueryTail = function () { var orderTok = null; var offsetTok = null; if (this.isWord('ORDER')) { orderTok = this.advance(); this.expectWord('BY', 'ORDER must be followed by BY. Add BY'); this.parseOrderByList('ORDER BY needs a column or expression. Add a column or expression'); }; if (this.isWord('OFFSET')) { offsetTok = this.advance(); var offsetFrom = this.pos; var fb5 = this.setFallback('OFFSET needs a number of rows to skip. Add a number'); if (this.isWord('ROWS') || this.isWord('ROW')) this.failExpected(this.exprFallback); this.parseUnary(); this.restoreFallback(fb5); var offset = this.constantCountAt(offsetFrom); if (offset && offset.value < 0) this.fail(offset.tok, 'The offset of an OFFSET clause may not be negative. Skip 0 rows or more'); this.expectWord(this.isWord('ROW') ? 'ROW' : 'ROWS', 'OFFSET needs ROWS after the number. Add ROWS'); if (this.isWord('FETCH')) { this.advance(); this.expectWord(this.isWord('FIRST') ? 'FIRST' : 'NEXT', 'FETCH needs NEXT after it. Add NEXT'); var fetchFrom = this.pos; var fb6 = this.setFallback('FETCH NEXT needs a row count. Add a number'); if (this.isWord('ROWS') || this.isWord('ROW')) this.failExpected(this.exprFallback); this.parseUnary(); this.restoreFallback(fb6); var fetch = this.constantCountAt(fetchFrom); if (fetch && fetch.value <= 0) this.fail(fetch.tok, 'The number of rows of FETCH NEXT must be greater than zero. Fetch 1 row or more'); this.expectWord(this.isWord('ROW') ? 'ROW' : 'ROWS', 'FETCH NEXT needs ROWS after the number. Add ROWS'); this.expectWord('ONLY', 'FETCH NEXT ... ROWS needs ONLY at the end. Add ONLY'); }; }; this.parseForXmlOpt(); return { orderTok: orderTok, offsetTok: offsetTok }; }; StrictParser.prototype.checkLoneSelect = function (shape) { if (shape.tiesTok && !shape.orderTok) this.fail(shape.tiesTok, 'WITH TIES needs an ORDER BY in the same query, which says which rows tie. Add ORDER BY, or remove WITH TIES'); if (shape.topTok && shape.offsetTok) this.fail(shape.topTok, 'TOP cannot be in the same query as OFFSET. Use OFFSET with FETCH NEXT alone, or TOP alone'); }; StrictParser.prototype.parseForXmlOpt = function () { if (this.isWord('FOR') && this.isWordAt(1, 'JSON')) { this.advance(); this.advance(); // FOR JSON if (this.isWordAny(['PATH', 'AUTO'])) this.advance(); else this.fail(this.cur(), 'FOR JSON needs a mode. Add PATH or AUTO'); while (this.isPunct(',')) { this.advance(); if (this.isWordAny(['INCLUDE_NULL_VALUES', 'WITHOUT_ARRAY_WRAPPER'])) { this.advance(); } else if (this.isWord('ROOT')) { this.advance(); if (this.isPunct('(')) { var jOpen = this.advance(); if (this.cur() && this.cur().type === 'string') this.advance(); this.expectCloseParen(jOpen); }; } else { this.fail(this.cur(), 'This FOR JSON option is not recognized. Add ROOT, INCLUDE_NULL_VALUES or WITHOUT_ARRAY_WRAPPER'); }; }; return; }; if (!(this.isWord('FOR') && this.isWordAt(1, 'XML'))) return; this.advance(); this.advance(); // FOR XML if (this.isWordAny(['PATH', 'RAW'])) { this.advance(); if (this.isPunct('(')) { var openTok = this.advance(); var nameTok = this.cur(); if (nameTok && nameTok.type === 'string') this.advance(); this.expectCloseParen(openTok); }; } else if (this.isWordAny(['AUTO', 'EXPLICIT'])) { this.advance(); } else { this.fail(this.cur(), 'FOR XML needs a mode. Add PATH, RAW, AUTO or EXPLICIT'); }; while (this.isPunct(',')) { this.advance(); if (this.isWord('TYPE') || this.isWord('ELEMENTS')) { this.advance(); } else if (this.isWord('ROOT')) { this.advance(); if (!this.isPunct('(')) this.fail(this.cur(), 'ROOT needs its element name in parentheses. Add ('); var rOpen = this.advance(); var rTok = this.cur(); if (rTok && rTok.type === 'string') this.advance(); this.expectCloseParen(rOpen); } else { this.fail(this.cur(), 'This FOR XML option is not recognized. Add TYPE, ROOT(...) or ELEMENTS'); }; }; }; StrictParser.prototype.parseQueryTerm = function () { if (this.isPunct('(') && parenRunToSelect(this.tokens, this.pos, this.end) !== -1) { var openTok = this.advance(); this.queryParenDepth++; if (this.queryParenDepth > PAREN_QUERY_MAX_DEPTH) this.fail(openTok, 'These parentheses are nested too deeply. Remove the ones you do not need'); this.parseQueryExpression(); this.queryParenDepth--; this.expectCloseParen(openTok); return true; }; this.parseSelectStatement(); return false; }; StrictParser.prototype.parseQueryExpression = function () { var isParenthesised = this.parseQueryTerm(); var lone = isParenthesised ? null : this.lastSelect; if (lone && !this.isWordAny(['UNION', 'EXCEPT', 'INTERSECT'])) this.checkLoneSelect(lone); while (this.isWordAny(['UNION', 'EXCEPT', 'INTERSECT'])) { this.advance(); if (this.isWord('ALL')) this.advance(); isParenthesised = this.parseQueryTerm(); }; if (!isParenthesised) return; this.parseQueryTail(); if (this.isPunct('(')) this.fail(this.cur(), 'Queries in parentheses need UNION, UNION ALL, EXCEPT or INTERSECT between them. Add one, or remove the parentheses'); }; StrictParser.prototype.parseOptionalWith = function () { if (!this.isWord('WITH')) return; this.advance(); for (;;) { var nameTok = this.cur(); if (!nameTok || nameTok.type !== 'identifier') { this.fail(nameTok, 'WITH needs a name for this common table expression. Add a name'); }; this.advance(); if (this.isPunct('(')) { var colOpen = this.advance(); var ct = this.cur(); if (!ct || ct.type !== 'identifier') this.fail(ct, 'This column list is missing a name. Add a column name'); this.advance(); while (this.isPunct(',')) { this.advance(); var ct2 = this.cur(); if (!ct2 || ct2.type !== 'identifier') this.fail(ct2, 'This column list is missing a name. Add a column name'); this.advance(); }; this.expectCloseParen(colOpen); }; this.expectWord('AS', 'This common table expression needs AS after its name. Add AS'); if (!this.isPunct('(')) this.fail(this.cur(), 'AS needs the common table expression body in parentheses. Add ('); var openTok = this.advance(); this.parseOptionalWith(); this.parseQueryExpression(); this.expectCloseParen(openTok); if (this.isPunct(',')) { this.advance(); continue; }; break; }; }; StrictParser.prototype.parseStatementBody = function () { this.parseOptionalWith(); this.parseQueryExpression(); }; function endingClauseError(tokens, at, end, lineIndex) { var first = tokens[at]; var isOptionAt = function (k) { return isKw(tokens[k], 'OPTION') && isPunctToken(tokens[k + 1], '(') && matchParen(tokens, k + 1) === end - 1; }; var err = null; if (isOptionAt(at)) { err = strictMkErr(lineIndex, first, tokens[end - 1], 'Marketing Cloud Engagement runs the query inside another one, where a query hint is not allowed: Incorrect syntax near the keyword \'OPTION\'. Remove the OPTION clause.', ''); err.endingClause = { title: 'Remove the OPTION clause', end: tokens[end - 1].end, isRecursionLimit: tokens.slice(at, end).some(function (t) { return !t.quote && t.upper === 'MAXRECURSION'; }) }; } else if (isKw(first, 'FOR') && isKw(tokens[at + 1], 'BROWSE') && (at + 2 === end || isOptionAt(at + 2))) { err = strictMkErr(lineIndex, first, tokens[at + 1], 'Marketing Cloud Engagement runs the query inside another one, where FOR BROWSE is not allowed: The FOR BROWSE clause is no longer supported in views. Remove it.', ''); err.endingClause = { title: 'Remove FOR BROWSE', end: tokens[at + 1].end, isRecursionLimit: false }; }; return err; }; function endingClauseFix(sql, tokens, err) { return { title: err.endingClause.title, edits: [{ start: removalStart(sql, tokens, err.start), end: err.endingClause.end, text: '' }], isSafe: !err.endingClause.isRecursionLimit, isInFixAll: !err.endingClause.isRecursionLimit }; }; function strictParseStatement(tokens, start, end, lineIndex) { if (end <= start) return null; for (var i = start; i < end; i++) { if (tokens[i].unterminated && tokens[i].type === 'string') { return strictMkErr(lineIndex, tokens[i], tokens[i], 'This string starting on line ' + tokens[i].line + ' is never closed. Add the missing closing quote.', ''); }; }; var p = new StrictParser(tokens, start, end, null, lineIndex); try { p.parseStatementBody(); if (p.pos < p.end) { var ending = endingClauseError(tokens, p.pos, p.end, lineIndex); if (ending) return ending; p.fail(p.cur(), 'There is extra text after the end of the query. Remove it'); }; return null; } catch (e) { if (e instanceof StrictError) return strictLongRunError(tokens, start, end, e.err, lineIndex) || e.err; return null; // defensive: parseStrict must never throw or leak an error shape we didn't build }; }; function strictLongRunError(tokens, start, end, err, lineIndex) { var k = start; while (k < end) { if (!isPunctToken(tokens[k], '(')) { k++; continue; }; var r = k; while (r < end && isPunctToken(tokens[r], '(')) r++; if (r - k > PAREN_QUERY_MAX_RUN && r < end && isKw(tokens[r], 'SELECT')) { var after = matchParen(tokens, k) + 1; var isCaused = err.start >= tokens[k].start && (after >= end || err.start <= tokens[after].start); if (isCaused) { return strictMkErr(lineIndex, tokens[k], tokens[k], 'SQL Studio reads at most ' + PAREN_QUERY_MAX_RUN + ' ( in a row before a SELECT. Remove the ones you do not need, near ' + strictNearDesc(tokens[k]) + '.', ''); }; }; k = r; }; return null; }; function parseStrictFromTokens(allTokens, sql) { var errors = []; try { var tokens = allTokens.filter(function (t) { return t.type !== 'comment'; }); var lineIndex = buildLineIndex(sql); statementSegments(sql, tokens).segments.forEach(function (seg) { var err = strictParseStatement(tokens, seg.start, seg.end, lineIndex); if (err) errors.push(err); }); } catch (e) { /* never throw */ } return { errors: errors }; }; function parseStrict(sql) { sql = sql == null ? '' : String(sql); var allTokens; try { allTokens = tokenize(sql); } catch (e) { return { errors: [] }; }; return parseStrictFromTokens(allTokens, sql); }; RULES.push(function ruleMCE000(sql, tokens, ctx) { var findings = []; var connectors = danglingConnectors(sql, tokens, ctx.getCoreTokens()); var result = ctx.getStrict(); (result.errors || []).forEach(function (e) { var isConnectorError = connectors.some(function (c) { return c.isLeading ? e.start === c.token.start : (e.start >= c.token.start && e.start <= c.nextStart); }); if (isConnectorError) return; var fix = e.fix || (e.endingClause ? endingClauseFix(sql, tokens, e) : null); findings.push(['MCE000', 'error', e.message, STRICT_DOCS_URL, e, e, fix ? [fix] : undefined]); }); connectors.forEach(function (c) { var word = c.token.upper; var message = c.isLeading ? 'This ' + word + ' has no condition before it. Remove the ' + word + ', or add the condition it joins.' : 'This ' + word + ' has no condition after it. Add the condition, or remove the ' + word + '.'; findings.push(['MCE000', 'error', message, STRICT_DOCS_URL, c.token, c.token, [ { title: 'Remove ' + word, edits: [removeConnectorEdit(sql, tokens, c.token)], isSafe: true, isInFixAll: true } ]]); }); return findings; }); var FIX_ALL_MAX_ROUNDS = 50; function shiftedOffset(offset, edits) { var shift = 0; edits.forEach(function (e) { if (e.end > offset || (e.start === offset && e.end > e.start)) return; shift += e.text.length - (e.end - e.start); }); return offset + shift; }; function fixAll(sql, context, options) { var current = sql == null ? '' : String(sql); var isFoundOnly = !!(options && options.isFoundOnly); var appliedCount = 0; var appliedIds = []; var rejected = {}; var currentErrors = null; var found = null; var findings = null; function foundIndex(finding) { for (var k = 0; k < found.length; k++) { if (found[k].id === finding.id && found[k].start === finding.start) return k; }; return -1; }; for (var round = 0; round < FIX_ALL_MAX_ROUNDS; round++) { findings = lint(current, context); if (!found) { found = findings.map(function (f) { return { id: f.id, start: f.start }; }); }; var comments = findings.filter(function (f) { var fix = f.id === 'MCE100' && f.fixes && f.fixes[0]; return !!fix && fix.isSafe && fix.isInFixAll !== false && !rejected['MCE100:' + JSON.stringify(fix.edits)] && (!isFoundOnly || foundIndex(f) !== -1); }); if (comments.length > 1) { var commentEdits = []; comments.forEach(function (f) { commentEdits = commentEdits.concat(f.fixes[0].edits); }); var commentSql = applyEdits(current, commentEdits); if (currentErrors === null) currentErrors = parseStrict(current).errors.length; var commentErrors = parseStrict(commentSql).errors.length; if (commentErrors <= currentErrors) { comments.forEach(function (f) { var at = foundIndex(f); if (at !== -1) found.splice(at, 1); appliedIds.push('MCE100'); }); found.forEach(function (f) { f.start = shiftedOffset(f.start, commentEdits); }); current = commentSql; currentErrors = commentErrors; appliedCount += comments.length; findings = null; continue; }; }; var target = null; var ordered = findings.filter(function (f) { return f.id === 'MCE100'; }).concat(findings.filter(function (f) { return f.id !== 'MCE100'; })); for (var i = 0; i < ordered.length; i++) { var candidate = ordered[i].fixes && ordered[i].fixes[0]; if (!(candidate && candidate.isSafe && candidate.isInFixAll !== false)) continue; var candidateKey = ordered[i].id + ':' + JSON.stringify(candidate.edits); if (rejected[candidateKey]) continue; var at = foundIndex(ordered[i]); if (isFoundOnly && at === -1) continue; var fixedSql = applyEdits(current, candidate.edits); if (currentErrors === null) currentErrors = parseStrict(current).errors.length; var fixedErrors = parseStrict(fixedSql).errors.length; if (fixedErrors > currentErrors) { rejected[candidateKey] = true; continue; }; target = { id: ordered[i].id, sql: fixedSql, errors: fixedErrors, edits: candidate.edits, at: at }; break; }; if (!target) break; if (target.at !== -1) found.splice(target.at, 1); found.forEach(function (f) { f.start = shiftedOffset(f.start, target.edits); }); current = target.sql; currentErrors = target.errors; appliedCount++; appliedIds.push(target.id); findings = null; }; if (!findings) findings = lint(current, context); return { sql: current, appliedCount: appliedCount, appliedIds: appliedIds, newCount: findings.filter(function (f) { return foundIndex(f) === -1; }).length }; }; var FREE_READ_MAX_ROWS = 2000; var RESULT_CHUNK_SIZES = [2000, 1000, 500, 400, 250, 200]; var RESULT_CHUNK_CACHE_MAX = 5; function exportPageSize(columnCount) { var size = Math.floor(250000 / Math.max(1, columnCount) / 100) * 100; return Math.min(2500, Math.max(200, size)); }; var WSPROXY_BATCH_ROWS = 2500; var WSPROXY_EXPORT_MAX_COLUMNS = 100; function resultChunkSize(columnCount) { var limit = exportPageSize(columnCount); for (var i = 0; i < RESULT_CHUNK_SIZES.length; i++) { if (RESULT_CHUNK_SIZES[i] <= limit) return RESULT_CHUNK_SIZES[i]; }; return RESULT_CHUNK_SIZES[RESULT_CHUNK_SIZES.length - 1]; }; function isFreeReadPage(page, pageSize) { return page * pageSize <= FREE_READ_MAX_ROWS; }; function resultChunkCount(count, chunkSize) { return count > 0 ? Math.ceil(count / chunkSize) : 0; }; function resultChunkLength(chunk, count, chunkSize) { return Math.max(0, Math.min(chunkSize, count - (chunk - 1) * chunkSize)); }; function resultChunksForPage(page, pageSize, chunkSize, count) { var start = (page - 1) * pageSize; var end = Math.min(page * pageSize, count); var parts = []; if (end <= start) return parts; var lastChunk = Math.floor((end - 1) / chunkSize) + 1; for (var chunk = Math.floor(start / chunkSize) + 1; chunk <= lastChunk; chunk++) { var chunkStart = (chunk - 1) * chunkSize; parts.push({ chunk: chunk, from: Math.max(start, chunkStart) - chunkStart, to: Math.min(end, chunkStart + chunkSize) - chunkStart }); }; return parts; }; function createChunkCache(deKey, orderBy, chunkSize) { return { deKey: String(deKey), orderBy: orderBy || null, chunkSize: chunkSize, chunks: {}, used: [], freeFailed: false }; }; function chunkCacheMatches(cache, deKey, orderBy, chunkSize) { return !!cache && cache.deKey === String(deKey) && cache.orderBy === (orderBy || null) && cache.chunkSize === chunkSize; }; function chunkCacheTouch(cache, chunk) { var at = cache.used.indexOf(chunk); if (at !== -1) cache.used.splice(at, 1); cache.used.push(chunk); }; function chunkCacheGet(cache, chunk) { if (!cache.chunks.hasOwnProperty(chunk)) return null; chunkCacheTouch(cache, chunk); return cache.chunks[chunk]; }; function chunkCachePut(cache, chunk, rows) { cache.chunks[chunk] = rows; chunkCacheTouch(cache, chunk); while (cache.used.length > RESULT_CHUNK_CACHE_MAX) { delete cache.chunks[cache.used.shift()]; }; }; var DIAGRAM_APP_VERSION = '1.25.9'; var DIAGRAM_SUGGEST_LAYOUT_TABLES = 3; var DIAGRAM_COLORS = { dataView: '#1D73C9', dataExtension: '#BE5C2A', query: '#A06F03', line: '#74797F', sources: '#1D73C9', target: '#008B46' }; var DIAGRAM_GEOMETRY = { header: 32, row: 22, toggle: 18, modelWidth: 480, modelGap: 128, modelRowGap: 96, modelPerRow: 4, modelOrigin: 100, cardWidth: 260, zoneWidth: 356, zonePitch: 556, zoneInset: 48, cardGap: 36, actionGap: 16, actionHeight: 24 }; var DIAGRAM_TYPE_NAMES = { text: 'Text', number: 'Number', decimal: 'Decimal', date: 'Date', boolean: 'Boolean', email: 'EmailAddress', emailaddress: 'EmailAddress', phone: 'Phone', locale: 'Locale' }; var DIAGRAM_KINDS = ['model', 'modelAll', 'lineage']; var DIAGRAM_MAX_CELLS = 2000; var DIAGRAM_MAX_JSON = 8 * 1024 * 1024; var DIAGRAM_LABEL_MAX = 40; var DIAGRAM_TRACE_DEPTH = 8; function diagramCardHeight(fieldCount) { return DIAGRAM_GEOMETRY.header + Math.max(fieldCount, 1) * DIAGRAM_GEOMETRY.row + DIAGRAM_GEOMETRY.toggle; }; function diagramRgba(hex, alpha) { var n = parseInt(hex.slice(1), 16); return 'rgba(' + ((n >> 16) & 255) + ',' + ((n >> 8) & 255) + ',' + (n & 255) + ',' + alpha + ')'; }; function diagramFieldType(field) { var type = field && typeof field.type === 'string' ? field.type.trim() : ''; if (!type) return 'Text'; return Object.prototype.hasOwnProperty.call(DIAGRAM_TYPE_NAMES, type.toLowerCase()) ? DIAGRAM_TYPE_NAMES[type.toLowerCase()] : type; }; function diagramFieldLength(field) { if (!field) return null; var length = field.length != null ? field.length : field.precision; return typeof length === 'number' && length > 0 ? length : null; }; function diagramPartText(token) { return String(token.value).replace(/^\[|\]$/g, ''); }; function diagramKey(name) { return String(name).replace(/\[|\]/g, '').replace(/\s+/g, ' ').toLowerCase(); }; function diagramCteOf(model, entry) { if (!entry || entry.kind !== 'table' || entry.isFunction || !entry.name) return null; var parts = typeof entry.nameParts === 'number' ? entry.nameParts : String(entry.name).split('.').length; if (parts !== 1) return null; var key = String(entry.name).replace(/^\[|\]$/g, '').toUpperCase(); return Object.prototype.hasOwnProperty.call(model.ctes, key) ? model.ctes[key] : null; }; function diagramRecordOf(model, entry) { if (!entry || entry.kind !== 'table' || entry.isFunction || !entry.name || diagramCteOf(model, entry)) return null; return model.recordByKey[diagramKey(entry.name)] || null; }; function diagramSchemaFields(rec) { if (!rec.table || !Array.isArray(rec.table.fields)) return []; return rec.table.fields.filter(function (f) { return !!f && typeof f.name === 'string' && f.name.charAt(0) !== '['; }); }; function diagramSchemaField(rec, name) { var wanted = String(name).toLowerCase(); var fields = diagramSchemaFields(rec); for (var i = 0; i < fields.length; i++) { if (fields[i].name.toLowerCase() === wanted) return fields[i]; }; return null; }; function diagramUse(rec, name) { var lower = String(name).toLowerCase(); if (Object.prototype.hasOwnProperty.call(rec.isUsed, lower)) return rec.isUsed[lower]; var known = diagramSchemaField(rec, name); var kept = known ? known.name : String(name); rec.isUsed[lower] = kept; rec.used.push(kept); return kept; }; function diagramEntryFieldNames(model, entry) { var rec = diagramRecordOf(model, entry); if (rec) { if (!rec.table || !Array.isArray(rec.table.fields) || !rec.table.fields.length || hasOpenFieldList(rec.table)) return null; return diagramSchemaFields(rec).map(function (f) { return f.name; }); }; var names = resolveEntryColumns(entry, { schemaIndex: model.byName, ctes: model.ctes }, false); if (!names || !names.length) return null; return names.every(function (n) { return typeof n === 'string' && n.length > 0; }) ? names : null; }; function diagramBindQualified(model, selectIdx, qualifier, column, limit) { var chain = visibleScopes(model.core, model.nesting, model.scopeBySelect, selectIdx); for (var c = 0; c < chain.length; c++) { var reading = model.readings.bySelect[chain[c].scope.selectIdx]; if (!reading) continue; var binder = scopeBinder(model.core, reading); var count = entriesBefore(binder, c === 0 && limit !== undefined ? limit : chain[c].limit); var k = firstBound(binder.exposed, qualifier, count); if (k !== -1) return { entry: binder.entries[k], column: column, selectIdx: chain[c].scope.selectIdx }; }; return null; }; function diagramBindBare(model, selectIdx, column, limit) { var lower = String(column).toLowerCase(); var chain = visibleScopes(model.core, model.nesting, model.scopeBySelect, selectIdx); var fallback = null; for (var c = 0; c < chain.length; c++) { var reading = model.readings.bySelect[chain[c].scope.selectIdx]; if (!reading) continue; var binder = scopeBinder(model.core, reading); var count = entriesBefore(binder, c === 0 && limit !== undefined ? limit : chain[c].limit); var candidates = binder.entries.slice(0, count).filter(function (e) { return !e.isFunction; }); if (!candidates.length) continue; var owners = []; var unknown = []; candidates.forEach(function (e) { var names = diagramEntryFieldNames(model, e); if (!names) { unknown.push(e); } else if (names.some(function (n) { return n.toLowerCase() === lower; })) { owners.push(e); }; }); var bound = function (entry) { return { entry: entry, column: column, selectIdx: chain[c].scope.selectIdx }; }; if (owners.length === 1) return bound(owners[0]); if (owners.length > 1) return null; if (unknown.length === 1) return bound(unknown[0]); if (unknown.length > 1) return null; if (c === 0 && candidates.length === 1) fallback = bound(candidates[0]); }; return fallback; }; function diagramBindRef(model, selectIdx, ref, limit) { var n = ref.parts.length; var column = diagramPartText(ref.parts[n - 1]); if (n >= 2) return diagramBindQualified(model, selectIdx, diagramPartText(ref.parts[n - 2]).toLowerCase(), column, limit); return diagramBindBare(model, selectIdx, column, limit); }; function diagramStarEntries(model, scope, item) { var reading = model.readings.bySelect[scope.selectIdx]; if (!reading) return []; var binder = scopeBinder(model.core, reading); if (!item.starTable) return binder.entries.filter(function (e) { return !e.isFunction; }); var k = firstBound(binder.exposed, String(item.starTable).replace(/^\[|\]$/g, '').toLowerCase(), binder.entries.length); return k === -1 ? [] : [binder.entries[k]]; }; function diagramOutputs(model, scope) { var outputs = []; scope.selectList.items.forEach(function (item, itemIndex) { if (item.kind === 'empty') return; if (item.kind !== 'star') { outputs.push({ name: item.name || null, item: item, itemIndex: itemIndex }); return; }; var entries = diagramStarEntries(model, scope, item); var expanded = []; var isKnown = entries.length > 0; entries.forEach(function (entry) { var names = diagramEntryFieldNames(model, entry); if (!names) { isKnown = false; return; }; names.forEach(function (name) { expanded.push({ name: name, item: item, itemIndex: itemIndex, starIndex: expanded.length, entry: entry, column: name }); }); }); if (isKnown) { outputs = outputs.concat(expanded); } else { outputs.push({ name: null, item: item, itemIndex: itemIndex, isUnresolved: true }); }; }); return outputs; }; function diagramItemText(model, item, isOneLine) { var span = selectItemExpression(model.core, item); if (!span || span.to <= span.from) return ''; var text = model.sql.slice(model.core[span.from].start, model.core[span.to - 1].end); return isOneLine ? text.replace(/\s+/g, ' ').trim() : text; }; function diagramTraceBound(model, bound, depth) { var rec = diagramRecordOf(model, bound.entry); if (rec) return [{ rec: rec, field: diagramUse(rec, bound.column), rule: null }]; if (depth >= DIAGRAM_TRACE_DEPTH) return []; var cte = diagramCteOf(model, bound.entry); var inner = bound.entry.kind === 'derived' ? bound.entry.inner : (cte ? cte.inner : null); if (!inner || inner.noSelect) return []; var scope = model.scopeByStart[inner.start]; if (!scope) return []; var outputs = diagramOutputs(model, scope); var names = bound.entry.kind === 'derived' ? bound.entry.columns : (cte ? cte.columns : null); var lower = String(bound.column).toLowerCase(); var at = -1; for (var i = 0; i < outputs.length; i++) { var name = names && names.length === outputs.length ? names[i] : outputs[i].name; if (typeof name === 'string' && name.toLowerCase() === lower) { at = i; break; }; }; if (at === -1) return []; var found = diagramTraceOutput(model, scope, outputs[at], depth + 1); diagramBranches(model, scope).forEach(function (branch) { var branchOutputs = diagramOutputs(model, branch); if (branchOutputs.length !== outputs.length) return; diagramTraceOutput(model, branch, branchOutputs[at], depth + 1).forEach(function (source) { var isKnown = found.some(function (f) { return f.rec === source.rec && f.field.toLowerCase() === source.field.toLowerCase(); }); if (!isKnown) found.push(source); }); }); return found; }; function diagramBranches(model, scope) { var place = model.nesting[scope.selectIdx]; if (!place || place.head !== scope.selectIdx) return []; return model.scopes.filter(function (other) { var otherPlace = model.nesting[other.selectIdx]; return other !== scope && !!otherPlace && otherPlace.head === scope.selectIdx; }); }; function diagramTraceOutput(model, scope, output, depth) { if (output.entry) return diagramTraceBound(model, { entry: output.entry, column: output.column }, depth); var item = output.item; if (!item || output.isUnresolved) return []; if ((item.kind === 'column' || item.kind === 'alias') && item.sourceColumn) { var bound = item.sourceTable ? diagramBindQualified(model, scope.selectIdx, String(item.sourceTable).replace(/^\[|\]$/g, '').toLowerCase(), item.sourceColumn) : diagramBindBare(model, scope.selectIdx, item.sourceColumn); return bound ? diagramTraceBound(model, bound, depth) : []; }; var reading = model.readings.bySelect[scope.selectIdx]; if (!reading) return []; var rule = diagramItemText(model, item); var found = []; var seen = {}; reading.select.refs.filter(function (ref) { return ref.item === item; }).forEach(function (ref) { var bound = diagramBindRef(model, scope.selectIdx, ref); if (!bound) return; diagramTraceBound(model, bound, depth).forEach(function (source) { var key = source.rec.id + ':' + source.field.toLowerCase(); if (seen[key]) return; seen[key] = true; found.push({ rec: source.rec, field: source.field, rule: rule }); }); }); return found; }; function diagramCollectUsage(model) { model.scopes.forEach(function (scope) { var reading = model.readings.bySelect[scope.selectIdx]; if (!reading) return; var aliases = {}; scope.selectList.items.forEach(function (item) { if (item.kind === 'alias' && item.name) aliases[String(item.name).toLowerCase()] = true; }); [reading.select, reading.where, reading.groupBy, reading.having, reading.orderBy].concat(reading.ons).forEach(function (clause) { if (!clause) return; var limit = clause.on ? model.core[clause.on.onIdx].start : undefined; clause.refs.forEach(function (ref) { if (clause === reading.orderBy && ref.parts.length === 1 && aliases[diagramPartText(ref.parts[0]).toLowerCase()]) { var own = diagramBindBare(model, scope.selectIdx, diagramPartText(ref.parts[0])); var ownNames = own ? diagramEntryFieldNames(model, own.entry) : null; var isField = !!ownNames && ownNames.some(function (n) { return n.toLowerCase() === diagramPartText(ref.parts[0]).toLowerCase(); }); if (!isField) return; }; var bound = diagramBindRef(model, scope.selectIdx, ref, limit); var rec = bound ? diagramRecordOf(model, bound.entry) : null; if (rec) diagramUse(rec, bound.column); }); }); scope.selectList.items.forEach(function (item) { if (item.kind !== 'star') return; diagramStarEntries(model, scope, item).forEach(function (entry) { var rec = diagramRecordOf(model, entry); if (rec) diagramSchemaFields(rec).forEach(function (f) { diagramUse(rec, f.name); }); }); }); }); }; function diagramRequiredTest(core, from, to) { var groups = [{ parent: -1, isLoose: false, isNegated: false, isCase: false }]; var groupAt = {}; var stack = [0]; for (var q = from; q < to; q++) { var t = core[q]; var top = stack[stack.length - 1]; if (isPunctToken(t, '(') || isKw(t, 'CASE')) { groups.push({ parent: top, isLoose: isKw(t, 'CASE'), isNegated: isPunctToken(t, '(') && isKw(core[q - 1], 'NOT'), isCase: isKw(t, 'CASE') }); stack.push(groups.length - 1); } else if ((isPunctToken(t, ')') && !groups[top].isCase) || (isKw(t, 'END') && groups[top].isCase)) { if (stack.length > 1) stack.pop(); } else if (isKw(t, 'OR')) { groups[top].isLoose = true; }; groupAt[q] = stack[stack.length - 1]; }; return function (idx) { if (!Object.prototype.hasOwnProperty.call(groupAt, idx)) return 'loose'; var isLoose = false; for (var g = groupAt[idx]; g !== -1; g = groups[g].parent) { if (groups[g].isNegated) return 'negated'; if (groups[g].isLoose) isLoose = true; }; return isLoose ? 'loose' : 'needed'; }; }; function diagramCollectJoins(model) { var core = model.core; model.scopes.forEach(function (scope) { var reading = model.readings.bySelect[scope.selectIdx]; if (!reading) return; var conditions = reading.ons.map(function (onReading) { return { reading: onReading, limit: core[onReading.on.onIdx].start }; }); if (reading.where) conditions.push({ reading: reading.where, limit: undefined }); conditions.forEach(function (condition) { var clause = condition.reading; var placeOf = diagramRequiredTest(core, clause.from, clause.to); clause.refs.forEach(function (left) { var operator = core[left.end]; if (!operator || operator.type !== 'operator' || operator.value !== '=' || left.end >= clause.to) return; var place = placeOf(left.first); if (place === 'negated') return; var right = clause.refs.filter(function (r) { return r.first === left.end + 1; })[0]; if (!right) return; var before = core[left.first - 1]; var after = core[right.end]; var isPlainBefore = !!before && (isKwAny(before, ['ON', 'WHERE', 'AND', 'OR']) || isPunctToken(before, '(')); var isPlainAfter = right.end >= clause.to || isKwAny(after, ['AND', 'OR']) || isPunctToken(after, ')'); if (!isPlainBefore || !isPlainAfter) return; var sides = [left, right].map(function (ref) { var bound = diagramBindRef(model, scope.selectIdx, ref, condition.limit); var sources = bound ? diagramTraceBound(model, bound, 0) : []; if (sources.length !== 1 || sources[0].rule !== null) return null; return { rec: sources[0].rec, field: sources[0].field, slot: 'e' + bound.entry.start }; }); if (!sides[0] || !sides[1] || sides[0].slot === sides[1].slot) return; if (sides[0].rec === sides[1].rec && sides[0].field.toLowerCase() === sides[1].field.toLowerCase()) return; var slots = [sides[0].slot, sides[1].slot].sort(); var groupKey = scope.selectIdx + ':' + slots[0] + ':' + slots[1]; var group = model.joinGroups[groupKey]; if (!group) { group = model.joinGroups[groupKey] = { fields: {} }; group.fields[slots[0]] = {}; group.fields[slots[1]] = {}; }; var isNeeded = place === 'needed'; if (isNeeded) { group.fields[sides[0].slot][sides[0].field.toLowerCase()] = true; group.fields[sides[1].slot][sides[1].field.toLowerCase()] = true; }; var pairKey = [sides[0].rec.id + '.' + sides[0].field.toLowerCase(), sides[1].rec.id + '.' + sides[1].field.toLowerCase()].sort().join('='); var known = model.joinKeys[pairKey]; if (known) { if (isNeeded && !known.isRequired) { known.a = sides[0]; known.b = sides[1]; known.group = group; known.isRequired = true; }; return; }; var join = { a: sides[0], b: sides[1], group: group, isRequired: isNeeded }; model.joinKeys[pairKey] = join; model.joins.push(join); }); }); }); }; function diagramIsOne(rec, fields) { var keys = diagramSchemaFields(rec).filter(function (f) { return f.isPrimaryKey === true; }); return keys.length > 0 && keys.every(function (f) { return fields[f.name.toLowerCase()] === true; }); }; function diagramTwiceNamed(model) { var clean = function (name) { return String(name).replace(/^\[|\]$/g, ''); }; for (var r = 0; r < model.readings.list.length; r++) { var entries = scopeBinder(model.core, model.readings.list[r]).entries; var lastPivot = -1; entries.forEach(function (f, k) { if (isPivotEntry(model.core, f)) lastPivot = k; }); var exposed = {}; for (var k = Math.max(lastPivot, 0); k < entries.length; k++) { var f = entries[k]; var name = f.alias ? clean(f.alias) : (f.name ? clean(stripEnt(f.name)) : ''); if (!name) continue; var key = name.toLowerCase(); var before = Object.prototype.hasOwnProperty.call(exposed, key) ? exposed[key] : null; if (!before) { exposed[key] = f; continue; }; if (f.alias && before.alias) return 'Two tables of one FROM have the alias ' + name + ', as SQL Server reads an alias in any letter case. Give each table an alias of its own.'; if (!f.alias && !before.alias && clean(f.name).toLowerCase() === clean(before.name).toLowerCase()) return 'One FROM reads ' + name + ' twice with no alias, which SQL Server refuses. Give each an alias of its own.'; if (!!f.alias !== !!before.alias && !/^ENT\./i.test(String((f.alias ? before : f).name))) return 'The alias ' + name + ' is also the name of a table of its FROM, which SQL Server refuses. Give one of them another alias.'; }; }; return null; }; function analyzeQueryForDiagram(sql, schema) { sql = sql == null ? '' : String(sql); if (!sql.trim()) return { error: 'There is no query to document.' }; var tokens; var parsed; var strict; var count; try { tokens = tokenize(sql); count = splitStatements(sql).length; strict = parseStrictFromTokens(tokens, sql); parsed = parseFromTokens(tokens, sql, schema); } catch (e) { return { error: 'SQL Studio could not read this query.' }; }; if (count > 1) return { error: 'The text holds ' + count + ' queries. Select the one to document.' }; if (strict.errors.length) return { error: 'The query does not parse. Line ' + strict.errors[0].line + ': ' + strict.errors[0].message }; if (parsed._noSelect) return { error: 'The query has no SELECT, so there is nothing to document.' }; try { var core = withoutComments(tokens); var model = { sql: sql, parsed: parsed, core: core, byName: ensureIndex(schema), ctes: parsed._ctx && parsed._ctx.ctes ? parsed._ctx.ctes : {}, scopes: [], nesting: {}, readings: null, scopeBySelect: {}, scopeByStart: {}, topScope: null, records: [], recordByKey: {}, joins: [], joinGroups: {}, joinKeys: {} }; model.scopes = collectSelectScopes(sql, core, schema, model.ctes); model.nesting = collectSelectNesting(core, model.scopes); model.readings = collectScopeReadings(core, model.scopes, model.nesting); var twice = diagramTwiceNamed(model); if (twice) return { error: twice }; model.scopes.forEach(function (scope) { model.scopeBySelect[scope.selectIdx] = scope; model.scopeByStart[core[scope.selectIdx].start] = scope; if (scope.selectList.start === parsed.selectList.start) model.topScope = scope; }); var found = []; var seenStarts = {}; model.scopes.forEach(function (scope) { tableEntriesOf(core, scope.from).forEach(function (entry) { if (entry.kind !== 'table' || entry.isFunction || !entry.name || seenStarts[entry.start] || diagramCteOf(model, entry)) return; seenStarts[entry.start] = true; found.push(entry); }); }); found.sort(function (a, b) { return a.start - b.start; }); found.forEach(function (entry) { var key = diagramKey(entry.name); if (model.recordByKey[key]) return; var table = fieldCheckTable(model.byName, entry); var rec = { id: 't' + (model.records.length + 1), name: String(entry.name).replace(/^\[|\]$/g, ''), key: key, table: table, kind: table && table.kind === 'dataview' ? 'dataview' : 'de', used: [], isUsed: {}, start: entry.start }; model.recordByKey[key] = rec; model.records.push(rec); }); if (!model.records.length) return { error: 'The query reads no table, so there is nothing to document.' }; diagramCollectUsage(model); diagramCollectJoins(model); return model; } catch (e) { return { error: 'SQL Studio could not read the tables of this query.' }; }; }; function diagramModelFields(rec, isAll) { var fields = []; var seen = {}; var add = function (name, schemaField) { var lower = name.toLowerCase(); if (seen[lower]) return; seen[lower] = true; fields.push({ name: name, type: diagramFieldType(schemaField), length: diagramFieldLength(schemaField), isKey: !!schemaField && schemaField.isPrimaryKey === true, isRequired: !!schemaField && (schemaField.isPrimaryKey === true || schemaField.isNullable === false || schemaField.nullable === false) }); }; diagramSchemaFields(rec).forEach(function (f) { if (isAll || Object.prototype.hasOwnProperty.call(rec.isUsed, f.name.toLowerCase())) add(f.name, f); }); rec.used.forEach(function (name) { add(name, diagramSchemaField(rec, name)); }); return fields; }; function diagramFieldCell(fid, field) { return { label: field.name, apiName: field.name, type: field.type, keyType: field.isKey ? 'pk' : null, length: field.length, required: field.isRequired, deprecated: false, fid: fid }; }; function diagramObjectCell(id, name, color, fields, width, x, y) { return { id: id, type: 'sf.DataObject', position: { x: x, y: y }, size: { width: width, height: diagramCardHeight(fields.length) }, z: 2000, objectName: name, headerColor: color, fields: fields, showLabels: false, showFieldLengths: true, keyFieldsOnly: false }; }; function diagramMarker(kind) { var paths = { one: 'M -12 -8 L -12 8 M -12 0 L 0 0', many: 'M -12 -8 L 0 0 L -12 8 M 0 0 L -12 0', none: 'M 0 0 L -12 0' }; return { type: 'path', d: paths[kind], fill: 'none', stroke: DIAGRAM_COLORS.line, 'stroke-width': 2 }; }; function diagramRelationLink(id, from, to, fromMarker, toMarker) { return { id: id, type: 'standard.Link', z: 3000, source: { id: from.id, port: from.port }, target: { id: to.id, port: to.port }, router: { name: 'sfManhattan' }, connector: { name: 'rounded', args: { radius: 8 } }, attrs: { line: { stroke: DIAGRAM_COLORS.line, strokeWidth: 2, sourceMarker: diagramMarker(fromMarker), targetMarker: diagramMarker(toMarker) } } }; }; function diagramMappingLink(id, from, to, rule) { var link = { id: id, type: 'standard.Link', z: 3000, source: { id: from.id, port: from.port }, target: { id: to.id, port: to.port }, linkKind: 'mapping', mappingType: rule === null ? 'Standard' : 'Formula' }; if (rule !== null) link.expressionRule = rule; return link; }; function diagramEnds(join) { var aOne = join.isRequired && diagramIsOne(join.a.rec, join.group.fields[join.a.slot]); var bOne = join.isRequired && diagramIsOne(join.b.rec, join.group.fields[join.b.slot]); var isAFirst = join.a.rec.start <= join.b.rec.start; if ((!aOne && !bOne) || (aOne && bOne)) return { from: isAFirst ? join.a : join.b, to: isAFirst ? join.b : join.a, fromMarker: aOne ? 'one' : 'none', toMarker: aOne ? 'one' : 'none', manyField: null }; var from = aOne ? join.a : join.b; var to = aOne ? join.b : join.a; return { from: from, to: to, fromMarker: 'one', toMarker: 'many', manyField: to }; }; function diagramDataModel(model, isAll, title) { var g = DIAGRAM_GEOMETRY; var cards = model.records.map(function (rec, i) { var fields = diagramModelFields(rec, isAll); var cells = fields.map(function (field, j) { return diagramFieldCell(rec.id + 'f' + (j + 1), field); }); var fidByName = {}; cells.forEach(function (cell) { fidByName[cell.label.toLowerCase()] = cell; }); return { rec: rec, id: 'obj-' + (i + 1), cells: cells, fidByName: fidByName }; }); var cardOf = {}; cards.forEach(function (card) { cardOf[card.rec.id] = card; }); var rowTop = g.modelOrigin; for (var start = 0; start < cards.length; start += g.modelPerRow) { var row = cards.slice(start, start + g.modelPerRow); var rowHeight = 0; row.forEach(function (card, column) { card.x = g.modelOrigin + column * (g.modelWidth + g.modelGap); card.y = rowTop; card.height = diagramCardHeight(card.cells.length); rowHeight = Math.max(rowHeight, card.height); }); rowTop += rowHeight + g.modelRowGap; }; var links = []; model.joins.forEach(function (join, k) { var ends = diagramEnds(join); var fromCard = cardOf[ends.from.rec.id]; var toCard = cardOf[ends.to.rec.id]; var fromCell = fromCard.fidByName[ends.from.field.toLowerCase()]; var toCell = toCard.fidByName[ends.to.field.toLowerCase()]; if (ends.manyField && toCell.keyType !== 'pk') toCell.keyType = 'fk'; var fromSide = fromCard.x < toCard.x ? 'right' : (fromCard.x > toCard.x ? 'left' : 'right'); var toSide = fromCard.x < toCard.x ? 'left' : (fromCard.x > toCard.x ? 'right' : 'right'); links.push(diagramRelationLink('link-' + (k + 1), { id: fromCard.id, port: 'field-' + fromSide + '-' + fromCell.fid }, { id: toCard.id, port: 'field-' + toSide + '-' + toCell.fid }, ends.fromMarker, ends.toMarker)); }); var cells = cards.map(function (card) { return diagramObjectCell(card.id, card.rec.name, card.rec.kind === 'dataview' ? DIAGRAM_COLORS.dataView : DIAGRAM_COLORS.dataExtension, card.cells, g.modelWidth, card.x, card.y); }); var diagram = { version: 1, appVersion: DIAGRAM_APP_VERSION, title: title, diagramType: 'datamodel' }; if (cards.length >= DIAGRAM_SUGGEST_LAYOUT_TABLES) diagram.suggestAutoLayout = true; diagram.graph = { cells: cells.concat(links) }; return diagram; }; function diagramRowType(inferred, sourceField) { if (inferred && typeof inferred.type === 'string') return { type: inferred.type, length: inferred.type === 'Decimal' && typeof inferred.precision === 'number' ? inferred.precision : null }; if (sourceField) { var type = diagramFieldType(sourceField); if (type === 'Date' || type === 'Number' || type === 'Decimal' || type === 'Boolean') return { type: type, length: type === 'Decimal' ? diagramFieldLength(sourceField) : null }; }; return { type: 'Text', length: null }; }; function diagramQueryRows(model, sql, schema) { var top = model.topScope; if (!top) return []; var outputs = diagramOutputs(model, top); var types = []; try { types = inferSaveColumnTypes(sql, schema); } catch (e) { types = []; }; var offsets = []; var width = 0; top.selectList.items.forEach(function (item, i) { offsets[i] = width; width += item.kind === 'star' && item._resolvedColumns ? item._resolvedColumns.length : 1; }); var isTyped = types.length === width; var rows = outputs.map(function (output) { var inferred = null; if (isTyped) { if (output.entry) { inferred = output.item._resolvedColumns && output.item._resolvedColumns.length ? (types[offsets[output.itemIndex] + output.starIndex] || null) : null; } else if (!output.isUnresolved) { inferred = types[offsets[output.itemIndex]] || null; }; }; var sources = diagramTraceOutput(model, top, output, 0); var rec = output.entry ? diagramRecordOf(model, output.entry) : null; var starField = rec ? diagramSchemaField(rec, output.column) : null; var rowType = diagramRowType(inferred, starField); var label = output.name; if (typeof label !== 'string' || !label) { var text = output.item && output.item.kind !== 'star' ? diagramItemText(model, output.item, true) : (output.item ? model.sql.slice(output.item.start, output.item.end) : ''); label = text.length > DIAGRAM_LABEL_MAX ? text.slice(0, DIAGRAM_LABEL_MAX - 3) + '...' : text; }; return { name: typeof output.name === 'string' ? output.name : null, label: label || 'Column', type: rowType.type, length: rowType.length, sources: sources }; }); diagramBranches(model, top).forEach(function (scope) { var branch = diagramOutputs(model, scope); if (branch.length !== rows.length) return; branch.forEach(function (output, i) { diagramTraceOutput(model, scope, output, 0).forEach(function (source) { rows[i].sources.push(source); }); }); }); return rows; }; function diagramZoneCell(id, label, color, x, height, embeds) { return { id: id, type: 'sf.Zone', position: { x: x, y: 0 }, size: { width: DIAGRAM_GEOMETRY.zoneWidth, height: height }, z: 0, embeds: embeds, attrs: { body: { fill: diagramRgba(color, 0.05), stroke: color }, label: { text: label, fill: color } } }; }; function diagramActionLabel(id, text, x, y) { return { id: id, type: 'sf.TextLabel', position: { x: x, y: y }, size: { width: DIAGRAM_GEOMETRY.cardWidth, height: DIAGRAM_GEOMETRY.actionHeight }, z: 2000, attrs: { label: { x: 'calc(0.5 * w)', y: 'calc(0.5 * h)', textAnchor: 'middle', textVerticalAnchor: 'middle', fontSize: 13, fontFamily: 'system-ui, -apple-system, sans-serif', fill: 'var(--text-primary)', fontWeight: '600', text: text } } }; }; function diagramLineage(model, sql, schema, options, title) { var g = DIAGRAM_GEOMETRY; var rows = diagramQueryRows(model, sql, schema); var target = options.target && typeof options.target === 'object' ? options.target : null; var targetFields = target && Array.isArray(target.fields) ? target.fields.filter(function (f) { return !!f && typeof f.name === 'string' && f.name; }) : []; var targetByName = {}; targetFields.forEach(function (f, i) { targetByName[f.name.toLowerCase()] = i; }); var cells = []; var links = []; var y = g.zoneInset; var sourceCards = model.records.map(function (rec, i) { var fields = diagramModelFields(rec, false).map(function (field, j) { return diagramFieldCell(rec.id + 'f' + (j + 1), field); }); var card = diagramObjectCell('obj-' + (i + 1), rec.name, rec.kind === 'dataview' ? DIAGRAM_COLORS.dataView : DIAGRAM_COLORS.dataExtension, fields, g.cardWidth, g.zoneInset, y); y += card.size.height + g.cardGap; return card; }); var cardOf = {}; sourceCards.forEach(function (card, i) { cardOf[model.records[i].id] = card; }); var sourcesHeight = y - g.cardGap + g.zoneInset; var parts = [{ zoneId: 'zone-sources', label: 'Sources', color: DIAGRAM_COLORS.sources, x: 0, height: sourcesHeight, cards: sourceCards }]; var queryFields = rows.map(function (row, i) { var cell = diagramFieldCell('q' + (i + 1), { name: row.label, type: row.type, length: row.length, isKey: false, isRequired: false }); if (row.name !== null && target && targetFields.length && !Object.prototype.hasOwnProperty.call(targetByName, row.name.toLowerCase())) cell.deprecated = true; return cell; }); var queryCard = diagramObjectCell('obj-query', title, DIAGRAM_COLORS.query, queryFields, g.cardWidth, g.zonePitch + g.zoneInset, g.zoneInset); queryCard.showFieldLengths = false; parts.push({ zoneId: 'zone-query', label: 'Query', color: DIAGRAM_COLORS.query, x: g.zonePitch, height: g.zoneInset + queryCard.size.height + g.zoneInset, cards: [queryCard] }); var targetCard = null; if (target) { var targetCells = targetFields.map(function (f, i) { return diagramFieldCell('g' + (i + 1), { name: f.name, type: diagramFieldType(f), length: diagramFieldLength(f), isKey: f.isPrimaryKey === true, isRequired: f.isPrimaryKey === true || f.isNullable === false }); }); targetCard = diagramObjectCell('obj-target', String(target.name || 'Target'), DIAGRAM_COLORS.dataExtension, targetCells, g.cardWidth, 2 * g.zonePitch + g.zoneInset, g.zoneInset); var targetCards = [targetCard]; var targetHeight = g.zoneInset + targetCard.size.height + g.zoneInset; if (typeof target.action === 'string' && target.action.trim()) { targetCards.push(diagramActionLabel('label-action', 'Action: ' + target.action.trim(), targetCard.position.x, targetCard.position.y + targetCard.size.height + g.actionGap)); targetHeight += g.actionGap + g.actionHeight; }; parts.push({ zoneId: 'zone-target', label: 'Target', color: DIAGRAM_COLORS.target, x: 2 * g.zonePitch, height: targetHeight, cards: targetCards }); }; parts.forEach(function (part) { cells.push(diagramZoneCell(part.zoneId, part.label, part.color, part.x, part.height, part.cards.map(function (c) { return c.id; }))); }); parts.forEach(function (part) { part.cards.forEach(function (card) { card.parent = part.zoneId; cells.push(card); }); }); var cardFieldOf = function (rec, field) { var card = cardOf[rec.id]; for (var i = 0; i < card.fields.length; i++) { if (card.fields[i].label.toLowerCase() === field.toLowerCase()) return card.fields[i]; }; return null; }; var fidOf = function (rec, field) { var found = cardFieldOf(rec, field); return found ? found.fid : null; }; var count = 0; rows.forEach(function (row, i) { var seen = {}; row.sources.forEach(function (source) { var fid = fidOf(source.rec, source.field); var key = source.rec.id + ':' + source.field.toLowerCase(); if (!fid || seen[key]) return; seen[key] = true; count++; links.push(diagramMappingLink('map-' + count, { id: cardOf[source.rec.id].id, port: 'field-right-' + fid }, { id: queryCard.id, port: 'field-left-q' + (i + 1) }, source.rule)); }); if (targetCard && row.name !== null && Object.prototype.hasOwnProperty.call(targetByName, row.name.toLowerCase())) { count++; links.push(diagramMappingLink('map-' + count, { id: queryCard.id, port: 'field-right-q' + (i + 1) }, { id: targetCard.id, port: 'field-left-g' + (targetByName[row.name.toLowerCase()] + 1) }, null)); }; }); var relCount = 0; model.joins.forEach(function (join) { if (join.a.rec === join.b.rec) return; var ends = diagramEnds(join); var fromField = cardFieldOf(ends.from.rec, ends.from.field); var toField = cardFieldOf(ends.to.rec, ends.to.field); if (!fromField || !toField) return; if (ends.manyField && toField.keyType !== 'pk') toField.keyType = 'fk'; relCount++; links.push(diagramRelationLink('rel-' + relCount, { id: cardOf[ends.from.rec.id].id, port: 'field-left-' + fromField.fid }, { id: cardOf[ends.to.rec.id].id, port: 'field-left-' + toField.fid }, ends.fromMarker, ends.toMarker)); }); return { version: 1, appVersion: DIAGRAM_APP_VERSION, title: title, diagramType: 'datamapping', graph: { cells: cells.concat(links) } }; }; function buildQueryDiagram(sql, schema, options) { options = options && typeof options === 'object' ? options : {}; var kind = options.kind === undefined || options.kind === null ? 'model' : options.kind; if (DIAGRAM_KINDS.indexOf(kind) === -1) return { error: 'Unknown diagram kind "' + kind + '".' }; var title = typeof options.title === 'string' && options.title.trim() ? options.title.trim() : 'Query'; var model = analyzeQueryForDiagram(sql, schema); if (model.error) return { error: model.error }; var diagram; try { diagram = kind === 'lineage' ? diagramLineage(model, sql, schema, options, title) : diagramDataModel(model, kind === 'modelAll', title); } catch (e) { return { error: 'SQL Studio could not draw this query.' }; }; var name = kind === 'lineage' ? 'lineage' : 'data model'; var hint = kind === 'lineage' ? 'Try a data model, or a query of fewer columns.' : 'Try a query of fewer tables.'; var count = diagram.graph.cells.length; if (count > DIAGRAM_MAX_CELLS) return { error: 'The ' + name + ' would have ' + String(count).replace(/\B(?=(\d{3})+(?!\d))/g, ',') + ' shapes and lines, and Diagramforce opens at most 2,000. ' + hint }; if (JSON.stringify(diagram).length > DIAGRAM_MAX_JSON) return { error: 'The ' + name + ' would be larger than the 8 MB Diagramforce opens. ' + (kind === 'modelAll' ? 'Try the fields in the query.' : hint) }; return diagram; }; function queryDiagramTables(sql, schema) { var model = analyzeQueryForDiagram(sql, schema); if (model.error) return { error: model.error }; return { tables: model.records.map(function (rec) { return { name: rec.name, table: rec.table, kind: rec.kind }; }) }; }; var SQLStudioSQLTools = { tokenize: tokenize, parse: parse, extractColumns: extractColumns, inferColumnTypes: inferColumnTypes, inferColumnTypeTables: inferColumnTypeTables, inferSaveColumnTypes: inferSaveColumnTypes, inferSaveColumnSources: inferSaveColumnSources, inferValueType: inferValueType, isEmailAddress: isEmailAddress, formatDateValue: formatDateValue, formatBooleanValue: formatBooleanValue, formatDecimalValue: formatDecimalValue, formatTypedValue: formatTypedValue, suggestAliases: suggestAliases, lint: lint, fixAll: fixAll, applyEdits: applyEdits, completionContext: completionContext, joinCompletions: joinCompletions, joinToQuery: joinToQuery, knownJoinFields: knownJoinFields, joinKeyColumns: joinKeyColumns, joinKeywordCompletions: joinKeywordCompletions, deriveDeAlias: deriveDeAlias, dataViewTable: dataViewTable, formatIdentifier: formatIdentifier, buildFullSelect: buildFullSelect, formatSql: formatSql, sameTokens: sameTokens, sameQuery: sameQuery, runScope: runScope, splitStatements: splitStatements, parseStrict: parseStrict, buildIndex: buildIndex, buildQueryDiagram: buildQueryDiagram, queryDiagramTables: queryDiagramTables, lookupTable: lookupTable, fieldCheckTable: fieldCheckTable, exportPageSize: exportPageSize, resultChunkSize: resultChunkSize, isFreeReadPage: isFreeReadPage, resultChunkCount: resultChunkCount, resultChunkLength: resultChunkLength, resultChunksForPage: resultChunksForPage, createChunkCache: createChunkCache, chunkCacheMatches: chunkCacheMatches, chunkCacheGet: chunkCacheGet, chunkCachePut: chunkCachePut, FREE_READ_MAX_ROWS: FREE_READ_MAX_ROWS, WSPROXY_BATCH_ROWS: WSPROXY_BATCH_ROWS, WSPROXY_EXPORT_MAX_COLUMNS: WSPROXY_EXPORT_MAX_COLUMNS, RESULT_CHUNK_CACHE_MAX: RESULT_CHUNK_CACHE_MAX, MCE077_PLACES: MCE077_PLACES, RESERVED_WORDS: RESERVED_WORDS, HIGHLIGHT_KEYWORDS: HIGHLIGHT_KEYWORDS, HIGHLIGHT_TYPES: HIGHLIGHT_TYPES, NEWER_THAN_2022: NEWER_THAN_2022, TRACKING_VIEWS: TRACKING_VIEWS, SEND_LOG_KEYS: SEND_LOG_KEYS, subscriberFieldKind: subscriberFieldKind }; if (typeof window !== 'undefined') window.SQLStudioSQLTools = SQLStudioSQLTools; if (typeof module !== 'undefined' && module.exports) module.exports = SQLStudioSQLTools; }()); /* ======================================================== sqlstudio.css == */ window.SQLStudioEmbeddedCss = "/* ==========================================================================\n SQL Studio styles\n\n Brand: SQL Studio follows the same design tokens and Lightning-adjacent\n chrome as its sibling app Diagramforce. The dark tokens\n are the base, and .ss[data-theme=\"light\"] overrides them. The App picks\n the theme from the user's choice or the operating system's mode. Everything is scoped under .ss so the app can be\n dropped into a Cloud Page without leaking styles or claiming .\n\n Sections:\n 1. Design tokens (brand, semantic, surfaces, sizing, shadows)\n 2. Reset and base\n 3. Layout shell (toolbar-on-top anatomy: header, sidebar, main - no status bar)\n 4. Buttons and form controls\n 5. Sidebar tree and search\n 6. Toolbar, help/theme menu, and problems strip\n 7. Editor pane and split resizer\n 8. Results grid, header, notice, error panel and pager\n 9. Dialogs, dropdown managers and modal\n 10. Toasts\n 11. Misc (badges, scrollbars, focus, responsive)\n ========================================================================== */\n\n/* -------------------------------------------------------------------- */\n/* 1. Design tokens */\n/* -------------------------------------------------------------------- */\n.ss {\n /* Brand (theme-stable) */\n --brand-blue: #1D73C9;\n --brand-red: #DA4E55;\n --brand-amber: #F6B355;\n --brand-amber-strong: #D4911F;\n --brand-green: #27AE60;\n\n /* Semantic: the primary hue flips between themes (deliberate, matches\n Diagramforce); danger is always the brand red. --color-action is a separate accent hue for the\n Run button, primary dialog buttons, and results/pager accents - it\n also flips between themes, but independently of --color-primary. */\n --color-primary: var(--brand-red);\n --color-primary-hover: color-mix(in srgb, var(--color-primary) 82%, black);\n --color-danger: var(--brand-red);\n --color-accent: var(--brand-amber);\n --color-action: var(--brand-amber);\n --color-action-hover: color-mix(in srgb, var(--color-action) 82%, black);\n --color-action-fg: #1C1E21;\n --tint-danger: color-mix(in srgb, var(--color-danger) 14%, transparent);\n --tint-warning: color-mix(in srgb, var(--warning-color, var(--brand-amber)) 16%, transparent);\n --tint-info: color-mix(in srgb, var(--brand-blue) 14%, transparent);\n /* The focused completion row and the header strip of its details panel, in both themes: brand blue with white\n text, which reads at 4.8:1 on it, and the row reads at 4.4:1 against the light list and 3.2:1 against the dark\n one. The Monaco theme writes the same pair (Editor._registerThemes). The rest of the panel is a card in a light\n tint of the same blue, under which the note's secondary text keeps 4.5:1 in the light theme. */\n --focus-band: var(--brand-blue);\n --focus-band-fg: #FFFFFF;\n --focus-card: color-mix(in srgb, var(--brand-blue) 8%, transparent);\n --tint-success: color-mix(in srgb, var(--brand-green) 14%, transparent);\n\n /* Dark theme surfaces and text (default) */\n --bg-app: #212121;\n --bg-surface: #18191A;\n --bg-surface-raised: #242526;\n --bg-elevated: rgba(127, 127, 127, 0.08);\n --bg-hover: rgba(255, 255, 255, 0.10);\n --text-primary: #F5F6F7;\n --text-secondary: #B0B3B8;\n --text-muted: #9CA3AF;\n /* The rows of a query the editor no longer holds, and the mark that says so (.ss-stale-mark): muted, at 4.67:1 on a hovered row and\n 5.77:1 on the results header. */\n --text-stale: var(--text-muted);\n /* The soft hatch behind those rows: lighter stripes, which keep --text-stale at 5.5:1 or more where they lie. */\n --stale-hatch: rgba(255, 255, 255, 0.045);\n --text-inverse: #1C1E21;\n --border-color: #3A3B3C;\n --border-color-strong: #4E4F50;\n --toolbar-bg: #242526;\n --toolbar-button-hover: rgba(255, 255, 255, 0.10);\n --toolbar-button-active: rgba(218, 78, 85, 0.15);\n /* The quick tip (.ss-tip): a step lighter than the sidebar, with a border and a tight shadow that set it off from the rows\n under it (the author's RC11 test, 2026-10-09: the surface and border colours left it hard to tell from the list). */\n --tooltip-bg: #333437;\n --tooltip-fg: var(--text-primary);\n --tooltip-border: #6A6D72;\n --shadow-tooltip: 0 4px 14px rgba(0, 0, 0, 0.55);\n --modal-btn-neutral-bg: #E4E6EB;\n --modal-btn-neutral-hover: #D2D6DC;\n --modal-btn-neutral-fg: #1C1E21;\n /* The severity colours: --warning-color, --color-danger and --brand-blue\n mark (squiggles, rulers, borders, dots) at 3:1 or more, and the -text\n variants colour words at 4.5:1 or more on the tints, the active tab and\n the hovered tab they sit on. The dark theme's error red and info blue are\n a shade lighter as text. */\n --warning-color: var(--brand-amber);\n --warning-text: var(--warning-color);\n --danger-text: #E89397;\n --info-text: #73AFEB;\n --shadow-sm: 0 1px 2px 0 rgba(0, 0, 0, 0.3);\n --shadow-md: 0 5px 40px rgba(0, 0, 0, 0.35);\n --shadow-lg: 0 12px 28px 0 rgba(0, 0, 0, 0.4), 0 2px 4px 0 rgba(0, 0, 0, 0.2);\n --shadow-dropdown: 0 14px 24px -10px rgba(0, 0, 0, 0.55);\n\n /* Editor tokens: keywords/functions use the\n flipping primary/accent hues, data views stay brand blue in both\n themes, strings are always green, comments always --text-muted.\n Each reads at 4.5:1 or more on --editor-bg, which the details\n panel's code blocks share too: where a brand colour falls short,\n the token takes the same hue a step lighter here, and a step darker\n in the light theme below. Dark: the keyword red is #DB545B (the\n brand red is 4.36:1), the Data View blue #2884E0 (the brand blue\n is 3.65:1). */\n --editor-bg: var(--bg-surface);\n --editor-fg: var(--text-primary);\n --editor-keyword: #DB545B;\n --editor-function: var(--brand-amber);\n --editor-string: var(--brand-green);\n --editor-identifier: var(--text-primary);\n --editor-dataview: #2884E0;\n --editor-comment: var(--text-muted);\n --editor-operator: #778899;\n\n /* Sizing and type */\n --toolbar-height: 50px;\n --panel-header-height: 45px;\n --border-radius-sm: 6px;\n --border-radius-md: 8px;\n --spacing-xs: 4px;\n --spacing-sm: 8px;\n --spacing-md: 12px;\n --spacing-lg: 16px;\n --spacing-xl: 24px;\n --font-family: system-ui, -apple-system, \"Segoe UI\", Roboto, Ubuntu, Cantarell, \"Noto Sans\", sans-serif;\n --font-mono: ui-monospace, SFMono-Regular, \"SF Mono\", Menlo, Consolas, \"Liberation Mono\", monospace;\n --font-size-xs: 11px;\n --font-size-sm: 12px;\n --font-size-md: 14px;\n --font-size-lg: 16px;\n --line-height: 1.65;\n --logo-data-uri: url(\"data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAEAAAABACAYAAACqaXHeAAAABGdBTUEAALGPC/xhBQAAAERlWElmTU0AKgAAAAgAAYdpAAQAAAABAAAAGgAAAAAAA6ABAAMAAAABAAEAAKACAAQAAAABAAAAQKADAAQAAAABAAAAQAAAAABGUUKwAAABy2lUWHRYTUw6Y29tLmFkb2JlLnhtcAAAAAAAPHg6eG1wbWV0YSB4bWxuczp4PSJhZG9iZTpuczptZXRhLyIgeDp4bXB0az0iWE1QIENvcmUgNi4wLjAiPgogICA8cmRmOlJERiB4bWxuczpyZGY9Imh0dHA6Ly93d3cudzMub3JnLzE5OTkvMDIvMjItcmRmLXN5bnRheC1ucyMiPgogICAgICA8cmRmOkRlc2NyaXB0aW9uIHJkZjphYm91dD0iIgogICAgICAgICAgICB4bWxuczpleGlmPSJodHRwOi8vbnMuYWRvYmUuY29tL2V4aWYvMS4wLyI+CiAgICAgICAgIDxleGlmOkNvbG9yU3BhY2U+MTwvZXhpZjpDb2xvclNwYWNlPgogICAgICAgICA8ZXhpZjpQaXhlbFhEaW1lbnNpb24+MTk2PC9leGlmOlBpeGVsWERpbWVuc2lvbj4KICAgICAgICAgPGV4aWY6UGl4ZWxZRGltZW5zaW9uPjE5NjwvZXhpZjpQaXhlbFlEaW1lbnNpb24+CiAgICAgIDwvcmRmOkRlc2NyaXB0aW9uPgogICA8L3JkZjpSREY+CjwveDp4bXBtZXRhPgosnlo7AAAMIklEQVR4AeVba2wdxRX+9u7eh31jO7ZjB2xwchMCAURCHhWPQsEhIWlTKlIeKvTxB1X9gUAV/0qlItoK2kogVX0L8SeIP6gtqC0VJLQxSdoqPOI4AkNMXnZiOwlx8OPavs/dfmf2Pnbvrl+xHYN9lPHenZmdme+bc86cmc1qmIRYlhVhtVqmaqZFTGEmnenzJFkOJskUZ/qMqU/TtASv44o2VilBC8htTDsAaz2vjUxRQDN4T/BjPspqcyEWO9VIgpXhj2Gmbt4f5PVVpjdIhpAzOSH4rUz/Y5ovIli2+qH3TCMr/ogVn2YK2g8Is19kKUBME8VT1IRnnWgKpZKZA/+MXeGLDtwJU34XoD7pJKGQS/BbWOt1Js78fAMvBIgouKIJ20nC7kIOwZcR9F5W2Dh/wQtcESHBeo/Xr5CE0YDKA+5eGOAFrVotONGCGcgTcJ/cLDBRmANUfwly1i0w8AJ3nWAXDWCEZzUsPAIU5lohoIaJEd6CE8FcQwIyjO0lvF1oIpgzi0iAEaIJfN42NpdgNgSzERIT+BxubC4Bfjso0j2qr2JA/tH8okFN+JqJKDEXgFqmP1KtEKD6lI9VdnHjKiFAw7njXfjJy+9hRA+7SMhmTTTVV+HpR25DyBCLubgOhVozPoSfvrAHHSMBGE48bNLQA/jZI7ejsa4i14eGVDqDH7+4H73n4zCMAMIhA3VVZbiqsRprV9ZhdVMNysK5vdsUx1VCAFA51IfIBwfREr2Gpx5yxmCLwG09eh7N65tw95di+eyLuva1tODwgQ/RHm1EyKEFFjsJEmAiVexXOjCZ336iD8fP9CuCBKPJylJfyFi+tAKbNy7HjtuuQsMSIU6EhZMQ0WmXWIEAtmW6UKunybauBiSDCjHpuoadu9qRysgAnVPnamKcGw3ZkTj69ryBUCSIEGdb2i5NfhZgj0FX2hcK6ogQeFnYgNQ93juA373aiod//jr+9Pc2jCZlvzO58XkIyJKAK41BfDlzGqlCpGxjEtVvO/op9rR2jQNy/KKBAy3IDnQjQAAzIQJTzEbI6I8n8es/v48fPLcbR0/LqdjEJHgIED+nRXVs00+gikdslk8jLxW0YCoQaPujcQy+uwuhqiC0WVh49YCmiDjYcRbff+4tHOw4MyEJXgJkYGU6lkXiuF3v9mpBMEAtOJfTgokZdlIUP7wX5lAPgtEgAhzsJM1UNSH2ztjdTs5GfX5rRhBGqg/mu88je+ET1hh7nF4CODCjTINWHsD26ElU8SzRLG2Ahjc1LeDsJ4YxfHgXjKgBnacPJdblA8OdFSLx4vDE/oW5RCqDZDqrHKGzZsoMoDE6il/d9AFWR05h6MAfqXkXWMWfBI8higkYEVamL4hpw2jO9OC14RjCcuCaE3GIbcdsX7BVrQgTe9zRjr3ASC+C5Qw8uaSqkCLf4LhXC2GCf/7RZqT5nGjN0GgKR7ouYO/h0zjwUS8SyYwixgafwLMb2nB1xSCSZhjWYA9G2l7GopseJQdeEjwEyMzoigCpHMA91Z1oSTRi2DR45wRq4aU329G8rkmtEGNjYEiVHMbox7sRiEh31C6TyaN747TAgV9ZX+mqsHZlPR5sXo3WT87iN39txX/azyC2OIVfbDiEVZUDSGZtJ6MZYaROHUCq6VaEGjawDScGH0WUgemcJElWSENsURybKukLqFpOkRXh8PFzePvQKWZ7mXXWTRzfB3O4VxEQYLsa28UUfYA9cBm8O61btRR/eGILHv3qSjy95j2sqiiCz49BfEfiyD8ZPMgrA7e4UbFM48D0MNd8bhMkgene+lOoNlIeXyBD2fnmh7m4wN2wfcfZTw0jcfQttexpEvbRfAJBasD4nPk1NkaemIiOx791C9asuQEJ+oVS0fQgMn0dSJ/vYJG7Yy8BLM9rgNIC2t+Kyji2LOnx1YJDx86h5VCXp+H8IJIn9yEb74Emzov/ZBOqiHCPI1/9Iq8yFQYia74LvYJnO6aXBCubQar7HU/7HgKEIE1myJGynLV7G7pQHeSKYHlHLr7AGx3K7MeROMbZp7mIacnar9Z/2xV4BjO9DEYs4UpErt7KlcFLgBYwkPn0CKxsytWNPwFUVZmlfDIZacUqh7Glvpda4CZAfMEhrggtyhe42kaycz+yg920d0HOf3kSNNuO5e9MS6hxIwJlfIfr2GOoPti5OcLYQC2JxV59CVBbNGWvHHXuKpzefwPPkCrCaiNSbML+9ZL4goL9FW1fK91xc2D6ohrolZdTVcfYDpc2Pul7i462GkZVE/GXaAGdjpVJKBKczXkIEOeUV1XnNaOZWNVUja/fskIFIM5GJC4o9QXJTtr+EGdf6byjtplGcPmdDLTqmFkySEe16fzUK5dSe2kSDi1WfidAwtPy4rgo3jiAZaKqJc5SrT5ZLicPbVqNf+zvQJzBiApnC23Z0WHz+mUImqPK84vduYS2GYjWIxJrJvYDrqKZvIl36ejbn+Zq455fM52CEeMENBZ7KxkhC3K26k8AD0XqothOLZDlT3ZgeSloQVsvttS0K9uXIMQp4okjK5qhRaroqGZa/Ys9xU8Mor89xeW8mCe/TB6sLB2lP3KIm6J8AUlQBJRec+UP33UtaiojPr4ggJ2vv4MLH+2i3yuSox5Ttl+P8Io7c63MxoUDpl9J9narGKbUBCQSDVZXuTr2J8BVxXvTtLSSvmCljy/Q0HZyEHuPWQyPhb2iWLT9sMx+eDEzZ8P/230lensx0tlF+y+ZABJjRKMI14nvKcpFESCPKy2ooBbIeZVT6PRe6VyORIZrfz5fZp9OLxy7M58za9dP/92CzNAQNbjQu+rLzGYRabicGiD/zakoF0mABdGCe3y0IEhP+8Fni7HvbB2PvGwvb8/+Ji5Rszn7GkY7O3HujTfp/PIHpEWgYhpVN67lojQZH+B4bryfD22+FrW+vgB45WSTrQX0/HqUs0/1nz3RkBkYwPHf/t6efW7lXcLVSy8vR/UtN7uy5aakpqd8nAxqAbeoX7uZcUHJKW5BC87VI8QzpXBMZl+cT4m5jNP65IpEzTUkunvQ8cwvEf/4iO/sm6k0Fm9Yj/LlyzxjKPEUk+vWWevbm6/D3/57TB1IBhx2l6Zv2PnxZbj1iiTqZmT23TYtY5BZP//2PvS++hpS589zx8m9dqlQ9fXyCC6/75sskTbckzBNAmxf8NiOG3GIB5ByjO6UbLYR/SvuQP00Z98iiIH3W3mkPqKaT/f3Y+TECQx92I7EmbPKrn3Bs7aZSqHxvh2IXrWSd27w0tg0CZAmLDy8+XqV5M5fvB371/PLZQzPAKbzhRcxevq07cRo0zKbstSNBVxaMhNJLN64AQ0P3O/XsMqbAQKknekAHHNsrgKNnl3AlnpxVyXHjZlMYtE1VyP2+GMIhCUk9B/jDBHg6Hmuf1I7BHzl2jVY+cQPEaqV///hD16GOq8IMNPcADEEv+wb9+CK732Hx+9y/j42+HlBgDhIi8DFNCqvvw4NDz6AqnU3CjbK+OClhlcDGLZaWf7HarUnliq2WJkUDxnkpePMiLzUGOV5ftYRSotvy/L0WU5xS0W8uah20Qdo9Al8wVpbi4rrVqP2jjsY6a1huUDyPl/aXv7eQ0CgfAki12xneUmMxCNlozqWf27a1+0MoG6ILVFvnAuNcdwBRnGLuccoCg82eCRXv3ULMv0DPFjVua6XIVRTg0hjI8quvAJGRf6VuDw1efBSWyPbm3j9l9zYD3sDDrss/3dqHeSf8l6n2s949ac6pkJbd4kGcMciDeQzp9qYF9rkcqbaz1TrTzQKhTkrei4GPzuHcxONYU7LFeakEMBTQvWZyZwO59J3bn9aIwTIu2P3UemlH81c9CiYLwgBfTSBnrkYwdz2qTD3cQerPi1rndvBzEnvrYJdNEDkL/ZlQf1VmPME7KIjlM9IFgADglGwgphz4R5VYZTgn+Q9Y935TILCJhjlyzFidsS7zNjN+6ckc36SUJhY+XZQsCrJm4C6YYF8VOjQhMJDdu0v5F/BkJ95uL4ZHBMO9wcL99PZPCskQc6Rch9Pgx9PW42852cm6usS9+ln/qG5v8q+JvfxtMZ385jw4+lJ6TjJkP0pP65yfj7Pd18+xwlzw4FgNmQ/w33N1D6f/z9nGYh4iZaoUAAAAABJRU5ErkJggg==\");\n --transition-fast: 150ms ease;\n --transition-normal: 400ms cubic-bezier(0.08, 0.52, 0.52, 1);\n\n color-scheme: dark;\n background: var(--bg-app);\n color: var(--text-primary);\n}\n\n.ss[data-theme=\"light\"] {\n --color-primary: var(--brand-blue);\n --color-primary-hover: color-mix(in srgb, var(--color-primary) 85%, black);\n --color-action: var(--brand-blue);\n --color-action-hover: color-mix(in srgb, var(--color-action) 85%, black);\n --color-action-fg: #FFFFFF;\n\n --bg-app: #F5F6F7;\n --bg-surface: #FFFFFF;\n --bg-surface-raised: #FFFFFF;\n --bg-elevated: rgba(127, 127, 127, 0.08);\n --bg-hover: rgba(0, 0, 0, 0.06);\n --text-primary: #1C1E21;\n --text-secondary: #606770;\n --text-muted: #65707B;\n /* Secondary, not muted, in the light theme: --text-muted on the app background read at 4.09:1 on a hovered row and 4.29:1 on the\n results header, where --text-secondary keeps 4.63:1 and 4.86:1. The two greys are a shade apart. */\n --text-stale: var(--text-secondary);\n /* Darker stripes in the light theme, which keep --text-stale at 4.7:1 or more where they lie. */\n --stale-hatch: rgba(0, 0, 0, 0.05);\n --text-inverse: #FFFFFF;\n --border-color: #DADDE1;\n --border-color-strong: #BEC3C9;\n --toolbar-bg: #FFFFFF;\n --toolbar-button-hover: rgba(0, 0, 0, 0.06);\n --toolbar-button-active: rgba(53, 120, 229, 0.12);\n --tooltip-bg: #FFFFFF;\n --tooltip-fg: var(--text-primary);\n --tooltip-border: #A9AFB6;\n --shadow-tooltip: 0 4px 14px rgba(0, 0, 0, 0.16);\n --modal-btn-neutral-bg: #E4E6EB;\n --modal-btn-neutral-hover: #D2D6DC;\n --modal-btn-neutral-fg: #1C1E21;\n /* The light theme's severity colours: the warning amber a shade darker\n than the strong one, which marked at 2.67:1 on white, and every -text\n variant a shade darker again, for 4.5:1 as text. */\n --warning-color: #B47B1A;\n --warning-text: #825913;\n --danger-text: #B9272E;\n --info-text: #1962AC;\n --shadow-sm: 0 1px 2px 0 rgba(0, 0, 0, 0.1);\n --shadow-md: 0 5px 40px rgba(0, 0, 0, 0.12);\n --shadow-lg: 0 12px 28px 0 rgba(0, 0, 0, 0.2), 0 2px 4px 0 rgba(0, 0, 0, 0.1);\n --shadow-dropdown: 0 14px 24px -10px rgba(0, 0, 0, 0.18);\n\n /* The light theme's editor tokens at 4.5:1 or more on white: the keyword\n and Data View blue as they are, the amber a step darker than the\n strong one (2.67:1), the green a step darker than the brand green\n (2.87:1), the operator grey a step darker than Monaco's (3.64:1), and\n the second bracket pair's green a step darker than Monaco's (3.93:1). */\n --editor-keyword: var(--color-primary);\n --editor-function: #9E6C17;\n --editor-string: #1E864A;\n --editor-dataview: var(--brand-blue);\n --editor-operator: #667788;\n --editor-bracket-2: #2D872D;\n\n color-scheme: light;\n}\n\n/* -------------------------------------------------------------------- */\n/* 2. Reset and base */\n/* -------------------------------------------------------------------- */\n.ss, .ss * {\n box-sizing: border-box;\n}\n.ss {\n position: relative;\n display: flex;\n flex-direction: column;\n /* The viewport, not 100%: the Cloud Page gives html, body and #sqlstudio no height, so a\n percentage resolved to auto there and the app grew with its content (a 100-row page made\n it about 3,000px tall, scrolling the whole page instead of the grid). */\n height: 100vh;\n min-height: 640px;\n font-family: var(--font-family);\n font-size: var(--font-size-sm);\n line-height: var(--line-height);\n}\n/* :where() keeps this reset at zero specificity, so every component's own\n color rule (.ss-btn, .ss-toolbar__button, .ss-tab, ...) always wins\n without needing !important or selector-weight tricks. */\n:where(.ss button, .ss input, .ss select, .ss textarea) {\n font-family: inherit;\n font-size: inherit;\n color: inherit;\n}\n/* A page without a doctype renders in quirks mode, where tables do not inherit\n the text colour or font: the results grid came out black on the dark theme\n on the first org deployment. */\n:where(.ss table) {\n color: inherit;\n font-size: inherit;\n font-weight: inherit;\n font-style: inherit;\n line-height: inherit;\n}\n.ss a {\n color: var(--color-primary);\n}\n.ss svg {\n fill: currentColor;\n}\n.ss :focus-visible {\n outline: 2px solid var(--color-primary);\n outline-offset: 1px;\n}\n.ss ul {\n margin: 0;\n padding: 0;\n list-style: none;\n}\n.ss h1, .ss h2, .ss h3, .ss p {\n margin: 0;\n}\n\n/* -------------------------------------------------------------------- */\n/* 3. Layout shell */\n/* -------------------------------------------------------------------- */\n.ss-toolbar__brand {\n display: flex;\n align-items: center;\n gap: var(--spacing-sm);\n text-decoration: none;\n white-space: nowrap;\n flex: 0 0 auto;\n padding: 0 var(--spacing-xs);\n}\n.ss-toolbar__brand-logo {\n width: 32px;\n height: 32px;\n flex: 0 0 auto;\n border-radius: var(--border-radius-sm);\n background-image: var(--logo-data-uri);\n background-size: contain;\n background-repeat: no-repeat;\n background-position: center;\n}\n.ss-toolbar__brand-name {\n font-size: 16px;\n font-weight: 700;\n letter-spacing: -0.3px;\n color: var(--color-primary);\n}\n.ss-about-logo {\n width: 64px;\n height: 64px;\n border-radius: var(--border-radius-md);\n background-image: var(--logo-data-uri);\n background-size: contain;\n background-repeat: no-repeat;\n background-position: center;\n}\n.ss-header__actions {\n display: flex;\n align-items: center;\n gap: var(--spacing-xs);\n}\n\n.ss-body {\n display: flex;\n flex: 1 1 auto;\n min-height: 0;\n position: relative;\n}\n\n.ss-sidebar {\n display: flex;\n flex-direction: column;\n width: 280px;\n min-width: 240px;\n max-width: 480px;\n flex: 0 0 auto;\n position: relative;\n border-left: 1px solid var(--border-color); /* the sidebar sits on the right, like Diagramforce's stencil */\n background: var(--bg-elevated);\n overflow: hidden;\n}\n.ss-sidebar.is-collapsed {\n width: 0 !important;\n min-width: 0;\n border-left: none;\n}\n.ss-sidebar__resizer {\n position: absolute;\n top: 0;\n bottom: 0;\n left: 0;\n width: 6px;\n cursor: col-resize;\n z-index: 5;\n}\n.ss-sidebar__search {\n position: relative;\n display: flex;\n align-items: center;\n height: var(--panel-header-height);\n padding: 0 var(--spacing-sm);\n border-bottom: 1px solid var(--border-color);\n}\n.ss-sidebar__search .ss-input {\n padding-right: 26px;\n}\n.ss-sidebar__search-clear {\n position: absolute;\n right: 14px;\n top: 50%;\n transform: translateY(-50%);\n border: none;\n background: none;\n padding: 2px;\n color: var(--text-secondary);\n cursor: pointer;\n border-radius: var(--border-radius-sm);\n}\n.ss-sidebar__search-clear:hover {\n color: var(--color-primary);\n background: var(--bg-hover);\n}\n.ss-sidebar__tree {\n flex: 1 1 auto;\n overflow-y: auto;\n overflow-x: hidden;\n padding: var(--spacing-xs) 0;\n}\n.ss-tree__section-title {\n padding: var(--spacing-sm) var(--spacing-md) 2px var(--spacing-sm);\n font-size: var(--font-size-sm);\n font-weight: 700;\n color: var(--text-primary);\n text-transform: uppercase;\n letter-spacing: 0.03em;\n}\n/* What is under a section title starts one indent step in from it, as a folder's children do from the\n folder, so the title reads as the outermost level: the title's caret is at the sidebar's left padding, a\n top-level row's caret one step in and a subfolder's another. */\n.ss-tree__section-body {\n padding-left: var(--spacing-lg);\n}\n/* The lines in a section body (the hint, a group title, a message, the footer) start in line with the carets\n of its rows, not a step further in. */\n.ss-tree__section-body > .ss-tree__hint,\n.ss-tree__section-body > .ss-tree__group-title,\n.ss-tree__section-body > .ss-tree__empty,\n.ss-tree__section-body > .ss-tree__loading,\n.ss-tree__section-body > .ss-tree__list-status {\n padding-left: var(--spacing-sm);\n}\n/* A section title as a button: pressing it collapses or expands the section, as a Data View group's\n caret does. It keeps the title's type, and adds the caret and a hover. */\n.ss-tree__section-toggle {\n display: flex;\n align-items: center;\n gap: 4px;\n width: 100%;\n border: none;\n background: none;\n line-height: var(--line-height);\n text-align: left;\n cursor: pointer;\n}\n.ss-tree__section-toggle:hover {\n background: var(--bg-hover);\n}\n.ss-tree__section-caret {\n flex: 0 0 auto;\n width: 14px;\n text-align: center;\n color: var(--text-secondary);\n}\n/* The Snippets title holds its toggle and its save button side by side, as a table row holds its toggle and its actions, so\n no control sits inside another. The toggle fills the row and the save button keeps the right end. */\n.ss-tree__section-head {\n display: flex;\n align-items: center;\n padding-right: var(--spacing-xs);\n}\n.ss-tree__section-head > .ss-tree__section-toggle {\n flex: 1 1 auto;\n width: auto;\n min-width: 0;\n}\n/* The app's own tooltip (UI._wireTips), for elements with a data-tip: it comes sooner than a title's. */\n.ss-tip {\n position: absolute;\n z-index: 300;\n max-width: min(420px, calc(100% - 16px));\n /* Its line breaks and the SQL's indentation are kept, as a snippet's and a tab's tip show SQL. */\n white-space: pre-wrap;\n tab-size: 4;\n overflow-wrap: anywhere;\n padding: 4px 8px;\n border: 1px solid var(--tooltip-border);\n border-radius: var(--border-radius-sm);\n background: var(--tooltip-bg);\n box-shadow: var(--shadow-tooltip);\n font-size: var(--font-size-xs);\n color: var(--tooltip-fg);\n pointer-events: none;\n}\n.ss-tip[hidden] {\n display: none;\n}\n/* The reload icon of a section's title turns while its list loads (UI._renderReloadButton). */\n.ss-tree__section-action.is-busy svg {\n animation: ss-spin 0.8s linear infinite;\n}\n/* A line of a section's list state, under its hint: the pages of a first load, a list that stops short, a reload that failed. */\n.ss-tree__list-status {\n padding: 2px var(--spacing-md) var(--spacing-xs);\n color: var(--text-muted);\n font-size: 11px;\n}\n.ss-tree__section-head > .ss-tree__section-action {\n margin-top: var(--spacing-xs);\n}\n/* The section titles dock (1.2.0, the author's RC3 test, 2026-10-08: the Snippets section was out of sight below a long Data\n Extension list until the others were collapsed). Each sticks to the top of the sidebar's scroll while its section scrolls\n by, below the titles before it, and to the bottom while its section is further down, above the titles after it, so every\n title is always in sight. Each title is one height (--ss-dock-h, the height the title had already), and\n UI._dockSectionTitles gives each the number of titles above and below it, so they stack without a gap or an overlap,\n whatever the sidebar's state when the tree is drawn. The background covers the rows that scroll under them: the sidebar's\n own --bg-elevated is a see-through grey over the app's --bg-app, so the title lays the same two on each other. */\n.ss-tree__dock {\n --ss-dock-h: 30px;\n position: sticky;\n z-index: 2;\n /* Less the scroll's own padding, which sticky offsets start inside: rows showed through that strip above the top title. */\n top: calc(var(--ss-dock-h) * var(--ss-dock-above, 0) - var(--spacing-xs));\n bottom: calc(var(--ss-dock-h) * var(--ss-dock-below, 0) - var(--spacing-xs));\n box-sizing: border-box;\n height: var(--ss-dock-h);\n background: linear-gradient(var(--bg-elevated), var(--bg-elevated)), var(--bg-app);\n}\n/* The Data Views title is its own toggle, with no head around it: its hover lays --bg-hover over the same two, as the hover's\n see-through grey alone let the rows under it show through (the author's RC10 test, 2026-10-09). */\n.ss-tree__dock.ss-tree__section-toggle:hover {\n background: linear-gradient(var(--bg-hover), var(--bg-hover)), linear-gradient(var(--bg-elevated), var(--bg-elevated)), var(--bg-app);\n}\n/* The line under a snippet's name in its dialog: what is saved, and who sees it. */\n.ss-snippet-note {\n margin: var(--spacing-sm) 0;\n font-size: var(--font-size-sm);\n color: var(--text-secondary);\n}\n/* The SQL a new snippet saves, in the dialog that asks for its name: monospace, its lines kept, as tall as a dozen of them\n before it scrolls. */\n.ss-snippet-preview {\n max-height: 14em;\n margin: 0;\n padding: var(--spacing-sm);\n overflow: auto;\n border: 1px solid var(--border-color);\n border-radius: var(--border-radius-sm);\n background: var(--bg-app);\n color: var(--text-primary);\n font-family: var(--font-mono);\n font-size: 12px;\n white-space: pre;\n user-select: text;\n -webkit-user-select: text;\n}\n/* The line above the Data Extension tree that gives the totals and says to search. */\n/* A note of the Data Extensions section, such as the one on SQL Studio installed in a child, has the hint's look and a class of its own,\n so the hint stays the line above a tree. */\n.ss-tree__hint,\n.ss-tree__note {\n padding: 2px var(--spacing-md) var(--spacing-xs);\n color: var(--text-muted);\n font-size: 11px;\n}\n/* A folder of the Data Extension tree reads heavier than the Data Extensions beside it, which have no count. */\n.ss-tree__folder--de > .ss-tree__name {\n font-weight: 600;\n}\n/* The made-up group of the Data Extensions in no loaded folder reads in italics, so it does not pass\n for a real folder of that name. */\n.ss-tree__folder--made-up > .ss-tree__name {\n font-style: italic;\n}\n/* The number of Data Extensions below a folder, at the right end of its row. */\n.ss-tree__count {\n flex: 0 0 auto;\n color: var(--text-muted);\n font-size: 11px;\n}\n/* The \"Show N more\" button under a folder's first Data Extensions, in line with their names. It is\n a .ss-tree__link too, which comes later in this file, hence the two classes. */\n.ss-tree__link.ss-tree__more {\n display: block;\n padding: 3px var(--spacing-sm) 3px 26px;\n text-align: left;\n}\n/* Inline text link inside a sidebar/Save As hint row (Retry on a failed\n Data Extension list load, Reload in the list-loaded footer). */\n.ss-tree__link {\n display: inline;\n border: none;\n background: none;\n padding: 0;\n color: var(--color-primary);\n cursor: pointer;\n font: inherit;\n}\n.ss-tree__link:hover {\n text-decoration: underline;\n}\n/* Shared DE name prefix, replacing the old SHARED badge. */\n.ss-tree__ent-prefix {\n color: var(--text-muted);\n}\n/* A parent Business Unit Data Extension's folder path, on its own line under its row, in line with the name. */\n.ss-tree__path {\n padding: 0 var(--spacing-sm) 3px 26px;\n color: var(--text-muted);\n font-size: 11px;\n overflow: hidden;\n text-overflow: ellipsis;\n white-space: nowrap;\n}\n/* The \"Parent BU\" heading above the parent's shared and synchronized Data Extensions in a search. */\n.ss-tree__group-title {\n padding: var(--spacing-sm) var(--spacing-md) 2px;\n color: var(--text-secondary);\n font-size: 11px;\n font-weight: 700;\n text-transform: uppercase;\n letter-spacing: 0.03em;\n}\n.ss-sidebar-overlay-bg {\n display: none;\n}\n\n.ss-main {\n display: flex;\n flex-direction: column;\n flex: 1 1 auto;\n min-width: 0;\n min-height: 0;\n}\n\n/* -------------------------------------------------------------------- */\n/* 4. Buttons and form controls */\n/* -------------------------------------------------------------------- */\n/* Neutral dialog/modal button: used for\n Cancel and other non-primary actions in modals and dropdown panels. */\n.ss-btn {\n display: inline-flex;\n align-items: center;\n justify-content: center;\n gap: var(--spacing-xs);\n height: 28px;\n padding: 0 var(--spacing-md);\n border: 1px solid transparent;\n border-radius: var(--border-radius-sm);\n background: var(--modal-btn-neutral-bg);\n color: var(--modal-btn-neutral-fg);\n cursor: pointer;\n white-space: nowrap;\n line-height: 1;\n transition: background-color var(--transition-fast);\n}\n.ss-btn:hover {\n background: var(--modal-btn-neutral-hover);\n}\n.ss-btn:disabled {\n opacity: 0.35;\n cursor: not-allowed;\n}\n.ss-btn--primary {\n background: var(--color-action);\n border-color: var(--color-action);\n color: var(--color-action-fg);\n}\n.ss-btn--primary:hover {\n background: var(--color-action-hover);\n border-color: var(--color-action-hover);\n}\n.ss-btn--icon {\n width: 28px;\n padding: 0;\n}\n.ss-btn--sm {\n height: 24px;\n padding: 0 var(--spacing-sm);\n font-size: var(--font-size-sm);\n}\n.ss-btn--link {\n border-color: transparent;\n background: transparent;\n color: var(--color-primary);\n text-decoration: underline;\n}\n.ss-btn--link:hover {\n background: transparent;\n color: var(--color-primary-hover);\n}\n\n.ss-field {\n display: flex;\n flex-direction: column;\n gap: 2px;\n margin-bottom: var(--spacing-md);\n}\n.ss-label {\n font-size: var(--font-size-sm);\n font-weight: 600;\n color: var(--text-secondary);\n}\n.ss-hint {\n font-size: var(--font-size-sm);\n color: var(--text-secondary);\n}\n/* The History dialog's line on where its entries are kept, between the search box and the list. */\n.ss .ss-history__where {\n margin: 6px 0 8px;\n}\n.ss-input, .ss-select, .ss-textarea {\n height: 30px;\n padding: 0 var(--spacing-sm);\n border: 1px solid var(--border-color-strong);\n border-radius: var(--border-radius-sm);\n background: var(--bg-surface-raised);\n color: var(--text-primary);\n width: 100%;\n}\n.ss-textarea {\n height: auto;\n padding: var(--spacing-sm);\n resize: vertical;\n font-family: var(--font-mono);\n}\n/* A focused field is marked in the action colour, the colour of a chosen card and of Save, so that red only marks an error: in the dark\n theme the primary colour is the danger colour, and a focused field looked like an invalid one (the author's decision on 1.3.0 RC4,\n 2026-10-11). It is the border and the keyboard ring of every text field, select and textarea, whatever its class. Checkboxes and\n radios have the action ring too: as first built they kept the primary ring, and a focused checkbox still looked wrong in the dark\n theme (the author's RC5 test, 2026-10-12: \"checkboxes are still red focus\"). Buttons, rows and links keep the primary ring of\n .ss :focus-visible, by the author's decision of 2026-10-12, as a red ring on Cancel reads as discarding what the view holds. A primary\n button such as Save keeps its own action ring (.ss-btn--primary). An invalid field keeps its danger border while it has the focus. */\n.ss-input:focus, .ss-select:focus, .ss-textarea:focus {\n border-color: var(--color-action);\n}\n.ss input:not([type=\"button\"]):not([type=\"submit\"]):focus-visible,\n.ss select:focus-visible,\n.ss textarea:focus-visible {\n outline-color: var(--color-action);\n}\n.ss-radio-group {\n display: flex;\n flex-direction: column;\n gap: var(--spacing-sm);\n}\n/* Three equal cards in one row (Save As update type). */\n.ss-radio-group--row {\n flex-direction: row;\n}\n.ss-radio-group--row .ss-radio-option {\n flex: 1 1 0;\n}\n.ss-radio-option {\n border: 1px solid var(--border-color);\n border-radius: var(--border-radius-sm);\n padding: var(--spacing-sm);\n cursor: pointer;\n transition: border-color var(--transition-fast), outline-color var(--transition-fast);\n}\n.ss-radio-option.is-selected {\n border-color: var(--color-action);\n outline: 2px solid var(--color-action);\n outline-offset: -1px;\n}\n.ss-radio-option strong {\n display: block;\n}\n.ss-radio-option span {\n color: var(--text-secondary);\n font-size: var(--font-size-sm);\n}\n\n/* -------------------------------------------------------------------- */\n/* 5. Sidebar tree and search */\n/* -------------------------------------------------------------------- */\n.ss-tree__row {\n display: flex;\n align-items: center;\n gap: 4px;\n padding: 3px var(--spacing-sm);\n cursor: pointer;\n border-radius: var(--border-radius-sm);\n white-space: nowrap;\n overflow: hidden;\n}\n.ss-tree__row:hover {\n background: var(--bg-hover);\n}\n/* A table row holds its toggle and its actions side by side, so no control sits inside another. The\n toggle, which opens and closes the fields, fills the row and has the row's padding, so its arrow and\n name sit where a folder row has them and a click anywhere on the name's side of the row hits it. The\n row does not clip, so the toggle's focus outline shows in full, as a focused row's did: the toggle cuts\n the name short itself.\n The actions, Insert name and Insert table query (SELECT with all fields), sit at the right end of the row. They take\n no width of their own: hidden, they leave the whole row to the name, and shown, while the row is\n hovered or the keyboard's focus is on its toggle or on one of them, they lie over the end of the row on\n its own background (the sidebar's, made opaque, with the hover tint on a hovered row). So a long name\n is cut only while they show, and nothing in the row moves. Their left edge fades, so what they cover\n fades out instead of breaking off (the mask's colour only gives its opacity). Hidden, they stay in the\n tab order and take no click. */\n.ss-tree__row--table {\n position: relative;\n padding: 0;\n overflow: visible;\n}\n.ss-tree__toggle {\n display: flex;\n flex: 1 1 auto;\n align-items: center;\n gap: 4px;\n min-width: 0;\n padding: 3px var(--spacing-sm);\n border-radius: var(--border-radius-sm);\n overflow: hidden;\n}\n.ss-tree__actions {\n position: absolute;\n top: 0;\n right: 0;\n bottom: 0;\n display: flex;\n align-items: center;\n gap: 2px;\n padding: 0 var(--spacing-xs) 0 var(--spacing-md);\n /* The row's own corners, as the row does not clip them. */\n border-top-right-radius: inherit;\n border-bottom-right-radius: inherit;\n background: linear-gradient(var(--bg-elevated), var(--bg-elevated)), var(--bg-app);\n -webkit-mask-image: linear-gradient(to right, transparent, var(--bg-app) var(--spacing-md));\n mask-image: linear-gradient(to right, transparent, var(--bg-app) var(--spacing-md));\n opacity: 0;\n pointer-events: none;\n}\n.ss-tree__row:hover > .ss-tree__actions,\n.ss-field-item:hover > .ss-tree__actions {\n background: linear-gradient(var(--bg-hover), var(--bg-hover)), linear-gradient(var(--bg-elevated), var(--bg-elevated)), var(--bg-app);\n}\n.ss-tree__row:hover > .ss-tree__actions,\n.ss-tree__toggle:focus-visible ~ .ss-tree__actions,\n.ss-field-item:hover > .ss-tree__actions,\n.ss-field-row:focus-visible ~ .ss-tree__actions,\n.ss-tree__actions:focus-within {\n opacity: 1;\n pointer-events: auto;\n}\n/* One action: the toolbar icon-button style, scaled to 24px for the sidebar tree. */\n.ss-tree__action {\n display: inline-flex;\n align-items: center;\n justify-content: center;\n flex: 0 0 auto;\n width: 24px;\n height: 24px;\n padding: 0;\n border: none;\n border-radius: var(--border-radius-sm);\n background: transparent;\n color: var(--text-secondary);\n cursor: pointer;\n transition: background-color var(--transition-fast), color var(--transition-fast);\n}\n.ss-tree__action:hover {\n background: var(--toolbar-button-hover);\n color: var(--color-primary);\n}\n/* An action that is off, such as Join to the query while the query has no table: dimmed, with its title saying why. */\n.ss-tree__action:disabled {\n opacity: 0.4;\n cursor: default;\n}\n.ss-tree__action:disabled:hover {\n background: transparent;\n color: var(--text-secondary);\n}\n.ss-tree__caret {\n flex: 0 0 auto;\n width: 14px;\n text-align: center;\n color: var(--text-secondary);\n background: none;\n border: none;\n padding: 0;\n cursor: pointer;\n}\n.ss-tree__name {\n flex: 1 1 auto;\n overflow: hidden;\n text-overflow: ellipsis;\n}\n/* Object-kind marker: DE, SHARED, VIEW, QUERY. */\n.ss-type-badge {\n flex: 0 0 auto;\n font-size: 10px;\n font-weight: 700;\n text-transform: uppercase;\n letter-spacing: 0.02em;\n color: var(--color-primary);\n border: 1px solid currentColor;\n border-radius: 2px;\n padding: 0 3px;\n}\n/* A badge in a folder tree row: smaller type and less space around it, so the name keeps the room. */\n.ss-type-badge--compact {\n font-size: 9px;\n letter-spacing: 0;\n padding: 0 2px;\n}\n.ss-tree__children {\n padding-left: var(--spacing-lg);\n}\n.ss-tree__fields {\n padding-left: var(--spacing-xl);\n}\n/* A field holds its row and its Insert name button side by side, as a table row holds its toggle and its\n actions: the button lies over the end of the row while the field is hovered or the keyboard's focus is\n on the row or on the button, and takes no width while hidden. The hover tint is the holder's, so it\n stays while the pointer is on the button. */\n.ss-field-item {\n position: relative;\n border-radius: var(--border-radius-sm);\n}\n.ss-field-item:hover {\n background: var(--bg-hover);\n}\n.ss-field-row {\n display: flex;\n align-items: baseline;\n gap: 6px;\n padding: 2px var(--spacing-sm);\n cursor: pointer;\n border-radius: var(--border-radius-sm);\n overflow: hidden;\n}\n/* A field row is shorter than a table row: its button fits it. */\n.ss-field-item .ss-tree__action {\n width: 20px;\n height: 20px;\n}\n.ss-field-row__name {\n overflow: hidden;\n text-overflow: ellipsis;\n white-space: nowrap;\n}\n.ss-field-row__type {\n flex: 0 0 auto;\n color: var(--text-secondary);\n font-size: 11px;\n font-family: var(--font-mono);\n}\n/* The primary key's mark: the SLDS key icon, as tall as the field's text, in the warning text colour, which reads at\n 4.5:1 on the sidebar in both themes. */\n.ss-field-row__key {\n display: inline-flex;\n flex: 0 0 auto;\n align-self: center;\n color: var(--warning-text);\n}\n.ss-field-row__key .ss-icon {\n width: 12px;\n height: 12px;\n}\n.ss-tree__empty, .ss-tree__loading {\n padding: var(--spacing-md);\n color: var(--text-secondary);\n font-size: var(--font-size-sm);\n}\n/* A hint line in a dialog's list (\"Loading...\", \"Could not load: ...\"), padded as the list's empty and loading rows are. */\n.ss-list > .ss-hint {\n padding: var(--spacing-md);\n}\n\n/* -------------------------------------------------------------------- */\n/* 6. Toolbar and problems strip */\n/* -------------------------------------------------------------------- */\n/* Toolbar, sidebar tree and tab labels are controls, not text: a drag or a select-all never paints\n them. The query, the grid, the Status messages and the run summary stay selectable. */\n.ss-toolbar,\n.ss-sidebar__tree,\n.ss-tab {\n -webkit-user-select: none;\n user-select: none;\n}\n\n.ss-toolbar {\n display: flex;\n align-items: center;\n gap: var(--spacing-xs);\n height: var(--toolbar-height);\n padding: 0 var(--spacing-sm);\n background: var(--toolbar-bg);\n border-bottom: 1px solid var(--border-color);\n flex: 0 0 auto;\n flex-wrap: nowrap;\n min-width: 0;\n overflow: visible;\n}\n.ss-icon {\n width: 16px;\n height: 16px;\n flex: 0 0 auto;\n fill: currentColor;\n}\n/* Toolbar buttons: transparent by default, icon (16px) + label. Run is the one filled/primary button. */\n.ss-toolbar__button {\n display: inline-flex;\n align-items: center;\n gap: 5px;\n min-height: 32px;\n padding: var(--spacing-xs) 10px;\n border: none;\n border-radius: var(--border-radius-sm);\n background: transparent;\n color: var(--text-secondary);\n font-size: var(--font-size-sm);\n font-weight: 500;\n white-space: nowrap;\n cursor: pointer;\n transition: background-color var(--transition-fast), color var(--transition-fast);\n}\n.ss-toolbar__button:hover {\n background: var(--toolbar-button-hover);\n color: var(--color-primary);\n}\n.ss-toolbar__button.is-active,\n.ss-toolbar__button[aria-expanded=\"true\"] {\n background: var(--toolbar-button-active);\n color: var(--color-primary);\n}\n.ss-toolbar__button:disabled {\n opacity: 0.35;\n cursor: not-allowed;\n}\n/* .is-unavailable is a disabled toolbar button that stays focusable and says why it does nothing (aria-disabled), as Document while the\n query cannot be drawn. It looks as :disabled does, with no hover highlight (the author's RC2 test, 2026-10-16: the greyed-out Document\n button was not grey, as the rule was written for .ss-btn and the toolbar's buttons are .ss-toolbar__button). Its icon and label fade,\n not the button: the button's opacity would fade its focus ring too, from 4.8:1 to 1.6:1 on the light toolbar and from 3.8:1 to 1.5:1\n on the dark one, and a disabled button takes no focus, so it never showed one (the review of RC3, 2026-10-16). On a transparent\n button the two look the same. */\n.ss-toolbar__button.is-unavailable {\n cursor: not-allowed;\n}\n.ss-toolbar__button.is-unavailable > * {\n opacity: 0.35;\n}\n.ss-toolbar__button:disabled:hover,\n.ss-toolbar__button.is-unavailable:hover {\n background: transparent;\n color: var(--text-secondary);\n}\n.ss-toolbar__button--primary {\n background: var(--color-action);\n color: var(--color-action-fg);\n}\n.ss-toolbar__button--primary:hover {\n background: var(--color-action-hover);\n color: var(--color-action-fg);\n}\n/* Focus rings on primary controls use --color-action too: amber in dark mode, blue in light mode, same as their fill. */\n.ss-toolbar__button--primary:focus-visible,\n.ss-btn--primary:focus-visible {\n outline-color: var(--color-action);\n}\n.ss-toolbar__button--icon-only {\n padding: var(--spacing-xs);\n min-width: 32px;\n justify-content: center;\n}\n/* Run while a run is in flight: a spinner in place of the play icon, at full\n strength rather than the faded disabled look, so it reads as busy. */\n.ss-toolbar__button--primary.is-running:disabled,\n.ss-toolbar__button--primary.is-running:disabled:hover {\n opacity: 1;\n cursor: progress;\n background: var(--color-action);\n color: var(--color-action-fg);\n}\n/* Validate while Marketing Cloud Engagement checks the query: busy, not faded. */\n.ss-toolbar__button.is-running:disabled {\n opacity: 1;\n cursor: progress;\n}\n.ss-toolbar__button.is-running > svg {\n display: none;\n}\n.ss-toolbar__button.is-running::before {\n content: '';\n width: 14px;\n height: 14px;\n box-sizing: border-box;\n border: 2px solid currentColor;\n border-right-color: transparent;\n border-radius: 50%;\n animation: ss-spin 0.8s linear infinite;\n flex: 0 0 auto;\n}\n\n/* Monaco's hovers and suggestion details can sit outside .ss, so these rules are not scoped\n to it. Tables there inherit the hover's colour and size, even in quirks mode, and read as a\n compact list rather than bold oversized headers. */\n.monaco-hover table,\n.suggest-details table {\n border-collapse: collapse;\n margin: 4px 0;\n color: inherit;\n font-size: inherit;\n line-height: inherit;\n}\n.monaco-hover th,\n.monaco-hover td,\n.suggest-details th,\n.suggest-details td {\n padding: 1px 16px 1px 0;\n text-align: left;\n vertical-align: top;\n}\n.monaco-hover th,\n.suggest-details th {\n font-weight: 600;\n opacity: 0.7;\n border-bottom: 1px solid rgba(128, 128, 128, 0.35);\n}\n\n/* Off-site links end with the arrow mateuszdabrowski.pl uses. Inline-block keeps the link's\n underline off it. Monaco's hovers and suggestion details can sit outside .ss. */\n.monaco-hover a::after,\n.suggest-details a::after,\n.ss a[target=\"_blank\"]:not(.ss-btn):not(.ss-toolbar__brand)::after {\n content: '\\2197';\n display: inline-block;\n margin-left: 0.3em;\n font-size: 0.85em;\n opacity: 0.6;\n text-decoration: none;\n}\n\n/* The suggestion details panel and its list (Monaco's suggest widget, set up by Editor._watchSuggestDetails in\n sqlstudio.js). The widget and the panel sit inside .ss, so the tokens reach them. The script says where the panel\n is against the list with data-ss-details on both: right, left or below. */\n/* The border-box rule of the reset above reaches Monaco's own boxes. It sizes the panel by its content, so the panel\n gets Monaco's content-box back, and its two border lines no longer cut off the last 2px of its text. */\n.ss .suggest-details {\n box-sizing: content-box;\n}\n/* The tie between the focused row and the panel: the row's band (the theme's editorSuggestWidget.selectedBackground,\n the same blue as --focus-band) goes on over the panel's header strip, and the panel has no border on the side that\n faces the list. Monaco lays the panel 1px over the list's border on that side, so with no border of its own the\n panel covers it, and the row and the strip meet with no line between them. At the list's left, Monaco stops the\n panel 1px short of that border, so the panel moves 1px over it. */\n.ss .suggest-details-container[data-ss-details=\"right\"] .suggest-details {\n border-left-width: 0;\n}\n.ss .suggest-details-container[data-ss-details=\"left\"] .suggest-details {\n position: relative;\n left: 1px;\n border-right-width: 0;\n}\n.ss .suggest-details-container[data-ss-details=\"below\"] .suggest-details {\n border-top-width: 0;\n}\n/* The card: Monaco keeps the panel at the list's top, wherever the focused row is, so the rest of the panel takes a\n light tint of the band's blue, and the whole panel reads as the focused row's own. */\n.ss .suggest-details > .monaco-scrollable-element {\n background: var(--focus-card);\n}\n/* The close button sits on the header strip in every panel, so it takes the strip's text colour. The selectors are as\n long as Monaco's own, which load later and would win a tie. */\n.ss .suggest-details > .monaco-scrollable-element > .body > .header > .codicon-close,\n.ss .suggest-details > .monaco-scrollable-element > .body > .header > .codicon-close::before {\n color: var(--focus-band-fg);\n}\n/* The header strip of any other item: Monaco's own header line, with the item's detail at full opacity, or, for an\n item with none, the first line of its text, which runs under the close button to the panel's edge. */\n.ss .suggest-details:not(.no-type):not(.ss-join-details) > .monaco-scrollable-element > .body > .header {\n color: var(--focus-band-fg);\n background: var(--focus-band);\n}\n.ss .suggest-details:not(.no-type):not(.ss-join-details) > .monaco-scrollable-element > .body > .header > .type {\n opacity: 1;\n}\n.ss .suggest-details.no-type:not(.ss-join-details) > .monaco-scrollable-element > .body > .docs.markdown-docs {\n margin-right: 0;\n}\n.ss .suggest-details.no-type:not(.ss-join-details) > .monaco-scrollable-element > .body > .docs.markdown-docs > .rendered-markdown > p:first-child {\n margin: -4px -5px 0;\n padding: 4px 29px 4px 5px;\n color: var(--focus-band-fg);\n background: var(--focus-band);\n}\n/* A code block in any panel has the editor's own background, so its tokens read as they do in the editor. */\n.ss .suggest-details .rendered-markdown > div {\n margin: 4px 0;\n padding: 4px 8px;\n border-radius: var(--border-radius-sm);\n background: var(--editor-bg);\n}\n/* A recommended join (class set by the script): its label in bold on the first line, the key columns under it,\n the SQL it writes in a block, and the note last, smaller and in the secondary text colour, which keeps 4.5:1 on\n the card. The label takes the place of the panel's own header line, which would only repeat the key columns, so\n the header keeps its close button alone. The label and the key columns are the header strip, out to the panel's\n edges, the label clear of the close button. */\n.ss .suggest-details.ss-join-details .header > .type {\n display: none;\n}\n.ss .suggest-details.ss-join-details > .monaco-scrollable-element > .body > .docs.markdown-docs > .rendered-markdown > p:first-child {\n margin: -4px -5px 0;\n padding: 4px 25px 2px 5px;\n color: var(--focus-band-fg);\n background: var(--focus-band);\n}\n.ss .suggest-details.ss-join-details > .monaco-scrollable-element > .body > .docs.markdown-docs > .rendered-markdown > p:nth-child(2) {\n margin: 0 -5px 6px;\n padding: 0 5px 6px;\n color: var(--focus-band-fg);\n font-size: var(--font-size-sm);\n background: var(--focus-band);\n}\n.ss .suggest-details.ss-join-details .rendered-markdown > div {\n margin: 6px 0;\n padding: 6px 8px;\n}\n.ss .suggest-details.ss-join-details .rendered-markdown > div ~ p {\n margin: 6px 0 0;\n color: var(--text-secondary);\n font-size: var(--font-size-sm);\n line-height: 1.5;\n}\n\n/* Run summary parts that open a breakdown (UI.showHint): after a short hover, on focus, or pinned\n by a click. */\n.ss-run-summary__hint {\n text-decoration: underline dotted;\n text-underline-offset: 3px;\n cursor: help;\n}\n.ss-run-summary__hint:focus-visible {\n outline: 2px solid var(--color-action);\n outline-offset: 2px;\n}\n/* Temp DE when it links to Contact Builder: a click goes there instead of pinning the hint. It keeps the summary's own colour and\n dotted underline, as the other parts with a hover do, so it does not read as a second red link beside Delete results. */\n.ss a.ss-run-summary__link {\n cursor: pointer;\n color: inherit;\n}\n/* The run summary's card (UI.showHint), with the quick tip's look. Only the card: as first written for every .ss-hint, the rule hid the\n hint lines of the dialogs too, such as History's line on where it is kept and \"Could not load: ...\" (the author's RC12 test, 2026-10-09). */\n.ss-hint[role=\"tooltip\"] {\n display: none;\n position: absolute;\n z-index: 150;\n max-width: 380px;\n padding: 10px 12px;\n border: 1px solid var(--tooltip-border);\n border-radius: var(--border-radius-sm);\n background: var(--tooltip-bg);\n box-shadow: var(--shadow-tooltip);\n font-size: var(--font-size-xs);\n color: var(--text-secondary);\n}\n.ss .ss-hint__title {\n margin: 0 0 4px;\n font-weight: 600;\n color: var(--text-primary);\n}\n.ss .ss-hint__title:not(:first-child) {\n margin-top: 10px;\n}\n/* The run's total split between calls through the API and through WSProxy, above the two tables. */\n.ss .ss-hint__split {\n margin: 0 0 6px;\n color: var(--text-primary);\n}\n.ss .ss-hint__line {\n margin: 2px 0;\n}\n.ss-hint__table {\n border-collapse: collapse;\n width: 100%;\n}\n.ss-hint__table td {\n padding: 2px 0;\n vertical-align: top;\n}\n.ss-hint__table td + td {\n padding-left: 16px;\n text-align: right;\n white-space: nowrap;\n color: var(--text-primary);\n}\n/* The Status tab's first line during a run. */\n.ss-status-run-note {\n display: flex;\n align-items: center;\n gap: var(--spacing-sm);\n padding: var(--spacing-sm) var(--spacing-md);\n color: var(--text-secondary);\n font-size: var(--font-size-sm);\n border-bottom: 1px solid var(--border-color);\n}\n\n/* Update notice: a small outlined pill next to the theme toggle. */\n.ss-update-badge {\n margin-right: var(--spacing-sm);\n padding: 2px 10px;\n border: 1px solid var(--color-action);\n border-radius: 12px;\n background: transparent;\n color: var(--color-action);\n font-size: 12px;\n font-weight: 600;\n white-space: nowrap;\n cursor: pointer;\n}\n.ss-update-badge:hover {\n background: var(--color-action);\n color: var(--color-action-fg);\n}\n.ss .ss-update-list {\n margin: var(--spacing-sm) 0;\n padding-left: 18px;\n list-style: disc;\n}\n/* A link that looks like a button takes the button's text colour: `.ss a` gave it the link red, on the neutral grey and on the\n amber of a primary button alike, in the update dialog's \"All changes\" and \"Update guide\" (the author, 2026-10-05). A link-style\n button keeps the link colour it asks for. */\n.ss a.ss-btn {\n text-decoration: none;\n color: var(--modal-btn-neutral-fg);\n}\n.ss a.ss-btn.ss-btn--primary {\n color: var(--color-action-fg);\n}\n.ss a.ss-btn.ss-btn--link {\n color: var(--color-primary);\n}\n.ss .ss-update-list li {\n margin: 4px 0;\n}\n\n/* Spinner. One turn takes 0.8 s: SPINNER_TURN_MS in sqlstudio.js must match. */\n@keyframes ss-spin {\n to { transform: rotate(360deg); }\n}\n.ss-spinner {\n display: inline-block;\n width: 12px;\n height: 12px;\n box-sizing: border-box;\n border: 2px solid currentColor;\n border-right-color: transparent;\n border-radius: 50%;\n animation: ss-spin 0.8s linear infinite;\n flex: 0 0 auto;\n}\n@media (prefers-reduced-motion: reduce) {\n .ss-spinner,\n .ss-toolbar__button.is-running::before {\n animation: none;\n }\n}\n\n/* Theme toggle icon: Diagramforce's\n moon/sun swap, translated from df- to ss- and scoped to .ss. */\n.ss svg.ss-toolbar__icon--theme,\n.ss svg.ss-toolbar__icon--theme * {\n /* Diagramforce draws the moon and sun as wire icons; the generic `.ss svg { fill }` rule must not apply. */\n fill: none;\n stroke: currentColor;\n stroke-width: 2;\n stroke-linecap: round;\n stroke-linejoin: round;\n}\n.ss-toolbar__icon--theme {\n width: 18px;\n height: 18px;\n fill: none;\n stroke: currentColor;\n stroke-width: 2;\n stroke-linecap: round;\n stroke-linejoin: round;\n}\n.ss[data-theme=\"dark\"] .ss-icon-sun { display: none; }\n.ss[data-theme=\"dark\"] .ss-icon-moon { display: inline; }\n.ss[data-theme=\"light\"] .ss-icon-sun { display: inline; }\n.ss[data-theme=\"light\"] .ss-icon-moon { display: none; }\n/* The system icon's right half is solid, which the wire-icon rule above would clear. */\n.ss svg.ss-toolbar__icon--theme .ss-icon-system__half { fill: currentColor; }\n.ss .ss-icon-system { display: none; }\n.ss[data-theme-choice=\"system\"] .ss-icon-sun,\n.ss[data-theme-choice=\"system\"] .ss-icon-moon { display: none; }\n.ss[data-theme-choice=\"system\"] .ss-icon-system { display: inline; }\n\n/* Help menu: Diagramforce's\n ss-toolbar__menu, absolute under its button rather than the centred\n dropdown-manager system (History keeps that; see section 9). */\n.ss-toolbar__dropdown {\n position: relative;\n}\n.ss-toolbar__menu {\n display: none;\n position: absolute;\n top: 100%;\n right: 0;\n margin-top: 4px;\n min-width: 180px;\n background: var(--toolbar-bg);\n border: 1px solid var(--border-color);\n border-radius: var(--border-radius-md);\n box-shadow: var(--shadow-md);\n padding: 4px 0;\n max-height: calc(100dvh - 64px);\n overflow-y: auto;\n z-index: 200;\n}\n.ss-toolbar__dropdown--open > .ss-toolbar__menu {\n display: block;\n}\n.ss-toolbar__menu-item {\n display: block;\n width: 100%;\n padding: 6px 14px;\n border: none;\n background: transparent;\n color: var(--text-secondary);\n font-weight: 500;\n font-size: var(--font-size-sm);\n font-family: var(--font-family);\n text-align: left;\n text-decoration: none;\n cursor: pointer;\n white-space: nowrap;\n}\n.ss-toolbar__menu-item:hover {\n background: var(--toolbar-button-hover);\n color: var(--text-primary);\n}\n.ss-toolbar__menu-item--icon {\n display: flex;\n align-items: center;\n gap: 8px;\n}\n.ss-toolbar__menu-icon {\n flex-shrink: 0;\n opacity: 0.7;\n}\n.ss-toolbar__menu-separator {\n border-top: 1px solid var(--border-color);\n margin: 4px 0;\n}\n/* The Document menu (Save as's neighbour): it opens at its button's left edge, not the right, and it carries a line of text, the trust\n line that says what leaves the browser, which wraps inside the menu's width. */\n.ss-toolbar__menu--start {\n left: 0;\n right: auto;\n}\n.ss-toolbar__menu--document {\n width: 300px;\n}\n.ss-toolbar__menu-note {\n margin: 0;\n padding: 6px 14px 8px;\n color: var(--text-secondary);\n font-size: var(--font-size-xs);\n line-height: 1.45;\n white-space: normal;\n}\n\n.ss-toolbar__divider {\n width: 1px;\n height: 20px;\n background: var(--border-color);\n margin: 0 var(--spacing-xs);\n}\n.ss-toolbar__spacer {\n flex: 1 1 auto;\n}\n/* Save and Disconnect, shown only while the tab on screen has a Query Activity open. */\n.ss-toolbar__group {\n display: inline-flex;\n align-items: center;\n gap: var(--spacing-xs);\n}\n.ss-toolbar__group[hidden] {\n display: none;\n}\n/* Lint findings list, now inside the Status tab of the bottom panel (see\n .ss-status-panel below); the whole panel scrolls as one unit, so the\n list itself carries no height cap of its own. */\n/* The Status tab's Show filter: the size of the small buttons next to it. */\n.ss-select--sm {\n width: auto;\n height: 24px;\n padding-top: 0;\n padding-bottom: 0;\n font-size: var(--font-size-sm);\n}\n/* The Status tab's findings filter is as wide as the option it shows, not its longest one: \"Show: all\" takes 88 px where \"Show: errors\n and warnings\" took 189, which kept Delete results whole beside the stale mark in a 744 px header (a 1,024 px window with the\n sidebar open, the second review of 1.2.1, 2026-10-10). A browser without field-sizing keeps the longest option's width. */\n.ss-results-header .ss-select--sm {\n field-sizing: content;\n}\n.ss-problem-hidden-note {\n padding: 3px var(--spacing-md);\n border-top: 1px solid var(--border-color);\n color: var(--text-secondary);\n font-size: var(--font-size-sm);\n}\n.ss-problem {\n display: flex;\n gap: var(--spacing-sm);\n padding: 3px var(--spacing-md);\n cursor: pointer;\n border-top: 1px solid var(--border-color);\n font-size: var(--font-size-sm);\n align-items: baseline;\n flex-wrap: wrap;\n}\n.ss-problem:hover {\n background: var(--bg-hover);\n}\n.ss-problem__loc {\n color: var(--text-secondary);\n font-family: var(--font-mono);\n flex: 0 0 auto;\n}\n.ss-problem__id {\n color: var(--text-secondary);\n flex: 0 0 auto;\n}\n/* From a basis of 0: a message longer than the row wraps beside its badge, where from its own width it went whole onto a line of its\n own, as MCE013's did (the author's RC3 test of 1.3.0, 2026-10-11). Below 160 px it still goes under the badge. */\n.ss-problem__message {\n flex: 1 1 0;\n min-width: 160px;\n}\n/* One-click fix button on a lint row:\n the neutral small button style, pushed to the row's right edge. A\n \"changes results\" fix keeps the same neutral look plus a small accent\n dot; the actual \"changes results\" wording lives only in the title\n tooltip, never as visible row text. */\n.ss-problem__fix {\n flex: 0 0 auto;\n margin-left: auto;\n}\n.ss-problem__fix--changes-results::before {\n content: '';\n display: inline-block;\n width: 6px;\n height: 6px;\n margin-right: var(--spacing-xs);\n border-radius: 50%;\n background: var(--brand-amber);\n}\n.ss[data-theme=\"light\"] .ss-problem__fix--changes-results::before {\n background: var(--warning-color);\n}\n/* Beside Fix all, in the panel header: how many fixes it leaves, with the dot their buttons carry. */\n.ss-fix-review-note {\n display: inline-flex;\n align-items: center;\n color: var(--text-secondary);\n font-size: var(--font-size-sm);\n white-space: nowrap;\n}\n.ss-fix-review-note::before {\n content: '';\n display: inline-block;\n width: 6px;\n height: 6px;\n margin-right: var(--spacing-xs);\n border-radius: 50%;\n background: var(--brand-amber);\n}\n.ss[data-theme=\"light\"] .ss-fix-review-note::before {\n background: var(--warning-color);\n}\n\n/* -------------------------------------------------------------------- */\n/* 7. Editor pane and split resizer */\n/* -------------------------------------------------------------------- */\n.ss-split {\n display: flex;\n flex-direction: column;\n flex: 1 1 auto;\n min-height: 0;\n}\n/* The resizable column: the query tab strip (fixed height) above the editor pane, which fills\n whatever is left. The split resizer/applyInitialSplit set this\n element's height, not .ss-editor-pane's, so the strip's own height never eats into the ratio\n the user dragged. */\n.ss-editor-column {\n display: flex;\n flex-direction: column;\n flex: 0 0 auto;\n height: 300px;\n min-height: 120px;\n}\n.ss-editor-pane {\n position: relative;\n flex: 1 1 auto;\n min-height: 0;\n}\n.ss-editor-container {\n /* Plain wrapper only: it deliberately does not carry the \"monaco-editor\"\n class, which would tie with this rule's specificity against Monaco's\n own editor.main.css (loaded later) and lose the position:absolute. */\n position: absolute;\n inset: 0;\n}\n.ss-split-resizer {\n height: 6px;\n cursor: row-resize;\n background: var(--bg-elevated);\n border-top: 1px solid var(--border-color);\n border-bottom: 1px solid var(--border-color);\n flex: 0 0 auto;\n}\n.ss-split-resizer:hover {\n background: var(--toolbar-button-active);\n}\n\n/* Query tab strip: 4 fixed tabs, always visible with their state, styled\n after Diagramforce's css/tabs.css. The strip shares the editor's background. The tabs sit in a tinted\n tray, as Diagramforce's ungrouped tray: an inactive tab shows the tray through it, and the active tab\n is taller, takes the editor's background and outline, and runs into the editor with no line between. */\n.ss-querytabs {\n display: flex;\n align-items: center;\n height: 36px;\n padding: 0 4px;\n background: var(--editor-bg);\n flex: 0 0 auto;\n}\n.ss-querytabs__tray {\n display: flex;\n align-items: stretch;\n gap: 2px;\n flex: 1 1 auto;\n min-width: 0;\n height: 28px;\n box-sizing: border-box;\n padding-right: 3px;\n border-radius: var(--border-radius-sm);\n background: color-mix(in srgb, var(--text-muted) 9%, transparent);\n}\n.ss-querytab {\n display: flex;\n align-items: center;\n gap: 6px;\n flex: 1 1 0;\n min-width: 0;\n height: 28px;\n box-sizing: border-box;\n padding: 4px 10px;\n border: 1px solid transparent;\n border-bottom: none;\n border-radius: var(--border-radius-sm) var(--border-radius-sm) 0 0;\n background: transparent;\n color: var(--text-muted);\n font-size: var(--font-size-sm);\n font-weight: 500;\n white-space: nowrap;\n cursor: pointer;\n position: relative;\n overflow: hidden;\n /* A narrow tab drops the run time first, then the row count, and keeps its icon and name (below). */\n container-type: inline-size;\n transition: background var(--transition-fast), color var(--transition-fast);\n}\n.ss-querytab:hover {\n background: color-mix(in srgb, var(--text-muted) 14%, transparent);\n color: var(--text-secondary);\n}\n.ss-querytab.is-active {\n align-self: flex-start;\n height: 32px;\n background: var(--editor-bg);\n color: var(--text-primary);\n border-color: var(--border-color);\n font-weight: 600;\n box-shadow: inset 0 2.5px 0 color-mix(in srgb, var(--text-muted) 30%, transparent);\n z-index: 1;\n}\n.ss-querytab.is-active:hover {\n background: color-mix(in srgb, var(--text-primary) 7%, var(--editor-bg));\n}\n/* The state icon at the left edge: a query glyph while idle, a spinner while the run is in flight,\n a check once done, an error mark after a failure. */\n.ss-querytab__icon {\n display: inline-flex;\n align-items: center;\n justify-content: center;\n width: 12px;\n height: 12px;\n flex: 0 0 auto;\n}\n.ss-querytab__icon .ss-icon {\n width: 12px;\n height: 12px;\n}\n.ss-querytab__icon--idle {\n opacity: 0.6;\n}\n.ss-querytab__icon--done {\n color: var(--brand-green);\n}\n.ss-querytab__icon--failed {\n color: var(--color-danger);\n}\n.ss-querytab__label {\n flex: 0 1 auto;\n min-width: 3em;\n overflow: hidden;\n text-overflow: ellipsis;\n text-align: left;\n}\n/* An idle, never-run tab is muted: its glyph and label fade further. */\n.ss-querytab.is-empty:not(.is-active) .ss-querytab__label {\n opacity: 0.75;\n}\n.ss-querytab__time {\n flex: 0 0 auto;\n font-weight: 500;\n color: var(--text-muted);\n font-variant-numeric: tabular-nums;\n}\n/* Unsaved changes to the tab's Query Activity: grey, apart from the orange dot of an unseen finish. */\n.ss-querytab__unsaved {\n width: 6px;\n height: 6px;\n border-radius: 50%;\n background: var(--text-muted);\n flex: 0 0 auto;\n}\n.ss-querytab__meta {\n margin-left: auto;\n padding-left: 8px;\n flex: 0 1 auto;\n min-width: 0;\n overflow: hidden;\n text-overflow: ellipsis;\n font-weight: 500;\n color: var(--text-muted);\n font-variant-numeric: tabular-nums;\n}\n.ss-querytab__meta--failed {\n color: var(--danger-text);\n}\n@container (max-width: 300px) {\n .ss-querytab__time {\n display: none;\n }\n}\n@container (max-width: 190px) {\n .ss-querytab__meta {\n display: none;\n }\n}\n/* A tab that finished or failed while another one was on screen, until it is opened. */\n.ss-querytab__dot {\n width: 6px;\n height: 6px;\n border-radius: 50%;\n background: var(--color-action);\n flex: 0 0 auto;\n}\n.ss-editor-fallback {\n width: 100%;\n height: 100%;\n border: none;\n resize: none;\n padding: var(--spacing-md);\n font-family: var(--font-mono);\n background: var(--editor-bg);\n color: var(--editor-fg);\n}\n.ss-banner {\n display: flex;\n align-items: center;\n gap: var(--spacing-sm);\n padding: 6px var(--spacing-md);\n background: var(--tint-warning);\n color: var(--warning-text);\n border-bottom: 1px solid var(--border-color);\n font-size: var(--font-size-sm);\n flex: 0 0 auto;\n}\n.ss-banner--error {\n background: var(--tint-danger);\n color: var(--danger-text);\n}\n.ss-banner__close {\n margin-left: auto;\n border: none;\n background: none;\n cursor: pointer;\n color: inherit;\n}\n\n/* -------------------------------------------------------------------- */\n/* 8. Results grid, tabs, inspector */\n/* -------------------------------------------------------------------- */\n.ss-results-pane {\n display: flex;\n flex-direction: column;\n flex: 1 1 auto;\n min-height: 120px;\n}\n/* Bottom panel header: the Status/Results tabs on the left, the run\n summary next to them (shown on either tab), then the page filter and\n Export CSV, shown only while Results is active (UI.setActiveTab). */\n.ss-results-header {\n display: flex;\n align-items: center;\n gap: var(--spacing-sm);\n height: var(--panel-header-height);\n padding: 0 var(--spacing-md);\n border-bottom: 1px solid var(--border-color);\n background: var(--bg-elevated);\n flex: 0 0 auto;\n /* Lets the cost beside Export CSV shorten when the header has little room (.ss-export-cost, below). */\n container-type: inline-size;\n}\n.ss-tabs {\n display: flex;\n align-items: center;\n gap: 2px;\n flex: 0 0 auto;\n}\n.ss-tab {\n display: inline-flex;\n align-items: center;\n gap: 6px;\n height: 26px;\n padding: 0 var(--spacing-sm);\n border: none;\n border-radius: var(--border-radius-sm);\n background: transparent;\n color: var(--text-secondary);\n font-size: var(--font-size-sm);\n font-weight: 600;\n cursor: pointer;\n}\n.ss-tab:hover {\n background: var(--toolbar-button-hover);\n}\n.ss-tab[aria-selected=\"true\"] {\n background: var(--toolbar-button-active);\n color: var(--color-primary);\n}\n.ss-tab__counts {\n display: inline-flex;\n align-items: center;\n gap: 6px;\n}\n.ss-tab__count {\n display: inline-flex;\n align-items: center;\n gap: 3px;\n font-size: 11px;\n font-weight: 600;\n}\n.ss-tab__count::before {\n content: '';\n display: inline-block;\n width: 6px;\n height: 6px;\n border-radius: 50%;\n background: currentColor;\n}\n.ss-tab__count--error {\n color: var(--danger-text);\n}\n.ss-tab__count--warning {\n color: var(--warning-text);\n}\n.ss-tab__count--info {\n color: var(--info-text);\n}\n.ss-run-summary {\n display: flex;\n align-items: center;\n gap: var(--spacing-xs);\n font-size: var(--font-size-sm);\n color: var(--text-secondary);\n white-space: nowrap;\n overflow: hidden;\n text-overflow: ellipsis;\n}\n/* The link takes no padding of its own: the summary's gap already spaces it, and since the WSProxy calls got their own\n number (\"4 API calls · 2 WSProxy\") the summary is 7 px too long for a 1,280 px window with the sidebar open, which\n the padding of a link button cut off. */\n.ss-run-summary__delete {\n flex: 0 0 auto;\n padding-left: 0;\n padding-right: 0;\n}\n/* \"Edited since this run\" (UI.syncStaleMark): the query in the editor is no longer the one whose rows are on screen. It is the header's own\n item, before the run summary, and never shrinks: inside the summary it was cut off with it, as in a 1,024 px window with the sidebar\n open, where the page filter and Export CSV leave the summary 139 px. The summary opens with the dot that parts the two, so a header too\n narrow for the summary (640 px, below) keeps the mark with no dot after it. The gap matches the summary's own. */\n.ss-stale-mark {\n flex: 0 0 auto;\n color: var(--text-stale);\n font-size: var(--font-size-sm);\n white-space: nowrap;\n cursor: default;\n}\n.ss-stale-mark[hidden] {\n display: none;\n}\n.ss-stale-mark:focus-visible {\n outline: 2px solid var(--color-action);\n outline-offset: 2px;\n}\n.ss-stale-mark:not([hidden]) + .ss-run-summary {\n margin-left: calc(var(--spacing-xs) - var(--spacing-sm));\n}\n.ss-stale-mark:not([hidden]) + .ss-run-summary::before {\n content: '\\00B7';\n}\n/* The page filter keeps its 180 px: in a tight header the run summary, which ends in an ellipsis, gives up room first,\n as the cost beside Export CSV (below) takes some. */\n.ss-results-filter {\n flex: 0 0 auto;\n}\n/* Beside Export CSV: what a click costs, said before the click (UI._syncExportCost). It never shrinks or wraps, so the\n button stays on screen and the run summary gives up its room first. A header with little room shows the short form,\n and with less still, nothing. The full text then stays for assistive technology (clipped out of sight, not removed)\n and in the button's title. The widths are the header's own (its container), as the sidebar takes room from it. */\n.ss-export-cost {\n position: relative;\n flex: 0 0 auto;\n color: var(--text-muted);\n font-size: var(--font-size-sm);\n white-space: nowrap;\n}\n.ss-export-cost__short {\n display: none;\n}\n/* In a header of 1,100 px or less, as at 1,280 px with the sidebar open, the run summary's row count, which the Results tab\n shows too, and Temp DE, a convenience, give way, so the two call counts and Delete results stay whole. */\n@container (max-width: 1100px) {\n .ss-run-summary__rows,\n .ss-run-summary__temp {\n display: none;\n }\n}\n/* While the stale mark shows, the same two give way from 1,240 px, as the mark takes the room that kept Delete results whole at the\n header of a 1,440 px window (1,160 px with the sidebar open). In a header of 1,040 px or less the mark drops \"since this run\" and\n keeps \"· Edited\", with the tip and the label unchanged, which keeps Delete results whole at the header of a 1,280 px window. */\n@container (max-width: 1240px) {\n .ss-run-summary.is-stale .ss-run-summary__rows,\n .ss-run-summary.is-stale .ss-run-summary__temp {\n display: none;\n }\n}\n@container (max-width: 1040px) {\n .ss-stale-mark__more {\n display: none;\n }\n}\n@container (max-width: 640px) {\n .ss-export-cost__full {\n position: absolute;\n width: 1px;\n height: 1px;\n overflow: hidden;\n clip: rect(0 0 0 0);\n }\n .ss-export-cost__short {\n display: inline;\n }\n}\n@container (max-width: 540px) {\n .ss-export-cost {\n position: absolute;\n width: 1px;\n height: 1px;\n overflow: hidden;\n clip: rect(0 0 0 0);\n }\n}\n/* Export CSV shows an export's progress itself (UI._syncExportButton): a spinner in place of the download icon, and\n \"Exporting 1,200 of 2,715…\" in place of the label. The label is two layers in one grid cell, the text and a hidden copy of\n its widest form (.ss-export-btn__sizer), so the button is as wide as the widest text and the header does not move on each\n update. Tabular numbers make \"1,200\" as wide as \"2,715\". The button is disabled meanwhile, and keeps full strength as\n Run does while it runs, so it reads as busy rather than as unavailable. */\n.ss-export-btn__label {\n display: inline-grid;\n font-variant-numeric: tabular-nums;\n}\n.ss-export-btn__label > * {\n grid-area: 1 / 1;\n}\n.ss-export-btn__sizer {\n visibility: hidden;\n}\n.ss-export-btn.is-exporting > svg {\n display: none;\n}\n.ss-export-btn.is-exporting:disabled,\n.ss-export-btn.is-exporting:disabled:hover {\n opacity: 1;\n cursor: progress;\n background: var(--modal-btn-neutral-bg);\n}\n/* Save to DE, beside Export CSV (UI._syncSaveButton). The header has room for its label only when it is wider than 1,280 px, which\n is a window of about 1,560 px with the sidebar open. Below that it is the icon alone, 24 px wide, which keeps the run summary's\n Delete results, the cost and Export CSV whole at 1,280 px with the sidebar open (the label stays for assistive technology, clipped\n out of sight, and in the title). A header of 640 px or less has no room for the run summary, which was already cut to nothing,\n so it goes, with the spacer that pushed the controls right, and the button and Export CSV fit as before. */\n.ss-save-btn {\n position: relative;\n flex: 0 0 auto;\n}\n@container (max-width: 1280px) {\n .ss-save-btn {\n width: 24px;\n padding: 0;\n }\n .ss-save-btn__label {\n position: absolute;\n width: 1px;\n height: 1px;\n overflow: hidden;\n clip: rect(0 0 0 0);\n }\n}\n@container (max-width: 640px) {\n .ss-results-header .ss-run-summary,\n .ss-results-header .ss-toolbar__spacer {\n display: none;\n }\n}\n/* Where an export's progress is announced to a screen reader (aria-live), out of sight. */\n.ss-export-status {\n position: absolute;\n width: 1px;\n height: 1px;\n overflow: hidden;\n clip: rect(0 0 0 0);\n white-space: nowrap;\n}\n/* Status tab content: the lint findings list (.ss-problem, above), then\n the error panel and the notice lines below it. The whole panel scrolls\n as one unit; .ss-empty-state (shared with the grid) covers the \"no\n problems\" case. */\n.ss-status-panel {\n flex: 1 1 auto;\n min-height: 0;\n overflow-y: auto;\n}\n/* An MCE or validation error (red left border, the message, a Details\n disclosure with the raw response); lives in Status, never in place of\n the grid. */\n.ss-error-panel {\n margin: var(--spacing-md);\n padding: var(--spacing-md);\n border-left: 3px solid var(--color-danger);\n background: var(--tint-danger);\n border-radius: 0 var(--border-radius-sm) var(--border-radius-sm) 0;\n}\n.ss-error-panel__message {\n white-space: pre-wrap;\n color: var(--text-primary);\n}\n/* An error about SQL the editor no longer holds (UI._setStaleClasses): muted on the soft hatch of stale rows, its border no longer red,\n so it stays readable while the query is fixed and does not read as the answer to the query on screen (the author's choice for 1.3.0,\n 2026-10-11). */\n.ss-error-panel.is-stale {\n border-left-color: var(--border-color-strong);\n background-color: transparent;\n background-image: repeating-linear-gradient(-45deg, var(--stale-hatch) 0, var(--stale-hatch) 6px, transparent 6px, transparent 12px);\n}\n.ss-error-panel.is-stale .ss-error-panel__message,\n.ss-error-panel.is-stale .ss-error-panel__hint {\n color: var(--text-stale);\n}\n.ss-error-panel__hint {\n margin-top: var(--spacing-xs);\n white-space: pre-wrap;\n color: var(--text-secondary);\n}\n.ss-error-panel details {\n margin-top: var(--spacing-sm);\n}\n.ss-error-panel summary {\n cursor: pointer;\n color: var(--text-secondary);\n font-size: var(--font-size-sm);\n}\n.ss-error-panel pre {\n margin-top: var(--spacing-xs);\n padding: var(--spacing-sm);\n background: var(--bg-elevated);\n border: 1px solid var(--border-color);\n border-radius: var(--border-radius-sm);\n font-family: var(--font-mono);\n font-size: var(--font-size-xs);\n white-space: pre-wrap;\n word-break: break-word;\n max-height: 30vh;\n overflow-y: auto;\n}\n/* Each outcome notice (0 rows returned, sort rejected, setup results) is one muted line, with\n nothing to dismiss: the next run or Validate in the tab replaces it. */\n.ss-notice {\n margin: var(--spacing-xs) var(--spacing-md) 0;\n padding: 2px 0 2px var(--spacing-sm);\n border-left: 2px solid var(--border-color-strong);\n color: var(--text-secondary);\n font-size: var(--font-size-sm);\n}\n.ss-notice:last-child {\n margin-bottom: var(--spacing-sm);\n}\n/* Results tab content: the grid and pager, hidden while Status is\n active. */\n.ss-results-panel {\n display: flex;\n flex-direction: column;\n flex: 1 1 auto;\n min-height: 0;\n}\n.ss-grid-scroll {\n flex: 1 1 auto;\n min-width: 0;\n overflow: auto;\n position: relative;\n}\n.ss-grid {\n border-collapse: collapse;\n table-layout: fixed;\n font-size: var(--font-size-sm);\n}\n.ss-grid thead th {\n position: sticky;\n top: 0;\n z-index: 2;\n /* The same tint as before, laid over the app background so the sticky header is opaque:\n --bg-elevated alone is 8% grey, and scrolled rows showed through it. */\n background: linear-gradient(var(--bg-elevated), var(--bg-elevated)), var(--bg-app);\n border-bottom: 1px solid var(--border-color-strong);\n border-right: 1px solid var(--border-color);\n padding: 0;\n text-align: left;\n}\n.ss-grid th button.ss-grid__sort {\n width: 100%;\n display: flex;\n align-items: center;\n gap: 4px;\n padding: 4px var(--spacing-sm);\n border: none;\n background: transparent;\n cursor: pointer;\n font-weight: 600;\n overflow: hidden;\n white-space: nowrap;\n text-overflow: ellipsis;\n}\n/* Active sort column gets the --color-action accent. */\n.ss-grid__sort.is-sorted {\n color: var(--color-action);\n}\n.ss-grid__resize-handle {\n position: absolute;\n right: 0;\n top: 0;\n bottom: 0;\n width: 6px;\n cursor: col-resize;\n}\n.ss-grid td {\n padding: 3px var(--spacing-sm);\n border-bottom: 1px solid var(--border-color);\n border-right: 1px solid var(--border-color);\n white-space: nowrap;\n overflow: hidden;\n text-overflow: ellipsis;\n max-width: 480px;\n cursor: default;\n}\n.ss-grid tbody tr:hover td {\n background: var(--bg-hover);\n}\n/* Rows of a query the editor no longer holds (UI.syncStaleMark): the body text and the column headers go muted, on a soft hatch. The\n colour, not opacity, as --text-stale keeps 4.5:1 on both themes, on a hovered row too. The NULL and (empty) cells, which are secondary\n text, take it too. As first built only the body text went muted, the colour (empty) cells already have, and a result of empty cells\n looked unchanged (the author's RC1 test of 1.2.1, 2026-10-10). The hatch is the table body's own background, one pattern across its\n cells. A hovered row covers it with an opaque hover colour, as the light theme's hover tint over a stripe read at 4.3:1. */\n.ss-grid-scroll.is-stale .ss-grid td,\n.ss-grid-scroll.is-stale .ss-grid th button.ss-grid__sort {\n color: var(--text-stale);\n}\n.ss-grid-scroll.is-stale .ss-grid tbody {\n background-image: repeating-linear-gradient(-45deg, var(--stale-hatch) 0, var(--stale-hatch) 6px, transparent 6px, transparent 12px);\n}\n.ss-grid-scroll.is-stale .ss-grid tbody tr:hover td {\n background: linear-gradient(var(--bg-hover), var(--bg-hover)), var(--bg-app);\n}\n.ss-grid__cell--null {\n color: var(--text-secondary);\n font-style: italic;\n}\n.ss-grid__cell--empty {\n color: var(--text-secondary);\n font-style: italic;\n}\n.ss-grid__spacer-row td {\n border: none;\n padding: 0;\n}\n/* Cell popover: replaces the old\n inspector pane. Positioned absolutely within .ss (see\n UI.openCellPopover), closes on Escape or a click outside. */\n.ss-cell-popover {\n position: absolute;\n z-index: 120;\n width: 280px;\n max-width: 90vw;\n background: var(--bg-surface);\n border: 1px solid var(--border-color);\n border-radius: var(--border-radius-md);\n box-shadow: var(--shadow-dropdown);\n padding: var(--spacing-sm);\n}\n.ss-cell-popover__value {\n white-space: pre-wrap;\n word-break: break-word;\n font-family: var(--font-mono);\n background: var(--bg-elevated);\n border: 1px solid var(--border-color);\n border-radius: var(--border-radius-sm);\n padding: var(--spacing-sm);\n margin-bottom: var(--spacing-sm);\n max-height: 40vh;\n overflow-y: auto;\n}\n/* Pager, one row under the grid: page\n size select on the left, prev/range/next centred, \"Page [n] of N\" on the\n right. No first/last or Go buttons. */\n.ss-pager {\n display: flex;\n align-items: center;\n justify-content: space-between;\n gap: var(--spacing-sm);\n padding: var(--spacing-sm) var(--spacing-md);\n border-top: 1px solid var(--border-color);\n flex: 0 0 auto;\n font-size: var(--font-size-sm);\n}\n.ss-pager__left,\n.ss-pager__center,\n.ss-pager__right {\n display: flex;\n align-items: center;\n gap: var(--spacing-sm);\n flex: 1 1 0;\n}\n.ss-pager__center {\n justify-content: center;\n}\n.ss-pager__right {\n justify-content: flex-end;\n}\n.ss-pager__range {\n white-space: nowrap;\n color: var(--text-secondary);\n}\n.ss-pager__page-input {\n width: 56px;\n text-align: center;\n}\n.ss-pager__page-input:focus {\n border-color: var(--color-action);\n}\n.ss-pager__page-of {\n color: var(--text-secondary);\n white-space: nowrap;\n}\n.ss-message-item {\n padding: var(--spacing-sm);\n border: 1px solid var(--border-color);\n border-radius: var(--border-radius-sm);\n margin-bottom: var(--spacing-sm);\n}\n.ss-message-item--error {\n border-color: var(--color-danger);\n background: var(--tint-danger);\n}\n.ss-message-item--warning {\n border-color: var(--warning-color);\n background: var(--tint-warning);\n}\n.ss-message-item--info {\n background: var(--bg-elevated);\n}\n.ss-empty-state {\n padding: var(--spacing-xl);\n text-align: center;\n color: var(--text-secondary);\n}\n.ss-empty-state--running {\n display: flex;\n flex-direction: column;\n align-items: center;\n gap: var(--spacing-sm);\n}\n.ss-empty-state--running p {\n margin: 0;\n}\n.ss-empty-state__title {\n color: var(--text-primary);\n font-weight: 500;\n}\n/* A finished run whose rows did not load: what happened, the error's own text, where the rows are, and the button that\n reads them again. */\n.ss-empty-state--unread {\n display: flex;\n flex-direction: column;\n align-items: center;\n gap: var(--spacing-sm);\n}\n.ss-empty-state--unread p {\n margin: 0;\n max-width: 70ch;\n}\n.ss-unread-page__error {\n color: var(--text-primary);\n white-space: pre-wrap;\n}\n.ss-unread-page__detail {\n font-size: var(--font-size-sm);\n}\n.ss-unread-page__button {\n display: inline-flex;\n align-items: center;\n gap: var(--spacing-xs);\n}\n\n/* -------------------------------------------------------------------- */\n/* 9. Dialogs, dropdown managers and modal */\n/* -------------------------------------------------------------------- */\n/* Above the anchored panels (.ss-dropdown-mount, 101): a dialog opened from one, such as History's Clear all or Open's question on\n unsaved changes, drew under the panel where the two met (2026-10-09). */\n.ss-modal-overlay {\n position: absolute;\n inset: 0;\n background: rgba(0, 0, 0, 0.45);\n display: flex;\n align-items: center;\n justify-content: center;\n z-index: 110;\n padding: var(--spacing-lg);\n}\n.ss-modal {\n background: var(--bg-surface);\n border: 1px solid var(--border-color);\n border-radius: var(--border-radius-md);\n box-shadow: var(--shadow-md);\n width: 100%;\n max-width: 480px;\n max-height: 100%;\n display: flex;\n flex-direction: column;\n overflow: hidden;\n}\n.ss-modal--wide {\n max-width: 640px;\n}\n.ss-modal__header {\n display: flex;\n align-items: center;\n gap: var(--spacing-sm);\n padding: var(--spacing-md) var(--spacing-lg);\n border-bottom: 1px solid var(--border-color);\n}\n.ss-modal__title {\n font-size: var(--font-size-md);\n font-weight: 700;\n flex: 1 1 auto;\n}\n.ss-modal__close {\n border: none;\n background: none;\n cursor: pointer;\n font-size: 16px;\n color: var(--text-secondary);\n padding: 4px;\n border-radius: var(--border-radius-sm);\n}\n.ss-modal__close:hover {\n background: var(--bg-hover);\n color: var(--color-primary);\n}\n.ss-modal__body {\n padding: var(--spacing-sm) var(--spacing-lg);\n overflow-y: auto;\n flex: 1 1 auto;\n}\n.ss-modal__status {\n margin: var(--spacing-sm) 0 0;\n font-size: var(--font-size-sm);\n color: var(--text-secondary);\n}\n.ss-modal__status:empty {\n display: none;\n}\n.ss-modal__footer {\n display: flex;\n justify-content: flex-end;\n gap: var(--spacing-sm);\n padding: var(--spacing-md) var(--spacing-lg);\n border-top: 1px solid var(--border-color);\n flex: 0 0 auto;\n}\n\n/* Dropdown managers (History, Runs, Settings, Help): anchored under their\n toolbar button, not centred. Plain anchored, with --shadow-dropdown. */\n.ss-dropdown-scrim {\n position: absolute;\n inset: 0;\n z-index: 100;\n background: transparent;\n}\n.ss-dropdown-mount {\n position: absolute;\n z-index: 101;\n}\n/* --toolbar-bg, not --bg-surface: History\n keeps its own toolbar button and anchored panel, but uses the same menu\n surface as the Help ss-toolbar__menu. */\n.ss-dropdown-panel {\n background: var(--toolbar-bg);\n border: 1px solid var(--border-color);\n border-radius: var(--border-radius-md);\n box-shadow: var(--shadow-dropdown);\n width: 300px;\n max-height: 70vh;\n display: flex;\n flex-direction: column;\n overflow: hidden;\n}\n.ss-dropdown-panel--wide {\n width: 420px;\n}\n/* Open: the width its rows had as a centred dialog (.ss-modal--wide). UI.openDropdown caps it at the app's width less 16px. */\n.ss-dropdown-panel--xwide {\n width: 640px;\n}\n/* About dialog: Diagramforce's df-about layout,\n * translated to ss-. .ss-about is the flex column all its children sit in;\n * .ss-about__separator is the thin full-width rule (there are exactly two:\n * before the links row and before the licence line), and .ss-about__meta /\n * __signed-in / __license are plain centred text rows, not bordered\n * individually, so the muted signed-in line can sit directly under the\n * links row with no rule between them. */\n.ss-about {\n display: flex;\n flex-direction: column;\n align-items: center;\n text-align: center;\n gap: var(--spacing-md);\n padding: var(--spacing-sm) 0;\n}\n.ss .ss-about__name {\n margin: 0;\n font-size: 20px;\n font-weight: 700;\n color: var(--text-primary);\n}\n.ss .ss-about__tagline {\n margin: 2px 0 0;\n font-size: var(--font-size-sm);\n color: var(--text-secondary);\n}\n.ss .ss-about__info {\n margin: 0;\n max-width: 440px;\n font-size: 13px;\n line-height: var(--line-height);\n color: var(--text-secondary);\n}\n/* Label and value rows, left-aligned in a quiet panel. */\n.ss-about__details {\n display: grid;\n grid-template-columns: max-content 1fr;\n gap: 6px var(--spacing-md);\n width: 100%;\n margin: 0;\n padding: 10px 14px;\n box-sizing: border-box;\n border: 1px solid var(--border-color);\n border-radius: var(--border-radius-sm);\n background: var(--bg-elevated);\n text-align: left;\n font-size: var(--font-size-xs);\n}\n.ss-about__details dt {\n color: var(--text-muted);\n}\n.ss-about__details dd {\n margin: 0;\n color: var(--text-secondary);\n overflow-wrap: anywhere;\n}\n.ss-about__links {\n display: flex;\n flex-wrap: wrap;\n justify-content: center;\n gap: var(--spacing-sm);\n}\n.ss-about__pill {\n padding: 4px 12px;\n border: 1px solid var(--border-color);\n border-radius: 14px;\n color: var(--color-primary);\n font-size: var(--font-size-xs);\n text-decoration: none;\n}\n.ss-about__pill:hover {\n background: var(--bg-hover);\n}\n.ss-about__link {\n color: var(--color-primary);\n text-decoration: none;\n}\n.ss-about__link:hover {\n text-decoration: underline;\n}\n.ss .ss-about__credits {\n margin: 0;\n font-size: var(--font-size-xs);\n color: var(--text-muted);\n text-align: center;\n}\n.ss .ss-about__credits p {\n margin: 0 0 2px;\n}\n.ss-about__license {\n width: 100%;\n padding-top: var(--spacing-md);\n border-top: 1px solid var(--border-color);\n font-size: var(--font-size-xs);\n color: var(--text-muted);\n}\n.ss .ss-about__license p {\n margin: 0;\n}\n.ss .ss-about__license p + p {\n margin-top: 2px;\n}\n/* Write SQL with Claude (Help menu): one section, as Diagramforce's Diagram with AI has. The link and the download button share one\n colour, the primary hue a step toward the text colour (as the lists' links have). It reads at 8.48:1 on the dark theme's dialog,\n where the primary colour reads at 4.36:1, and at 9.28:1 in the light theme. The note's muted text reads at 6.93:1 and 5.05:1, the\n failure line's at 7.59:1 and 6.19:1. The link is underlined at rest, as it has to read as a link and not as the body text above\n it. The button is a wire button in that colour, so the dialog's one filled button is Close. */\n.ss-skill {\n --skill-link: color-mix(in srgb, var(--color-primary) 50%, var(--text-primary));\n display: flex;\n flex-direction: column;\n gap: var(--spacing-sm);\n padding: var(--spacing-xs) 0;\n}\n.ss .ss-skill__title {\n font-size: var(--font-size-md);\n font-weight: 600;\n color: var(--text-primary);\n}\n.ss .ss-skill__blurb,\n.ss .ss-skill__steps {\n font-size: 13px;\n line-height: var(--line-height);\n color: var(--text-secondary);\n}\n.ss .ss-skill code {\n padding: 0 4px;\n border-radius: 4px;\n background: var(--bg-elevated);\n font-family: var(--font-mono);\n font-size: var(--font-size-sm);\n color: var(--text-primary);\n}\n/* Advice, never a warning: the store knows only this browser's downloads. */\n.ss .ss-skill__note {\n font-size: var(--font-size-sm);\n line-height: var(--line-height);\n color: var(--text-muted);\n}\n.ss .ss-skill__status {\n margin-top: 0;\n color: var(--danger-text);\n}\n.ss-skill__actions {\n display: flex;\n flex-wrap: wrap;\n align-items: center;\n justify-content: space-between;\n gap: var(--spacing-sm) var(--spacing-md);\n margin-top: var(--spacing-xs);\n}\n.ss .ss-skill__view {\n font-size: 13px;\n color: var(--skill-link);\n text-decoration: underline;\n}\n.ss .ss-skill__view:hover {\n text-decoration: none;\n}\n/* The width of the idle label and of the busy one, so the button does not move when it turns to \"Downloading…\". */\n.ss .ss-skill__download {\n min-width: 132px;\n border-color: var(--skill-link);\n background: transparent;\n color: var(--skill-link);\n}\n.ss .ss-skill__download:hover {\n background: var(--bg-hover);\n}\n.ss .ss-skill__download.is-busy {\n cursor: progress;\n background: transparent;\n}\n/* A list of rows in a dialog: the Open and History lists, and the list pickers below. The kind badge, a link in the list (Retry on a\n failed load) and the focus ring keep their hue, a step toward the text colour. In the dark theme the primary colour read at 3.80:1 on\n the raised surface behind Open's rows, 2.79:1 on a hovered row there, and 3.08:1 on the tip's surface behind a picker's rows. The mix\n reads at 7.41:1 and 6.01:1 there, 5.03:1 or more on a hovered row, and 8.18:1 or more in the light theme. A hovered link keeps the\n colour and is underlined. */\n.ss-list {\n position: relative;\n border: 1px solid var(--border-color);\n border-radius: var(--border-radius-sm);\n max-height: 320px;\n overflow-y: auto;\n}\n.ss-list-row {\n display: flex;\n align-items: center;\n gap: var(--spacing-md);\n padding: var(--spacing-sm) var(--spacing-md);\n border-bottom: 1px solid var(--border-color);\n cursor: pointer;\n}\n.ss-list-row:last-child {\n border-bottom: none;\n}\n.ss-list-row:hover {\n background: var(--bg-hover);\n}\n/* The highlighted row of any list (.is-active, under the arrow keys of Open's and History's search and the pickers') is the picker's\n highlight: a light veil of the text colour (6%) and a 3px bar in the action colour, on the list's own surface (the author's RC5 test,\n 2026-10-12). The primary colour is red in the dark theme, which read as an error. The pointer moving over a row highlights it, so a\n hovered row has this look too. */\n.ss-list .ss-list-row.is-active {\n background: color-mix(in srgb, var(--text-primary) 6%, transparent);\n box-shadow: inset 3px 0 0 var(--color-action);\n}\n/* \"Searching...\" under Open's list while the Backend answers, or why the latest request failed: a quiet line in the muted text colour\n (.ss-hint), outside the list, which scrolls. Inside it, as first built, it was the list's last line, out of view below rows that\n filled the list (the review of 2026-10-12). It keeps its one line when it is empty, so the panel keeps its height as it comes and\n goes. It stays empty while a pick loads, as the box over the list says Opening (.ss-list-opening, below), and says why a pick failed\n to open. */\n.ss-list-status {\n min-height: calc(var(--font-size-sm) * var(--line-height));\n margin: var(--spacing-xs) 0 0;\n padding: 0 var(--spacing-md);\n line-height: var(--line-height);\n}\n/* Open's list in a frame, which the box of Opening \"name\"... lies over (.ss-list-opening). The box is beside the list, not in it, as the list\n scrolls and is greyed out: it stays in the middle of the frame and keeps its own colours. */\n.ss-list-frame {\n position: relative;\n}\n/* Open while it loads a pick (the author's RC8 test, 2026-10-12: \"grey out open results with a spinner and line in it's center so that\n it's clear it's time to wait for the load\"). RC8 had only a line under the list that said Opening, and the list went on answering:\n a click was still a click, and the arrows still moved the highlight.\n - The list is greyed out: its rows stay where they are, at 40% of their strength, so the list plainly looks off in both themes, and\n the highlighted row keeps its bar. It takes no pointer events, which the rows inherit: no hover veil and no click. The wheel does\n not scroll it either.\n - The frame keeps a height that holds the box, as the list can be hidden while it holds nothing.\n - The cursor says to wait, as the list under it takes no pointer. */\n.ss-list-frame.is-opening {\n min-height: 56px;\n cursor: progress;\n}\n.ss-list-frame.is-opening > .ss-list {\n opacity: 0.4;\n pointer-events: none;\n}\n/* The status region over the list. It is empty, and invisible, until a pick is loading, and then it holds the box in its middle. It\n takes no pointer, so the frame has the cursor. */\n.ss-list-opening {\n position: absolute;\n inset: 0;\n z-index: 1;\n display: flex;\n align-items: center;\n justify-content: center;\n box-sizing: border-box;\n padding: var(--spacing-sm);\n pointer-events: none;\n}\n/* The box of Opening \"name\"... after a spinner. It has the quick tip's surface (.ss-tip): the surface, border, shadow and text colour,\n a step off the greyed-out rows under it, so its text reads at 4.5:1 or more in both themes whatever row lies under it (the tip's text\n colour on its surface is 11:1 or more in the dark theme and 16:1 or more in the light one). A long name wraps inside the box. */\n.ss-list-opening__box {\n display: flex;\n align-items: center;\n gap: var(--spacing-sm);\n max-width: 100%;\n box-sizing: border-box;\n padding: var(--spacing-sm) var(--spacing-md);\n border: 1px solid var(--tooltip-border);\n border-radius: var(--border-radius-sm);\n background: var(--tooltip-bg);\n box-shadow: var(--shadow-tooltip);\n color: var(--tooltip-fg);\n line-height: var(--line-height);\n}\n.ss-list-opening__text {\n min-width: 0;\n overflow-wrap: anywhere;\n}\n/* Open's search with its Reload button at the right, which has the look of the sidebar's (.ss-tree__action). The search takes the room. */\n.ss-open-search {\n display: flex;\n align-items: center;\n gap: var(--spacing-xs);\n margin-bottom: var(--spacing-xs);\n}\n.ss-open-search .ss-input {\n flex: 1 1 auto;\n min-width: 0;\n}\n.ss-list .ss-type-badge,\n.ss-list .ss-tree__link {\n color: color-mix(in srgb, var(--color-primary) 50%, var(--text-primary));\n}\n/* No row of a list has the keyboard focus: the rows are options of a listbox, and the search above them keeps the focus (Open's and\n History's rows took it until 1.3.0, and a picker's chosen row until RC6, where the chosen target became a summary). The one thing\n in a list that Tab reaches is the Retry link of a list that could not load, and its ring is this mix. */\n.ss-list :focus-visible {\n outline-color: color-mix(in srgb, var(--color-primary) 50%, var(--text-primary));\n}\n/* The list picker (.ss-list--picker): the lists that a Data Extension or a folder is chosen from have this look, one for all of them\n (the author's RC3 and RC4 tests of 1.3.0, 2026-10-11, where the Existing list had it and the others kept the dialog's own background,\n a red tint and a red bar). They are the Save to DE overlay's Existing list and Folder list, and Save As's target list. It is the look\n of the app's quick tip (.ss-tip): the tip's surface, border, shadow and text colour, a step off the dialog behind it. Open and History\n keep the look above, as their lists fill their panel and are not popups. They take only the highlight's look (.is-active, above),\n with the keys of the pickers (the author's RC5 test, 2026-10-12).\n - A hovered row and the highlighted one (.is-active, under the arrow keys) are a light veil of the text colour. A stronger veil, or a\n tint under it, takes the muted text on the tip's surface below 4.5:1 in one of the themes.\n - The highlighted row has a bar in the action colour. The primary colour is red in the dark theme, which read as an error.\n - The chosen row (.is-selected) is framed in the action colour, as the selected option cards are (.ss-radio-option), and has no tint:\n the red tint read as an error as well. The frame is a shadow, joined with the bar on a chosen row that is highlighted, so the\n outline stays free. As first built the frame was an outline, which took the focus ring's place on the chosen row, which Tab\n reached then. Tab reaches no row now.\n - The Retry link's focus ring is the badge's mix, above: the primary colour reads at 3.08:1 on the tip's surface in the dark theme\n and 2.58:1 on the veil of a hovered row, and the mix at 6.01:1 and 5.03:1.\n - The list is positioned (every .ss-list is, above), so that an option's offsetTop is its place in the list, which the keys scroll\n to (targetCombobox, saveFolderPicker). The Folder list's popup places itself with position: absolute, further down.\n - The look is the look of a list that is open. A target picker's box (is-closed, set on each draw) has none of it while no list of\n matches is on show: the search is empty, or Escape closed the matches, or the Data Extensions load, or could not load, or the\n prompt asks for a search. The box is then plain text on the dialog, with a border that stays but does not show, so the box does\n not change its size, and the chosen Data Extension is a summary (chosenTargetSummary) and no row. As first built, in RC5, the\n closed box kept the tip's surface and held the chosen row in its frame, a button. In the author's RC6 test (2026-10-12) a click on\n a Data Extension of the matches chose it, and the closed box then looked like an open dropdown of one option, whose only row\n did nothing when clicked (\"Clicking with mouse on DE to save into does nothing\"). */\n.ss-list--picker {\n border-color: var(--tooltip-border);\n background: var(--tooltip-bg);\n box-shadow: var(--shadow-tooltip);\n color: var(--tooltip-fg);\n}\n.ss-list--picker.is-closed {\n border-color: transparent;\n background: transparent;\n box-shadow: none;\n color: inherit;\n}\n/* The summary of a picker's chosen target while no list of matches is on show (chosenTargetSummary), as text on the dialog: the caption\n in the secondary text colour, the name in bold in the dialog's text colour, and the line of its folder and key as a row has it. The\n badge keeps its hue from the list above (.ss-list .ss-type-badge). The texts read at 4.5:1 or more on the dialog in both themes (the\n secondary text 8.37:1 in the dark theme and 5.72:1 in the light one, and the badge is checked in the scenario). */\n.ss-list__summary {\n padding: var(--spacing-xs) var(--spacing-md) var(--spacing-sm);\n}\n.ss-list__caption {\n font-size: 11px;\n color: var(--text-secondary);\n}\n.ss-list__summary-name {\n margin-top: 2px;\n font-weight: 700;\n overflow: hidden;\n text-overflow: ellipsis;\n white-space: nowrap;\n}\n.ss-list__summary-meta {\n font-size: var(--font-size-sm);\n color: var(--text-secondary);\n overflow: hidden;\n text-overflow: ellipsis;\n white-space: nowrap;\n}\n.ss-list--picker .ss-list-row {\n border-bottom-color: color-mix(in srgb, var(--tooltip-border) 50%, transparent);\n}\n.ss-list--picker .ss-list-row:hover,\n.ss-list--picker .ss-list-row.is-active {\n background: color-mix(in srgb, var(--text-primary) 6%, transparent);\n}\n.ss-list--picker .ss-list-row.is-active {\n box-shadow: inset 3px 0 0 var(--color-action);\n}\n.ss-list--picker .ss-list-row.is-selected {\n box-shadow: inset 0 0 0 2px var(--color-action);\n}\n.ss-list--picker .ss-list-row.is-selected.is-active {\n box-shadow: inset 3px 0 0 var(--color-action), inset 0 0 0 2px var(--color-action);\n}\n.ss-list-row__main {\n flex: 1 1 auto;\n min-width: 0;\n overflow: hidden;\n}\n.ss-list-row__title {\n overflow: hidden;\n text-overflow: ellipsis;\n white-space: nowrap;\n}\n.ss-list-row__meta {\n font-size: var(--font-size-sm);\n color: var(--text-secondary);\n overflow: hidden;\n text-overflow: ellipsis;\n white-space: nowrap;\n}\n.ss-list-row__sql {\n font-family: var(--font-mono);\n font-size: var(--font-size-sm);\n color: var(--text-secondary);\n overflow: hidden;\n text-overflow: ellipsis;\n white-space: nowrap;\n}\n.ss-check-target {\n border: 1px solid var(--border-color);\n border-radius: var(--border-radius-sm);\n padding: var(--spacing-md);\n margin-top: var(--spacing-sm);\n}\n.ss-check-target table {\n width: 100%;\n border-collapse: collapse;\n font-size: var(--font-size-sm);\n}\n.ss-check-target th, .ss-check-target td {\n text-align: left;\n padding: 3px var(--spacing-sm);\n border-bottom: 1px solid var(--border-color);\n}\n\n/* -------------------------------------------------------------------- */\n/* 10. Toasts */\n/* -------------------------------------------------------------------- */\n/* Centred just below the toolbar, where the user is looking after pressing a\n toolbar button, not in a corner. The column lets clicks through between toasts. */\n.ss-toasts {\n position: absolute;\n top: calc(var(--toolbar-height) + var(--spacing-md));\n left: 50%;\n transform: translateX(-50%);\n display: flex;\n flex-direction: column;\n align-items: stretch;\n gap: var(--spacing-sm);\n z-index: 200;\n width: min(480px, calc(100% - 32px));\n pointer-events: none;\n}\n.ss-toast {\n pointer-events: auto;\n display: flex;\n align-items: flex-start;\n gap: var(--spacing-sm);\n padding: var(--spacing-sm) var(--spacing-md);\n border-radius: var(--border-radius-sm);\n border: 1px solid var(--border-color);\n background: var(--bg-surface-raised);\n box-shadow: var(--shadow-md);\n}\n.ss-toast--success {\n border-color: var(--brand-green);\n}\n.ss-toast--error {\n border-color: var(--color-danger);\n}\n.ss-toast--warning {\n border-color: var(--warning-color);\n}\n.ss-toast__body {\n flex: 1 1 auto;\n font-size: var(--font-size-sm);\n}\n.ss-toast__close {\n border: none;\n background: none;\n cursor: pointer;\n color: var(--text-secondary);\n}\n\n/* -------------------------------------------------------------------- */\n/* 11. Misc: badges, scrollbars, focus, responsive */\n/* -------------------------------------------------------------------- */\n.ss-badge {\n display: inline-flex;\n align-items: center;\n gap: 3px;\n padding: 1px 6px;\n border-radius: 10px;\n font-size: 11px;\n font-weight: 600;\n background: var(--bg-elevated);\n color: var(--text-secondary);\n}\n.ss-badge--error {\n background: var(--tint-danger);\n color: var(--danger-text);\n}\n.ss-badge--warning {\n background: var(--tint-warning);\n color: var(--warning-text);\n}\n.ss-badge--info {\n background: var(--tint-info);\n color: var(--info-text);\n}\n\n.ss * ::-webkit-scrollbar {\n width: 10px;\n height: 10px;\n}\n.ss ::-webkit-scrollbar-thumb {\n background: var(--border-color-strong);\n border-radius: 6px;\n}\n.ss ::-webkit-scrollbar-track {\n background: transparent;\n}\n\n@media (max-width: 1023px) {\n .ss-sidebar {\n position: absolute;\n top: 0;\n bottom: 0;\n right: 0;\n z-index: 50;\n box-shadow: var(--shadow-md);\n }\n .ss-sidebar.is-collapsed {\n display: none;\n }\n .ss-sidebar-overlay-bg {\n display: block;\n position: absolute;\n inset: 0;\n background: rgba(0, 0, 0, 0.3);\n z-index: 40;\n }\n}\n\n/* Narrow windows: toolbar buttons drop their labels (icon only) except the primary Run button,\n so the single-row toolbar never wraps. Titles carry the label for tooltips. */\n@media (max-width: 1100px) {\n .ss .ss-toolbar__button--secondary > span { display: none; }\n .ss .ss-toolbar__button--secondary { padding: 4px 6px; }\n}\n@media (max-width: 900px) {\n .ss .ss-toolbar__button:not(.ss-toolbar__button--primary) > span { display: none; }\n .ss .ss-toolbar__button:not(.ss-toolbar__button--primary) { padding: 4px 6px; }\n}\n\n/* Menu links look like menu buttons (Diagramforce): text colour, not link colour. */\n.ss a.ss-toolbar__menu-item {\n color: var(--text-secondary);\n text-decoration: none;\n}\n.ss a.ss-toolbar__menu-item:hover {\n color: var(--text-primary);\n}\n\n/* -------------------------------------------------------------------- */\n/* 12. Save to DE */\n/* -------------------------------------------------------------------- */\n/* The overlay (SaveToDe in sqlstudio.js) needs the width of a table of fields. */\n.ss-modal--xwide {\n max-width: 1080px;\n}\n/* A toast's link has a line of its own, as wide as its words, so the off-site arrow stays beside them, in the toast's own text colour\n rather than the link red (the author's RC7 test, 2026-10-09: the red link wrapped and left its arrow on a line alone). */\n.ss .ss-toast__link {\n display: table;\n margin-top: 2px;\n color: var(--text-primary);\n text-decoration: underline;\n white-space: nowrap;\n}\n.ss .ss-toast__link:hover {\n color: var(--color-primary);\n}\n/* The line beside Close while the rows move: the Data Extension is made, and the window can close. */\n.ss-save__moving {\n flex: 1 1 auto;\n margin: 0;\n align-self: center;\n font-size: var(--font-size-sm);\n color: var(--text-primary);\n}\n.ss-save__moving:empty {\n display: none;\n}\n/* The warnings under the fields table (saveFieldWarnings): a field with no length, a length too short for the values on screen. */\n.ss-save__warnings {\n display: flex;\n flex-direction: column;\n gap: 4px;\n margin-top: var(--spacing-xs);\n}\n.ss-save__warnings:empty {\n display: none;\n}\n/* The line under a failed move's message: the Data Extension's fields are set, so the form's are locked. */\n.ss-save__locked {\n margin-top: 4px;\n}\n.ss-save__moving-line {\n display: block;\n}\n.ss-save__top {\n display: grid;\n grid-template-columns: repeat(3, minmax(0, 1fr));\n gap: var(--spacing-md);\n}\n/* The folder picker: the input, and under it the list of folders that opens over the fields table. It is a list picker (.ss-list--picker)\n and its rows are the other lists' (.ss-list-row): the active one (the arrow keys) is marked by a bar and the chosen one by a frame, as\n they are there. This rule places it over the table. */\n.ss-combo {\n position: relative;\n}\n.ss-combo__popup {\n position: absolute;\n top: 100%;\n left: 0;\n right: 0;\n z-index: 10;\n margin-top: 2px;\n overscroll-behavior: contain;\n}\n.ss-combo__option {\n padding: 5px var(--spacing-md);\n line-height: 1.4;\n}\n.ss-combo__list:not(:empty) + .ss-combo__note {\n border-top: 1px solid color-mix(in srgb, var(--tooltip-border) 50%, transparent);\n}\n/* Beside the other two fields the list is wider than its input, to the left, so a long path shows whole. */\n@media (min-width: 801px) {\n .ss-combo__popup {\n left: auto;\n width: 520px;\n }\n /* Save As's Folder is a field of its own, as wide as the dialog's other fields, so its list is as wide as the input. */\n .ss-combo--full .ss-combo__popup {\n left: 0;\n width: auto;\n }\n}\n/* Save As's Folder is the last field, so the dialog's body scrolls to show its list when it opens, with room under it (scroll-margin). */\n.ss-combo--full .ss-combo__popup {\n scroll-margin-bottom: var(--spacing-md);\n}\n.ss-save__notice:empty {\n display: none;\n}\n.ss-save__note {\n margin: 0 0 var(--spacing-sm);\n font-size: var(--font-size-sm);\n color: var(--text-secondary);\n}\n.ss-save__table-wrap {\n max-height: 36vh;\n overflow: auto;\n border: 1px solid var(--border-color);\n border-radius: var(--border-radius-sm);\n margin-bottom: var(--spacing-md);\n}\n.ss-save__table {\n width: 100%;\n border-collapse: collapse;\n font-size: var(--font-size-sm);\n}\n.ss-save__table th {\n position: sticky;\n top: 0;\n z-index: 1;\n padding: 4px;\n text-align: left;\n font-weight: 600;\n color: var(--text-secondary);\n background: var(--bg-elevated);\n border-bottom: 1px solid var(--border-color);\n}\n.ss-save__table td {\n padding: 3px 4px;\n vertical-align: top;\n}\n.ss-save__table tr.has-error td {\n background: var(--tint-danger);\n}\n/* The column's name on the line of its inputs: one line as tall as an input (26px) keeps the text at their middle, and the cell stays\n at the top, so a line of error under an input leaves it where it is (the author's RC13 test, 2026-10-09). */\n.ss-save__source {\n max-width: 150px;\n overflow: hidden;\n text-overflow: ellipsis;\n white-space: nowrap;\n line-height: 26px;\n color: var(--text-secondary);\n}\n.ss-save__table .ss-input,\n.ss-save__table .ss-select {\n height: 26px;\n}\n.ss-save__number {\n width: 72px;\n text-align: right;\n}\n/* Key and Null: the box at the middle of its column, under its centred heading, and at the middle of the line of the inputs. */\n.ss .ss-save__table .ss-save__check {\n text-align: center;\n}\n.ss-save__check-box {\n display: flex;\n align-items: center;\n justify-content: center;\n height: 26px;\n}\n.ss-save__check-box > input {\n margin: 0;\n}\n.ss-save__error {\n margin-top: 2px;\n font-size: 11px;\n color: var(--danger-text);\n}\n.ss-save__error:empty {\n display: none;\n}\n.ss .ss-input.is-invalid {\n border-color: var(--color-danger);\n}\n.ss-save__group {\n margin-bottom: var(--spacing-sm);\n}\n.ss-save__line {\n display: flex;\n flex-wrap: wrap;\n align-items: center;\n gap: var(--spacing-sm);\n}\n.ss-save__line .ss-select,\n.ss-save__line .ss-input {\n width: auto;\n}\n.ss-save__inline {\n display: inline-flex;\n align-items: center;\n gap: var(--spacing-xs);\n}\n.ss-save__cost,\n.ss-save__fix {\n margin: var(--spacing-xs) 0 0;\n font-size: var(--font-size-sm);\n color: var(--text-secondary);\n}\n.ss-save__cost:empty,\n.ss-save__fix:empty {\n display: none;\n}\n/* The line on what the save does, under the overlay's title, with the gap a field group has before the Name. */\n.ss-modal__body > [data-save=\"hint\"] {\n margin-bottom: var(--spacing-md);\n}\n/* The cost sits in the footer, left of Cancel and Save, as Export CSV's label sits by its button (the author's RC12 test, 2026-10-09).\n While a save runs it is empty, and the line on the move takes its place. */\n.ss-modal__footer > .ss-save__cost {\n flex: 1 1 auto;\n margin: 0;\n align-self: center;\n}\n.ss-save__fix {\n color: var(--danger-text);\n}\n.ss-save__link {\n display: inline-block;\n margin-top: var(--spacing-xs);\n color: var(--color-primary);\n}\n.ss-save__primary .ss-spinner {\n margin-right: var(--spacing-xs);\n}\n/* The choice at the top of the overlay (New or Existing Data Extension, 1.3.0) and the update type's cards of the Existing choice are\n Save As's radio cards. A card is dimmed while its radio is off: the choice while a save runs, the update types until a target and its\n fields are there, and Update when its key fields have no column. */\n.ss-save__choice {\n margin-bottom: var(--spacing-md);\n}\n.ss-radio-option.is-disabled {\n opacity: 0.6;\n cursor: not-allowed;\n}\n.ss-radio-option .ss-save__card-note {\n display: block;\n margin-top: 2px;\n color: var(--warning-text);\n}\n.ss-radio-option .ss-save__card-note:empty {\n display: none;\n}\n/* The list of Data Extensions to save into: a list picker (.ss-list--picker) in a box that leaves room for the mapping below it. */\n.ss-save__targets {\n max-height: 200px;\n margin-top: var(--spacing-xs);\n}\n.ss-save__targets-note {\n margin: var(--spacing-xs) 0 0;\n}\n/* The mapping of columns to fields: the field a column goes to, as plain text when its name matched, else a list with Leave out. */\n.ss-save__map .ss-select {\n width: 100%;\n max-width: 360px;\n}\n.ss-save__matched,\n.ss-save__ftype {\n display: block;\n line-height: 26px;\n white-space: nowrap;\n}\n.ss-save__ftype {\n color: var(--text-secondary);\n}\n.ss-save__fix.is-neutral {\n color: var(--text-secondary);\n}\n/* The lint errors that stopped a save of Save As or of the Save to DE overlay (lintErrorsBox), between the dialog's body and its buttons so they\n are in view whatever the body's scroll. A long list scrolls inside the box. */\n.ss-save__gate {\n flex: 0 1 auto;\n max-height: 160px;\n overflow-y: auto;\n margin: 0 var(--spacing-lg) var(--spacing-sm);\n font-size: var(--font-size-sm);\n}\n.ss-save__gate-title {\n font-weight: 700;\n}\n.ss-save__gate-list {\n margin: var(--spacing-xs) 0 0;\n padding-left: 18px;\n}\n.ss-save__gate-id {\n margin-right: var(--spacing-xs);\n}\n@media (max-width: 800px) {\n .ss-save__top {\n grid-template-columns: minmax(0, 1fr);\n }\n}\n"; /* ========================================================= sqlstudio.js == */ (function (global) { 'use strict'; var frontendStartedAt = readClock(); var SQLData = global.SQLStudioSQL || {}; var Tools = global.SQLStudioSQLTools; if (!Tools) { if (global.console) global.console.error('SQL Studio: sqlstudio-sql-tools.js must be loaded before sqlstudio.js'); return; }; var LS = { restore: 'ss.restore', sql: 'ss.sql', history: 'ss.history', settings: 'ss.settings', setup: 'ss.setup', update: 'ss.update', updateDismissed: 'ss.updateDismissed', skillDownloaded: 'ss.skillDownloaded', sweptAt: 'ss.sweptAt', sidebar: 'ss.sidebar', deExtras: 'ss.deExtras', suggestDetails: 'ss.suggestDetails', saveFolder: 'ss.saveFolder' }; function midKey(prefix) { return prefix + '.' + (Session.data.mid || 'default'); }; function userScope() { return (Session.data.mid || 'default') + '.' + (Session.data.userKey || 'default'); }; function userKeyFor(prefix) { return prefix + '.' + userScope(); }; var WORKSPACE_CACHE_MS = 7 * 24 * 60 * 60 * 1000; var WORKSPACE_WARNING_CACHE_MS = 24 * 60 * 60 * 1000; var WORKSPACE_CACHE_VERSION = 2; function logWorkspace(ws) { if (logWorkspace.isDone || !ws || typeof console === 'undefined') return; logWorkspace.isDone = true; var place = function (folderId, rootId) { if (!folderId) return 'the root folder'; return String(folderId) === String(rootId) ? 'the root folder' : 'the SQL Studio folder'; }; var text = '[SQL Studio] Runs file their Data Extensions in ' + place(ws.deFolderId, ws.deRootId) + ' and their query activities in ' + place(ws.queryFolderId, ws.queryRootId) + '.'; if (ws.warnings && ws.warnings.length) { console.warn(text + ' ' + ws.warnings.join(' ')); } else { console.info(text); }; }; function workspaceKey() { return midKey('ss.workspace'); }; function deListKey() { return userKeyFor('ss.deList2'); } function legacyDeListKey() { return userKeyFor('ss.deList'); } function sidebarKey() { return LS.sidebar + '.' + (Config.data.userKey || 'default'); } function suggestDetailsKey() { return LS.suggestDetails + '.' + (Config.data.userKey || 'default'); } function tabsKey() { if (Session.data.userKey) return 'ss.tabs.' + userScope(); var lastScope = safeGet(LAST_SCOPE_KEY); var isThisUser = !!lastScope && !!Config.data.userKey && lastScope.split('.')[1] === Config.data.userKey; return isThisUser ? 'ss.tabs.' + lastScope : null; }; var LAST_SCOPE_KEY = 'ss.lastScope'; function removeLegacyStores() { var legacyExact = ['ss.history', 'ss.lastMid', 'ss.sweptAt', 'ss.sql']; var legacyPerMid = /^ss\.(tabs|adhocQuery\d*|deList)\.[^.]+$/; var keys = []; try { for (var i = 0; i < global.localStorage.length; i++) keys.push(global.localStorage.key(i)); } catch (e) { return; }; keys.forEach(function (key) { if (key && (legacyExact.indexOf(key) !== -1 || legacyPerMid.test(key))) safeRemove(key); }); }; var QUERY_SLOT_COUNT = 4; function adhocQueryKey(slot) { var slotNumber = (slot >= 1 && slot <= QUERY_SLOT_COUNT) ? slot : 1; return userKeyFor('ss.adhocQuery' + (slotNumber === 1 ? '' : slotNumber)); }; var THEME_LABELS = { system: 'Theme: follows your system. Click for light.', light: 'Theme: light. Click for dark.', dark: 'Theme: dark. Click to follow your system.' }; var DEFAULT_SETTINGS = { colorTheme: 'system', pageSize: 100, sidebarWidth: 280, splitRatio: 0.68, statusLevel: 'info' }; var SEVERITY_RANK = { error: 0, warning: 1, info: 2 }; function isShownAtStatusLevel(severity) { var level = SEVERITY_RANK[Settings.data.statusLevel]; if (level === undefined) level = SEVERITY_RANK.info; var rank = SEVERITY_RANK[severity]; return rank === undefined || rank <= level; }; var OLD_DEFAULT_SPLIT_RATIO = 0.42; var MONACO_TIMEOUT_MS = 15000; var LINT_DEBOUNCE_MS = 400; var SAVE_GATE_CLICK_GAP_MS = 600; var JOIN_REFRESH_MS = 250; var JOIN_LIST_SHOW_WAIT_MS = 50; var JOIN_LIST_SHOW_TRIES = 20; var SQL_SAVE_DEBOUNCE_MS = 500; var DE_LIST_CACHE_MS = 24 * 60 * 60 * 1000; var DE_DESCRIPTION_MAX = 300; var DE_EXTRAS_UNNAMED_MS = 24 * 60 * 60 * 1000; var DE_LIST_EXTRAS = ['SendableDataExtensionField.Name', 'SendableSubscriberField.Name', 'Template.CustomerKey', 'Description']; var DE_LIST_DETAILS_MAX = 500; var DE_DETAILS_BATCH_MAX = 50; var DE_DETAILS_GATHER_MS = 50; var DE_LIST_SAVE_DELAY_MS = 1000; var DE_FOLDER_TYPES = ['dataextension', 'shared_data', 'shared_dataextension', 'salesforcedataextension', 'shared_salesforcedataextension', 'synchronizeddataextension']; var DE_FIELD_AUTOLOAD_DEBOUNCE_MS = 1500; var POLL_FAST_MS = 3000; var POLL_SLOW_MS = 30000; var POLL_WAIT_SHARE = 0.2; var POLL_FREE_FAST_MS = 3000; var POLL_FREE_SLOW_MS = 5000; var POLL_FREE_FAST_WINDOW_MS = 60000; var POLL_FIRST_STATUS_MS = 20000; var POLL_EARLY_STATUS_GAP_MS = 15000; var POLL_STATUS_SHARE = 0.5; var POLL_STATUS_MIN_MS = 30000; var POLL_STATUS_MAX_MS = 120000; var POLL_STATUS_RECHECK_MS = 20000; var POLL_QUEUE_WAIT_MS = 60000; var OUTCOME_MAX_CALLS = 12; var OUTCOME_RETRY_MS = [15000, 30000, 60000, 120000, 240000]; var TASK_ROW_WAIT_MS = 5 * 60 * 1000; var APP_VERSION = '1.4.1'; var UPDATE_CHECK_URL = 'https://raw.githubusercontent.com/MateuszDabrowski/sqlstudio/main/latest.json'; var UPDATE_CHECK_EVERY_MS = 24 * 60 * 60 * 1000; var UPDATE_CHECK_TIMEOUT_MS = 8000; var SKILL_FILE_PATH = 'skill/sf-mce-sql.skill'; var SKILL_FILE_URL = UPDATE_CHECK_URL.replace(/[^\/]*$/, SKILL_FILE_PATH); var SKILL_FILE_NAME = SKILL_FILE_PATH.replace(/^.*\//, ''); var SKILL_TITLE = 'SF MCE SQL'; var SKILL_DOWNLOAD_TIMEOUT_MS = 60 * 1000; var UPDATE_HIGHLIGHT_MAX_COUNT = 5; var UPDATE_HIGHLIGHT_MAX_LENGTH = 300; var UPDATE_LEAD_MAX_LENGTH = 120; var UPDATE_LINK_RE = /^https:\/\/(github\.com\/MateuszDabrowski\/|mateuszdabrowski\.pl\/)/; var SWEEP_EVERY_MS = 24 * 60 * 60 * 1000; var POLL_MAX_MS = 31 * 60 * 1000; var RERUN_MAX_ELAPSED_MS = 25 * 60 * 1000; var POLL_QUEUED_GRACE_MS = 60000; var POLL_MAX_NETWORK_FAILURES = 3; var RESTORE_MAX_AGE_MS = 30 * 60 * 1000; var SPINNER_TURN_MS = 800; var EXPORT_BUTTON_LABEL = 'Export CSV'; var SESSION_MIN_SECONDS_TO_RUN = 120; var FIELD_WAIT_MS = 5000; var WORKSPACE_RETRY_MS = 5 * 60 * 1000; var RENEW_SLOW_MS = 45000; var SILENT_RENEW_AT_SECONDS = 180; var SILENT_RENEW_IDLE_MS = 15 * 60 * 1000; var SILENT_RENEW_CHECK_MS = 30000; var SILENT_RENEW_RETRY_MS = 60000; var SILENT_RENEW_TIMEOUT_MS = 35000; var SESSION_ID_RE = /^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}$/; var STACK_KEY_RE = /^S[0-9]+$/; var DE_ID_RE = /^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}$/; var DISABLED_LINT_RULES = ['MCE061']; var PAGE_SIZES = [50, 100, 250, 500, 1000, 2500]; var VIRTUALIZE_THRESHOLD = 300; var ROW_HEIGHT_PX = 24; var DATA_VIEW_GROUPS = { _Subscribers: 'Email', _EnterpriseAttribute: 'Email', _Job: 'Email', _Sent: 'Email', _Open: 'Email', _Click: 'Email', _Bounce: 'Email', _Complaint: 'Email', _Unsubscribe: 'Email', _BusinessUnitUnsubscribes: 'Email', _ListSubscribers: 'Email', _ReconcilableDispositionView: 'Email', _Coupon: 'Email', _FTAF: 'Email', _SurveyResponse: 'Email', _SocialNetworkTracking: 'Email', _SocialNetworkImpressions: 'Email', _Journey: 'Journey', _JourneyActivity: 'Journey', _AutomationInstance: 'Automation', _AutomationActivityInstance: 'Automation', _MobileAddress: 'Mobile', _MobileSubscription: 'Mobile', _SubscriberSMS: 'Mobile', _SMSSubscriptionLog: 'Mobile', _SMSMessageTracking: 'Mobile', _UndeliverableSMS: 'Mobile', _PushAddress: 'Mobile', _PushTag: 'Mobile' }; var DATA_VIEW_GROUP_ORDER = ['Email', 'Journey', 'Mobile', 'Automation', 'Other']; function dataViewGroup(name) { return DATA_VIEW_GROUPS[name] || 'Other'; }; function computeDeAlias(name) { return Tools.deriveDeAlias(name); }; var Shared = { sidebarCollapsed: false, apiCalls: 0, wsproxyCalls: 0 }; function makeTabState() { return { dirty: false, lastLoadedText: '', openedQuery: null, /* { id, key, name, targetKey, targetName, updateType, categoryId } */ activeRun: null, /* { runId, deKey, deName, deId, queryId, slot, startedAt, pollMs, columns, retentionApplied, taskId, taskSig } */ runnerState: 'idle', results: null, /* { columns, rows, count, page, pageSize, orderBy, deKey } */ unreadPage: null, serverAcceptedText: null, statusError: null, /* { message, details, gate, hint, about } | null, gate being 'run' or 'save' for a lint gate's panel */ statusNotices: [], /* string[] */ validateAcceptedText: null, formatDoneText: null, bottomPanel: null, runDetails: [], grid: { data: null, clientFilter: '', sortColumn: null, sortDir: null, clientSortActive: false, colWidths: {}, chunkCache: null } }; }; var state = null; var Runner = null; function h(tag, attrs, children) { var node = document.createElement(tag); if (attrs) { for (var k in attrs) { if (!Object.prototype.hasOwnProperty.call(attrs, k)) continue; var v = attrs[k]; if (v === null || v === undefined) continue; if (k === 'class') node.className = v; else if (k === 'style' && typeof v === 'object') { for (var sk in v) if (Object.prototype.hasOwnProperty.call(v, sk)) node.style[sk] = v[sk]; } else if (k.slice(0, 2) === 'on' && typeof v === 'function') { node.addEventListener(k.slice(2).toLowerCase(), v); } else if (k === 'dataset' && typeof v === 'object') { for (var dk in v) if (Object.prototype.hasOwnProperty.call(v, dk)) node.dataset[dk] = v[dk]; } else if (v === true) { node.setAttribute(k, ''); } else if (v === false) { } else { node.setAttribute(k, String(v)); }; }; }; appendChildren(node, children); return node; }; function appendChildren(node, children) { if (children === null || children === undefined) return; var arr = Array.isArray(children) ? children : [children]; for (var i = 0; i < arr.length; i++) { var c = arr[i]; if (c === null || c === undefined || c === false) continue; if (typeof c === 'string' || typeof c === 'number') { node.appendChild(document.createTextNode(String(c))); } else if (Array.isArray(c)) { appendChildren(node, c); } else { node.appendChild(c); }; }; }; function clearNode(node) { while (node.firstChild) node.removeChild(node.firstChild); }; function svgIcon(pathData, size) { var ns = 'http://www.w3.org/2000/svg'; var svg = document.createElementNS(ns, 'svg'); svg.setAttribute('viewBox', '0 0 16 16'); svg.setAttribute('width', String(size || 14)); svg.setAttribute('height', String(size || 14)); svg.setAttribute('aria-hidden', 'true'); var path = document.createElementNS(ns, 'path'); path.setAttribute('d', pathData); svg.appendChild(path); return svg; }; function mdEscape(s) { s = s === null || s === undefined ? '' : String(s); return s.replace(/[\\`*_{}\[\]()#+\-.!<>&|]/g, function (c) { return '\\' + c; }); }; function debounce(fn, ms) { var t = null; return function () { var args = arguments; var ctx = this; if (t) clearTimeout(t); t = setTimeout(function () { t = null; fn.apply(ctx, args); }, ms); }; }; function clamp(n, min, max) { return Math.max(min, Math.min(max, n)); }; function uid() { return Date.now().toString(36) + Math.random().toString(36).slice(2, 8); }; function apiCallsText(n) { return fmtNumber(n) + (n === 1 ? ' API call' : ' API calls'); }; function wsproxyCallsText(n) { return fmtNumber(n) + (n === 1 ? ' WSProxy call' : ' WSProxy calls'); }; function callCountsText(apiCalls, wsproxyCalls) { return apiCallsText(apiCalls) + (wsproxyCalls ? ' · ' + fmtNumber(wsproxyCalls) + ' WSProxy' : ''); }; function exportCostText(apiCalls, wsproxyCalls) { var parts = []; if (wsproxyCalls) parts.push(wsproxyCallsText(wsproxyCalls)); if (apiCalls) parts.push(apiCallsText(apiCalls)); return parts.length ? parts.join(' and ') : 'No API call'; }; function exportCostShortText(apiCalls, wsproxyCalls) { var parts = []; if (wsproxyCalls) parts.push(fmtNumber(wsproxyCalls) + ' WSProxy'); if (apiCalls) parts.push(fmtNumber(apiCalls) + ' API'); return parts.length ? parts.join(' + ') : 'Free'; }; function exportProgressText(rowsRead, totalRows) { if (typeof totalRows !== 'number' || totalRows <= 0 || rowsRead > totalRows) return 'Exporting…'; return 'Exporting ' + fmtNumber(rowsRead) + ' of ' + fmtNumber(totalRows) + '…'; }; function fmtDuration(ms) { if (ms === null || ms === undefined) return '-'; if (ms < 1000) return ms + ' ms'; var s = ms / 1000; if (s < 60) return s.toFixed(1) + ' s'; var m = Math.floor(s / 60); var rs = Math.round(s % 60); return m + ' min ' + rs + ' s'; }; function readClock() { try { var now = global.performance.now(); return (typeof now === 'number' && isFinite(now)) ? now : null; } catch (e) { return null; }; }; function fmtSeconds(ms) { return (ms / 1000).toFixed(1) + ' s'; }; function fmtDateTime(value) { if (!value) return '-'; var parsedDate = value instanceof Date ? value : new Date(value); if (isNaN(parsedDate.getTime())) return String(value); return parsedDate.toLocaleString(); }; function fmtNumber(n) { if (n === null || n === undefined) return '-'; try { return n.toLocaleString(); } catch (e) { return String(n); }; }; function safeGet(key) { try { return global.localStorage.getItem(key); } catch (e) { return null; }; }; function safeSet(key, value) { try { global.localStorage.setItem(key, value); return true; } catch (e) { return false; }; }; function safeRemove(key) { try { global.localStorage.removeItem(key); } catch (e) { }; }; function readJSON(key, fallback) { var raw = safeGet(key); if (!raw) return fallback; try { return JSON.parse(raw); } catch (e) { return fallback; }; }; function writeJSON(key, value) { try { return safeSet(key, JSON.stringify(value)); } catch (e) { return false; }; }; function firstLine(sql, max) { var s = String(sql || '').replace(/\s+/g, ' ').trim(); if (s.length > (max || 120)) s = s.slice(0, max || 120) + '...'; return s; }; function fmtRows(count) { return fmtNumber(count) + (count === 1 ? ' row' : ' rows'); }; function fmtClock(ms) { var d = new Date(ms); var pad = function (n) { return (n < 10 ? '0' : '') + n; }; return pad(d.getHours()) + ':' + pad(d.getMinutes()) + ':' + pad(d.getSeconds()); }; function fmtShortDuration(ms) { if (!(ms >= 1000)) return 'under 1 s'; var seconds = Math.floor(ms / 1000); if (seconds < 60) return seconds + ' s'; return Math.floor(seconds / 60) + ' min ' + (seconds % 60) + ' s'; }; function once(fn) { var called = false, result; return function () { if (!called) { called = true; result = fn.apply(this, arguments); }; return result; }; }; function copyToClipboard(text) { if (legacyCopy(text)) return Promise.resolve(true); if (global.navigator && global.navigator.clipboard && global.navigator.clipboard.writeText) { return global.navigator.clipboard.writeText(text).then(function () { return true; }, function () { return false; }); }; return Promise.resolve(false); }; function announceCopy(copying, doneText) { copying.then(function (isCopied) { if (isCopied) Toast.show(doneText, 'success'); else Toast.show('The browser did not allow the copy. Select the text and copy it with ' + shortcutLabel('C') + '.', 'warning'); }); }; function legacyCopy(text) { var previousFocus = document.activeElement; var ta = document.createElement('textarea'); ta.value = text; ta.style.position = 'fixed'; ta.style.opacity = '0'; document.body.appendChild(ta); ta.select(); var isCopied = false; try { isCopied = document.execCommand('copy') === true; } catch (e) { isCopied = false; }; document.body.removeChild(ta); if (previousFocus && previousFocus.focus) previousFocus.focus(); return isCopied; }; var CSV_FORMULA_LEAD_RE = /^[=+\-@\t\r]/; var CSV_PLAIN_NUMBER_RE = /^[+-]?\d+([.,]\d+)?([eE][+-]?\d+)?$/; function csvEscape(value) { var s = value === null || value === undefined ? '' : String(value); if (CSV_FORMULA_LEAD_RE.test(s) && !CSV_PLAIN_NUMBER_RE.test(s)) s = "'" + s; if (/[",\n\r]/.test(s)) s = '"' + s.replace(/"/g, '""') + '"'; return s; }; var EXPORT_MIN_PAGE_SIZE = 100; function exportRetryPageSize(pageSize) { for (var d = Math.floor(pageSize / 2); d >= EXPORT_MIN_PAGE_SIZE; d--) { if (pageSize % d === 0) return d; }; return 0; }; function silentProgress() { var handle = { update: function () {}, close: function () {} }; return handle; }; function downloadText(filename, text, mime) { downloadBlob(filename, new Blob([text], { type: mime || 'text/plain' })); }; function downloadBlob(filename, blob) { var url = URL.createObjectURL(blob); var a = document.createElement('a'); a.href = url; a.download = filename; document.body.appendChild(a); a.click(); document.body.removeChild(a); setTimeout(function () { URL.revokeObjectURL(url); }, 4000); }; var ICON_SPRITE_SVG = ''; var ICON_SPRITE_SVG_MORE = '' + '' + '' + '' + ''; function injectIconSprite() { if (document.getElementById('ss-icon-sprite')) return; var svg = document.createElementNS('http://www.w3.org/2000/svg', 'svg'); svg.setAttribute('id', 'ss-icon-sprite'); svg.setAttribute('aria-hidden', 'true'); svg.style.position = 'absolute'; svg.style.width = '0'; svg.style.height = '0'; svg.style.overflow = 'hidden'; var doc = new DOMParser().parseFromString('' + ICON_SPRITE_SVG + ICON_SPRITE_SVG_MORE + '', 'image/svg+xml'); var symbols = doc.documentElement.childNodes; for (var i = 0; i < symbols.length; i++) { var imported = document.importNode(symbols[i], true); if (imported.nodeType === 1) svg.appendChild(imported); }; document.body.appendChild(svg); }; function icon(id, extraClass) { var ns = 'http://www.w3.org/2000/svg'; var svg = document.createElementNS(ns, 'svg'); svg.setAttribute('class', 'ss-icon' + (extraClass ? ' ' + extraClass : '')); svg.setAttribute('aria-hidden', 'true'); svg.setAttribute('focusable', 'false'); var use = document.createElementNS(ns, 'use'); use.setAttributeNS('http://www.w3.org/1999/xlink', 'href', '#' + id); use.setAttribute('href', '#' + id); svg.appendChild(use); return svg; }; var SVG_NS = 'http://www.w3.org/2000/svg'; function themeToggleIconSvg() { var svg = document.createElementNS(SVG_NS, 'svg'); svg.setAttribute('class', 'ss-icon ss-toolbar__icon--theme'); svg.setAttribute('viewBox', '0 0 24 24'); svg.setAttribute('fill', 'none'); svg.setAttribute('stroke', 'currentColor'); svg.setAttribute('stroke-width', '2'); svg.setAttribute('stroke-linecap', 'round'); svg.setAttribute('stroke-linejoin', 'round'); svg.setAttribute('aria-hidden', 'true'); var moon = document.createElementNS(SVG_NS, 'path'); moon.setAttribute('class', 'ss-icon-moon'); moon.setAttribute('d', 'M21 12.79A9 9 0 1 1 11.21 3 7 7 0 0 0 21 12.79z'); svg.appendChild(moon); var sun = document.createElementNS(SVG_NS, 'g'); sun.setAttribute('class', 'ss-icon-sun'); [ ['circle', { cx: '12', cy: '12', r: '5' }], ['line', { x1: '12', y1: '1', x2: '12', y2: '3' }], ['line', { x1: '12', y1: '21', x2: '12', y2: '23' }], ['line', { x1: '4.22', y1: '4.22', x2: '5.64', y2: '5.64' }], ['line', { x1: '18.36', y1: '18.36', x2: '19.78', y2: '19.78' }], ['line', { x1: '1', y1: '12', x2: '3', y2: '12' }], ['line', { x1: '21', y1: '12', x2: '23', y2: '12' }], ['line', { x1: '4.22', y1: '19.78', x2: '5.64', y2: '18.36' }], ['line', { x1: '18.36', y1: '5.64', x2: '19.78', y2: '4.22' }] ].forEach(function (spec) { var el = document.createElementNS(SVG_NS, spec[0]); for (var k in spec[1]) el.setAttribute(k, spec[1][k]); sun.appendChild(el); }); svg.appendChild(sun); var system = document.createElementNS(SVG_NS, 'g'); system.setAttribute('class', 'ss-icon-system'); var ring = document.createElementNS(SVG_NS, 'circle'); ring.setAttribute('cx', '12'); ring.setAttribute('cy', '12'); ring.setAttribute('r', '9'); system.appendChild(ring); var half = document.createElementNS(SVG_NS, 'path'); half.setAttribute('class', 'ss-icon-system__half'); half.setAttribute('d', 'M12 3a9 9 0 0 1 0 18z'); system.appendChild(half); svg.appendChild(system); return svg; }; function helpIconSvg() { var svg = document.createElementNS(SVG_NS, 'svg'); svg.setAttribute('class', 'ss-icon'); svg.setAttribute('viewBox', '0 0 16 16'); svg.setAttribute('fill', 'currentColor'); svg.setAttribute('aria-hidden', 'true'); var path = document.createElementNS(SVG_NS, 'path'); path.setAttribute('d', 'M8 1a7 7 0 1 0 0 14A7 7 0 0 0 8 1zm0 12.5a5.5 5.5 0 1 1 0-11 5.5 5.5 0 0 1 0 11z'); svg.appendChild(path); var text = document.createElementNS(SVG_NS, 'text'); text.setAttribute('x', '8'); text.setAttribute('y', '11.7'); text.setAttribute('text-anchor', 'middle'); text.setAttribute('font-size', '10'); text.setAttribute('font-weight', '700'); text.setAttribute('font-family', 'system-ui, -apple-system, sans-serif'); text.textContent = '?'; svg.appendChild(text); return svg; }; function normalizeForSearch(s) { return String(s || '').toLowerCase().replace(/_/g, ''); }; function isUnderscoreSearch(query) { return /^_+$/.test(String(query || '').trim()); }; var TOAST_DURATIONS = { success: 2000, info: 4000, warning: 6000, error: 10000 }; var Toast = { _container: null, init: function (container) { this._container = container; }, show: function (message, kind, opts) { opts = opts || {}; kind = kind || 'info'; if (!this._container) return function () { /* nothing to remove */ }; var isSticky = !!opts.sticky; var link = (opts.link && /^https:\/\//.test(String(opts.link.href))) ? h('a', { class: 'ss-toast__link', href: String(opts.link.href), target: '_blank', rel: 'noopener noreferrer' }, String(opts.link.text)) : null; var toast = h('div', { class: 'ss-toast ss-toast--' + kind, role: 'status' }, [ icon(kind === 'success' ? 'check' : kind === 'warning' ? 'warning' : kind === 'error' ? 'error' : 'info'), h('div', { class: 'ss-toast__body' }, link ? [String(message), ' ', link] : String(message)), h('button', { class: 'ss-toast__close', type: 'button', 'aria-label': 'Dismiss', onclick: function () { removeToast(); } }, icon('close')) ]); this._container.appendChild(toast); var timer = null; function removeToast() { if (timer) clearTimeout(timer); if (toast.parentNode) toast.parentNode.removeChild(toast); }; if (!isSticky) timer = setTimeout(removeToast, opts.duration || TOAST_DURATIONS[kind] || 4000); return removeToast; } }; var Config = { data: {}, init: function () { var raw = global.SQLStudioConfig || {}; this.data = { session: raw.session || '', backendUrl: raw.backendUrl || '', userName: raw.userName || '', userEmail: raw.userEmail || '', userKey: /^[0-9a-f]{8}$/.test(raw.userKey || '') ? raw.userKey : '', version: raw.version || '0.0.0', mock: !!raw.mock, monacoBase: raw.monacoBase || 'https://cdn.jsdelivr.net/npm/monaco-editor@0.52.2/min/vs', docsBase: raw.docsBase || 'https://mateuszdabrowski.pl/docs/category/salesforce/marketing-cloud-engagement/sql/', repoUrl: raw.repoUrl || 'https://github.com/MateuszDabrowski/sqlstudio', setupPending: !!raw.setupPending }; return this.data; } }; var IDEMPOTENT_ACTIONS = { whoami: true, getWorkspace: true, listFolders: true, listDataExtensions: true, listParentDataExtensions: true, getDataExtensionFields: true, getDataExtensionDetails: true, validateQuery: true, getRunStatus: true, getRunOutcome: true, getResults: true, listRuns: true, listQueryActivities: true, getQueryActivity: true, listHistory: true, clearHistory: true, listSnippets: true, deleteSnippet: true }; var SCHEMA_READ_ACTIONS = { listFolders: true, listDataExtensions: true, listParentDataExtensions: true, getDataExtensionFields: true, getDataExtensionDetails: true }; var SELF_RECOVERING_ACTIONS = { listFolders: true, listDataExtensions: true, listParentDataExtensions: true, getDataExtensionFields: true, getDataExtensionDetails: true, runQuery: true, getRunStatus: true, getRunOutcome: true }; var SLOW_CALL_LOG_MS = 5000; function logBackendCall(action, startedAt, err) { if (typeof console === 'undefined') return; var took = fmtDuration(Date.now() - startedAt); var timings = Api._lastTimings[action]; var split = (timings && timings.at >= startedAt) ? ': the Backend worked ' + fmtSeconds(timings.backendMs) + ', of which ' + fmtSeconds(timings.soapMs) + ' waiting for SOAP and ' + fmtSeconds(timings.restMs) + ' for REST' : ''; if (err) { var detailText = (err.code === 'INTERNAL' && err.details) ? ' | ' + String(err.details).substring(0, 500) : ''; console.warn('[SQL Studio] ' + action + ' failed after ' + took + ': ' + (err.code ? err.code + ' - ' : '') + err.message + detailText); } else if (Date.now() - startedAt >= SLOW_CALL_LOG_MS) { console.info('[SQL Studio] ' + action + ' took ' + took + split + '.'); }; }; var DEDUPE_ACTIONS = { getRunStatus: true, getResults: true }; var BACKEND_BU_ACTIONS = { getRunStatus: true, runQuery: true, moveToDataExtension: true, deleteRun: true, listRuns: true, exportRows: true }; var API_TIMEOUT_MS = 60000; var RUNQUERY_TIMEOUT_MS = 150000; var RATE_LIMIT_WAITS_MS = [1500, 3000, 6000]; var RATE_LIMIT_JITTER = 0.2; var RATE_LIMIT_MAX_RETRY_AFTER_MS = 30000; var RATE_LIMITED_TEXT = 'Marketing Cloud Engagement is getting too many requests from SQL Studio right now (HTTP 429).'; var RATE_LIMITED_ADVICE = ' Wait a few seconds and try again.'; function parseRetryAfter(header) { if (typeof header !== 'string' || !/^\s*\d+(\.\d+)?\s*$/.test(header)) return null; var ms = Math.round(parseFloat(header) * 1000); return ms > 0 ? ms : null; }; function rateLimitWaitMs(tries, retryAfterMs) { var base = (retryAfterMs && retryAfterMs <= RATE_LIMIT_MAX_RETRY_AFTER_MS) ? retryAfterMs : RATE_LIMIT_WAITS_MS[tries]; return Math.round(base * (1 + Math.random() * RATE_LIMIT_JITTER)); }; function withoutHtmlTags(text) { if (typeof text !== 'string' || text.indexOf('<') === -1) return text; var cleaned = text.replace(/<\/?[A-Za-z][A-Za-z0-9]*(?:\s[^<>]*)?\/?>/g, ' '); return cleaned === text ? text : cleaned.replace(/[ \t]{2,}/g, ' ').trim(); }; var Api = { _inflight: {}, makeError: function (code, message, details) { var e = new Error(withoutHtmlTags(message) || code); e.code = code || 'INTERNAL'; e.details = details === undefined ? null : details; return e; }, callLog: [], _lastTimings: {}, /* action -> the times of its latest answer (recordCall), for logBackendCall */ recordCall: function (action, json, owner) { var data = json.data || {}; var apiCalls = (json.meta && typeof json.meta.apiCalls === 'number') ? json.meta.apiCalls : 0; var wsproxyCalls = (json.meta && typeof json.meta.wsproxyCalls === 'number') ? json.meta.wsproxyCalls : 0; var entry = { action: action, apiCalls: apiCalls, wsproxyCalls: wsproxyCalls, isFree: apiCalls === 0 && ((action === 'getRunStatus' && json.ok === true && data.isRunning === null && typeof data.rowCount === 'number') || (action === 'getResults' && data.readPath === 'free')) }; this.callLog.push(entry); if (this.callLog.length > 500) this.callLog.splice(0, this.callLog.length - 500); var meta = json.meta || {}; if (typeof meta.backendMs === 'number') { this._lastTimings[action] = { at: Date.now(), backendMs: meta.backendMs, soapMs: typeof meta.soapMs === 'number' ? meta.soapMs : 0, restMs: typeof meta.restMs === 'number' ? meta.restMs : 0 }; }; if (owner && owner._calls) owner._calls.push(entry); }, call: function (action, payload, owner) { var self = this; payload = payload || {}; if (BACKEND_BU_ACTIONS[action] && Session.isBackendBu()) { payload = Object.assign({}, payload, { inBackendBu: true }); }; if (action === 'getResults' && payload.hasRowMark === undefined && runForDeKey(payload.deKey)) { payload = Object.assign({}, payload, { hasRowMark: true }); }; var dedupeKey = DEDUPE_ACTIONS[action] ? action + ' ' + JSON.stringify(payload) : null; if (dedupeKey && self._inflight[dedupeKey]) { return self._inflight[dedupeKey]; }; var promise = self._send(action, payload, false, owner).then( function (data) { if (dedupeKey) delete self._inflight[dedupeKey]; return data; }, function (err) { if (dedupeKey) delete self._inflight[dedupeKey]; throw err; } ); if (dedupeKey) self._inflight[dedupeKey] = promise; return promise; }, callWithSession: function (action, payload, sessionId) { return this._send(action, payload || {}, false, null, sessionId); }, _send: function (action, payload, isRetry, owner, sessionOverride, isResent, rateTries) { var self = this; var cfg = Config.data; rateTries = rateTries || 0; if (SCHEMA_READ_ACTIONS[action] && !sessionOverride) { var renewal = Session.recovery(); if (renewal) { return renewal.then(function () { return self._send(action, payload, isRetry, owner, sessionOverride, isResent, rateTries); }, function () { throw self.makeError('SESSION_EXPIRED', 'Your session has expired. Please sign in again.'); }); }; }; var sent = { action: action, session: sessionOverride || cfg.session, isOwn: !sessionOverride, isResent: !!isResent }; if (cfg.mock) { var mockFn = global.SQLStudioMockBackend; if (typeof mockFn !== 'function') { return Promise.reject(self.makeError('NETWORK', 'Mock backend is not loaded.')); }; var mockPromise; var mockStartedAt = Date.now(); try { mockPromise = mockFn(action, payload, sent.session); } catch (syncErr) { return Promise.reject(self.makeError('NETWORK', 'Mock backend threw: ' + syncErr.message)); }; return Promise.resolve(mockPromise).then(function (json) { if (json && typeof json.httpStatus === 'number') throw self._notJsonError(json.httpStatus, json.retryAfter); if (json) self.recordCall(action, json, owner); return self._handleResponse(json, sent); }, function (err) { throw self.makeError('NETWORK', 'Mock backend rejected: ' + (err && err.message ? err.message : err)); }).then(function (data) { logBackendCall(action, mockStartedAt, null); return data; }).catch(function (err) { var mockWaited = self._afterRateLimit(err, action, payload, owner, isRetry, sessionOverride, isResent, rateTries); if (mockWaited) return mockWaited; var mockAgain = self._resendAfterSessionError(err, payload, owner, isRetry, sent); if (mockAgain) return mockAgain; throw err; }); }; var timeoutMs = (action === 'runQuery' || action === 'finishSetup' || action === 'moveToDataExtension') ? RUNQUERY_TIMEOUT_MS : API_TIMEOUT_MS; var controller = (typeof AbortController !== 'undefined') ? new AbortController() : null; var timer = setTimeout(function () { if (controller) controller.abort(); }, timeoutMs); var fetchOpts = { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ action: action, session: sent.session, payload: payload }), credentials: 'same-origin' }; if (controller) fetchOpts.signal = controller.signal; var startedAt = Date.now(); return fetch(cfg.backendUrl, fetchOpts).then(function (res) { return res.text().then(function (text) { var json = null; try { json = text ? JSON.parse(text) : null; } catch (e) { json = null; }; if (json) Api.recordCall(action, json, owner); if (!json) throw self._notJsonError(res.status, res.headers.get('Retry-After')); return self._handleResponse(json, sent); }); }).catch(function (err) { if (err && typeof err.code === 'string') throw err; var isAbort = err && err.name === 'AbortError'; if (isAbort && action === 'runQuery') { var timeoutErr = self.makeError('NETWORK', 'No answer from Marketing Cloud Engagement within 150 seconds. The query may still be running there. Wait a minute before running it again.'); timeoutErr.timeout = true; throw timeoutErr; }; var networkErr = self.makeError('NETWORK', isAbort ? 'Marketing Cloud Engagement did not answer within ' + Math.round(timeoutMs / 1000) + ' seconds, so SQL Studio stopped waiting for this request.' : 'Network request failed: ' + (err && err.message ? err.message : String(err))); networkErr.timeout = isAbort; networkErr.isConnection = true; throw networkErr; }).then(function (data) { clearTimeout(timer); logBackendCall(action, startedAt, null); return data; }, function (err) { clearTimeout(timer); var waited = self._afterRateLimit(err, action, payload, owner, isRetry, sessionOverride, isResent, rateTries); if (waited) return waited; logBackendCall(action, startedAt, err); var again = self._resendAfterSessionError(err, payload, owner, isRetry, sent); if (again) return again; if (!isRetry && err && err.code === 'NETWORK' && !err.isTimeLimit && IDEMPOTENT_ACTIONS[action]) { return self._send(action, payload, true, owner, sessionOverride, isResent, rateTries); }; throw err; }); }, _notJsonError: function (status, retryAfter) { if (status === 429) { var limited = this.makeError('RATE_LIMITED', RATE_LIMITED_TEXT); limited.isRateLimited = true; limited.retryAfterMs = parseRetryAfter(retryAfter); return limited; }; var isTimeLimit = status === 408 || status === 504; var httpErr = this.makeError('NETWORK', isTimeLimit ? 'Marketing Cloud Engagement stopped the request at its time limit (HTTP ' + status + ').' : 'The backend did not answer with JSON (HTTP ' + status + ').'); httpErr.isTimeLimit = isTimeLimit; return httpErr; }, _afterRateLimit: function (err, action, payload, owner, isRetry, sessionOverride, isResent, rateTries) { var self = this; if (!err || err.code !== 'RATE_LIMITED') return null; if (!IDEMPOTENT_ACTIONS[action]) { if (!err.hasAdvice) { err.message += RATE_LIMITED_ADVICE; err.hasAdvice = true; }; return null; }; if (rateTries >= RATE_LIMIT_WAITS_MS.length) return null; var waitMs = rateLimitWaitMs(rateTries, err.retryAfterMs); if (typeof console !== 'undefined') console.info('[SQL Studio] ' + action + ' was refused with HTTP 429, so SQL Studio tries again in ' + fmtDuration(waitMs) + '.'); return new Promise(function (resolve) { setTimeout(resolve, waitMs); }).then(function () { return self._send(action, payload, isRetry, owner, sessionOverride, isResent, rateTries + 1); }); }, _resendAfterSessionError: function (err, payload, owner, isRetry, sent) { var self = this; var action = sent.action; var isSessionError = !!err && (err.code === 'SESSION_EXPIRED' || err.code === 'SESSION_INVALID'); if (!isSessionError || !sent.isOwn) return null; if (sent.isResent) { Session.noteUnanswered(action); return null; }; var sendAgain = function () { return self._send(action, payload, isRetry, owner, undefined, true); }; if (err.isSessionReplaced) { if (typeof console !== 'undefined') console.info('[SQL Studio] ' + action + ' was refused with a session that a renewal has replaced since, so it is sent again with the new one.'); return sendAgain(); }; var recovery = SCHEMA_READ_ACTIONS[action] ? Session.recovery() : null; if (!recovery) { Session.noteUnanswered(action); return null; }; if (typeof console !== 'undefined') console.info('[SQL Studio] ' + action + ' met the ended session, so it waits for the renewal and is sent again.'); return recovery.then(sendAgain, function () { throw err; }); }, _handleResponse: function (json, sent) { if (json && json.meta && typeof json.meta.apiCalls === 'number') { Shared.apiCalls = (Shared.apiCalls || 0) + json.meta.apiCalls; }; if (json && json.meta && typeof json.meta.wsproxyCalls === 'number') { Shared.wsproxyCalls = (Shared.wsproxyCalls || 0) + json.meta.wsproxyCalls; }; if (json && json.ok === true) return json.data; var error = (json && json.error) || {}; var err = this.makeError(error.code || 'INTERNAL', error.message || 'Unknown backend error.', error.details); if (err.code === 'SESSION_EXPIRED' || err.code === 'SESSION_INVALID') { var isReplaced = !!sent && sent.isOwn && sent.session !== Config.data.session; if (isReplaced && IDEMPOTENT_ACTIONS[sent.action] && !sent.isResent) { err.isSessionReplaced = true; } else { Session.expire(err.code); }; } else if (err.code === 'CONFIG_OUTDATED') { UI.showConfigOutdatedBanner(err.message); }; throw err; } }; var RENEW_FRAME_NAME = 'sqlstudio-renew'; var lastActivityAt = Date.now(); function isRenewFrame() { return global.name === RENEW_FRAME_NAME; }; function replaceSessionInUrl(sessionId) { try { var url = new URL(global.location.href); url.searchParams.set('s', sessionId); global.history.replaceState(null, '', url.toString()); } catch (e) { }; }; var SilentRenew = { _promise: null, run: function () { var self = this; if (self._promise) return self._promise; self._promise = new Promise(function (resolve, reject) { var frame = document.createElement('iframe'); frame.name = RENEW_FRAME_NAME; frame.setAttribute('sandbox', 'allow-scripts allow-same-origin allow-forms'); frame.setAttribute('aria-hidden', 'true'); frame.setAttribute('tabindex', '-1'); frame.title = 'SQL Studio session renewal'; frame.style.cssText = 'position:absolute;width:0;height:0;border:0;visibility:hidden;'; var timer = null; function finish(err, sessionId) { clearTimeout(timer); self._check = null; if (frame.parentNode) frame.parentNode.removeChild(frame); self._promise = null; if (err) reject(err); else resolve(sessionId); }; function check() { var href; try { href = frame.contentWindow.location.href; } catch (e) { return; }; if (!href || href === 'about:blank') return; var url; try { url = new URL(href); } catch (e) { return; }; if (url.origin !== global.location.origin) return; var sessionId = url.searchParams.get('s') || ''; if (url.pathname === global.location.pathname && SESSION_ID_RE.test(sessionId)) { finish(null, sessionId); return; }; var errorCode = url.searchParams.get('error'); finish(Api.makeError('RENEW_FAILED', errorCode ? 'Marketing Cloud Engagement refused the background sign-in (' + errorCode + ').' : 'The background sign-in ended on an unexpected page.')); }; self._check = check; timer = setTimeout(function () { finish(Api.makeError('RENEW_FAILED', 'The background sign-in did not finish within ' + Math.round(SILENT_RENEW_TIMEOUT_MS / 1000) + ' seconds.')); }, SILENT_RENEW_TIMEOUT_MS); frame.src = Config.data.backendUrl; document.body.appendChild(frame); }); return self._promise; }, frameArrived: function () { if (this._check) this._check(); } }; var Session = { data: { userName: '', userEmail: '', userKey: '', identitySource: null, mid: null, midSig: null, backendMid: null, stackKey: null, tokenSecondsLeft: 0, backendVersion: '', historyEnabled: false, snippetsEnabled: false, existingTargetsEnabled: false }, syncedAt: 0, _expired: false, _recovery: null, _renewPromise: null, _silentFailedAt: 0, _lastFailureWasHidden: false, _hasUnanswered: false, isRenewing: false, _hasAnswered: false, _bootError: null, _knownWaiters: [], boot: function () { var self = this; self.data.userName = Config.data.userName; self.data.userEmail = Config.data.userEmail; return Api.call('whoami', {}).catch(function (err) { var isSessionError = !!err && (err.code === 'SESSION_EXPIRED' || err.code === 'SESSION_INVALID'); var recovery = isSessionError ? self.recovery() : null; if (!recovery) throw err; return recovery.then(function () { return self.data; }, function () { throw err; }); }).then(function (data) { self.data = data || self.data; self.syncedAt = Date.now(); self._expired = false; var secondsLeft = self.secondsLeft(); if (secondsLeft !== null && typeof console !== 'undefined') { console.info('[SQL Studio] Session ends in ' + fmtDuration(secondsLeft * 1000) + '.'); }; self.logIdentitySource(); self._settleKnown(null); }, function (err) { self._settleKnown(err || new Error('whoami failed')); throw err; }); }, whenKnown: function () { var self = this; if (self._hasAnswered) return Promise.resolve(self.data); if (self._bootError) return Promise.reject(self._bootError); return new Promise(function (resolve, reject) { self._knownWaiters.push({ resolve: resolve, reject: reject }); }); }, _settleKnown: function (err) { if (err) { this._bootError = err; } else { this._hasAnswered = true; this._bootError = null; }; var waiters = this._knownWaiters; this._knownWaiters = []; for (var i = 0; i < waiters.length; i++) { if (err) waiters[i].reject(err); else waiters[i].resolve(this.data); }; }, isBackendBu: function () { var data = this.data || {}; var hasValue = function (value) { return value !== null && value !== undefined && String(value) !== ''; }; return hasValue(data.mid) && hasValue(data.backendMid) && String(data.mid) === String(data.backendMid); }, logIdentitySource: function () { if (typeof console === 'undefined') return; var source = this.data && this.data.identitySource; console.info('[SQL Studio] Your user id comes from ' + (source ? 'user.' + source : 'the sign-in, which did not say where from') + '.'); }, secondsLeft: function () { var reading = Number(this.data && this.data.tokenSecondsLeft); if (!this.syncedAt || !(reading > 0)) return null; return reading - Math.round((Date.now() - this.syncedAt) / 1000); }, renewSilently: function () { var self = this; if (self._renewPromise) return self._renewPromise; var startedAt = Date.now(); var wasHidden = !!document.hidden; var onVisibility = function () { if (document.hidden) wasHidden = true; }; document.addEventListener('visibilitychange', onVisibility); self._renewPromise = SilentRenew.run().then(function (sessionId) { return Api.callWithSession('whoami', {}, sessionId).then(function (answer) { var pageUserKey = self.data.userKey || Config.data.userKey || ''; var isOtherUser = !pageUserKey || !answer || answer.userKey !== pageUserKey; replaceSessionInUrl(sessionId); if (!isOtherUser) Config.data.session = sessionId; return { data: answer, isOtherUser: isOtherUser }; }); }).then(function (outcome) { var data = outcome.data; document.removeEventListener('visibilitychange', onVisibility); self._renewPromise = null; self._lastFailureWasHidden = false; if (outcome.isOtherUser) { self.isRenewing = true; global.location.reload(); return new Promise(function () {}); }; self.data = data || self.data; self.syncedAt = Date.now(); self._settleKnown(null); if (typeof console !== 'undefined') { var secondsLeft = self.secondsLeft(); console.info('[SQL Studio] Session renewed in the background in ' + fmtDuration(Date.now() - startedAt) + '.' + (secondsLeft !== null ? ' It now ends in ' + fmtDuration(secondsLeft * 1000) + '.' : '')); }; }, function (err) { document.removeEventListener('visibilitychange', onVisibility); if (document.hidden) wasHidden = true; self._renewPromise = null; self._lastFailureWasHidden = wasHidden; self._silentFailedAt = wasHidden ? 0 : Date.now(); if (typeof console !== 'undefined') { console.warn('[SQL Studio] Background session renewal failed after ' + fmtDuration(Date.now() - startedAt) + (wasHidden ? ', while the tab was in the background' : '') + ': ' + (err && err.message)); }; throw err; }); return self._renewPromise; }, maybeRenewSilently: function () { if (this._expired || this._renewPromise) return; var secondsLeft = this.secondsLeft(); if (secondsLeft === null || secondsLeft > SILENT_RENEW_AT_SECONDS) return; var isInUse = (Date.now() - lastActivityAt < SILENT_RENEW_IDLE_MS) || QueryTabs.hasRunInFlight(); if (!isInUse) return; if (this._silentFailedAt && Date.now() - this._silentFailedAt < SILENT_RENEW_RETRY_MS) return; this.renewSilently().catch(function () { /* logged; the next check tries again */ }); }, expire: function (reason) { if (this._expired) return; this._expired = true; var self = this; var hasJustFailed = !!self._silentFailedAt && Date.now() - self._silentFailedAt < SILENT_RENEW_RETRY_MS; if (reason === 'SESSION_ENDING' || hasJustFailed) { self._showExpiredDialog(reason); return; }; self._hasUnanswered = false; var removeRenewingToast = Toast.show('Your session had ended. SQL Studio is renewing it in the background.', 'warning', { sticky: true }); self._recovery = self.renewSilently().catch(function (err) { if (!self._lastFailureWasHidden) throw err; return whenPageVisible().then(function () { return self.renewSilently(); }); }).then(function () { removeRenewingToast(); self._recovery = null; self._expired = false; if (self._hasUnanswered) { Toast.show('Your session had ended, so SQL Studio renewed it. What you asked for meanwhile did not go through: try it again.', 'warning'); } else { Toast.show('Your session had ended, so SQL Studio renewed it.', 'success'); }; }, function (err) { removeRenewingToast(); self._recovery = null; self._showExpiredDialog(reason); throw err; }); self._recovery.catch(function () { /* the dialog has taken over */ }); }, noteUnanswered: function (action) { if (!SELF_RECOVERING_ACTIONS[action]) this._hasUnanswered = true; }, recovery: function () { return this._recovery; }, _showExpiredDialog: function (reason) { this._saveRestoreState(); UI.showSessionExpiredModal(reason); }, renew: function () { this._saveRestoreState(); this.isRenewing = true; global.location.href = Config.data.backendUrl; }, _saveRestoreState: function () { var snapshot = QueryTabs.snapshot(true); snapshot.scope = Session.data.userKey ? userScope() : null; snapshot.who = Session.data.userKey || Config.data.userKey || ''; writeJSON(LS.restore, snapshot); }, takeRestore: function () { var data = readJSON(LS.restore, null); safeRemove(LS.restore); if (!data) return null; if (Date.now() - (data.savedAt || 0) > RESTORE_MAX_AGE_MS) return null; if (!data.who || data.who !== Config.data.userKey) return null; return data; } }; function compareLowerNames(a, b) { return a.lname < b.lname ? -1 : a.lname > b.lname ? 1 : 0; }; function parentFolderSegments(t) { var segments = Array.isArray(t.folderPathSegments) ? t.folderPathSegments : String(t.folderPath || '').split(' / '); return segments.map(function (segment) { return (segment === null || segment === undefined) ? '' : String(segment).trim(); }).filter(function (name) { return !!name; }); }; var DE_TOP_FOLDER_ORDER = ['dataextension', 'salesforcedataextension', 'synchronizeddataextension', 'shared_data']; var PARENT_TOP_FOLDERS = { shared: { contentType: 'shared_data', name: 'Shared Items', folderTypes: ['shared_data', 'shared_dataextension', 'shared_salesforcedataextension'] }, synchronized: { contentType: 'synchronizeddataextension', name: 'Synchronized Data Extensions', folderTypes: ['synchronizeddataextension'] } }; function topFolderRank(contentType) { var index = DE_TOP_FOLDER_ORDER.indexOf(contentType); return index === -1 ? DE_TOP_FOLDER_ORDER.length : index; }; function isSystemTableName(name) { return String(name === null || name === undefined ? '' : name).charAt(0) === '_'; }; function buildDeTree(tables, folders) { var byId = {}; var nodes = []; var top = { key: 'top', name: '', folders: [], tables: [], count: 0 }; var unlisted = { key: 'unlisted', name: 'Not in a loaded folder', lname: 'not in a loaded folder', isMadeUp: true, folders: [], tables: [], count: 0 }; folders.forEach(function (folder) { if (!folder || folder.id === null || folder.id === undefined) return; var name = (folder.name === null || folder.name === undefined) ? '' : String(folder.name); var parentId = (folder.parentId === null || folder.parentId === undefined) ? '' : String(folder.parentId); var contentType = (folder.contentType === null || folder.contentType === undefined) ? '' : String(folder.contentType); var isTopFolder = parentId === '' || parentId === '0'; var node = { key: 'f' + folder.id, name: name, lname: name.toLowerCase(), parentId: parentId, contentType: contentType, rank: isTopFolder ? topFolderRank(contentType) : DE_TOP_FOLDER_ORDER.length, isStartOpen: isTopFolder && contentType === 'dataextension', folders: [], tables: [], count: 0 }; byId[String(folder.id)] = node; nodes.push(node); }); nodes.sort(compareLowerNames); var orphans = []; nodes.forEach(function (node) { var parent = byId[node.parentId] || null; if (!parent && node.parentId !== '' && node.parentId !== '0') orphans.push(node); var up = parent; var steps = 0; while (up && steps < 64) { up = byId[up.parentId] || null; steps += 1; }; if (up) parent = null; (parent || top).folders.push(node); }); var touched = []; var addFolder = function (parentNode, name, contentType, rank) { var node = { key: (parentNode === top ? 'p' : parentNode.key) + '/' + encodeURIComponent(name), name: name, lname: name.toLowerCase(), parentId: '', contentType: contentType, rank: rank, isStartOpen: false, folders: [], tables: [], count: 0 }; parentNode.folders.push(node); if (touched.indexOf(parentNode) === -1) touched.push(parentNode); return node; }; var findFolder = function (list, lname) { for (var i = 0; i < list.length; i++) { if (list[i].lname === lname) return list[i]; }; return null; }; var findTopOfType = function (contentType) { for (var i = 0; i < top.folders.length; i++) { if (top.folders[i].contentType === contentType && top.folders[i].rank < DE_TOP_FOLDER_ORDER.length) return top.folders[i]; }; return null; }; var takeOrphan = function (parentNode, lname, folderTypes, start) { for (var i = 0; i < orphans.length; i++) { var orphan = orphans[i]; if (orphan.lname !== lname || folderTypes.indexOf(orphan.contentType) === -1 || orphan === start) continue; orphans.splice(i, 1); top.folders.splice(top.folders.indexOf(orphan), 1); parentNode.folders.push(orphan); if (touched.indexOf(parentNode) === -1) touched.push(parentNode); return orphan; }; return null; }; var pathNodes = {}; var placeParentTable = function (t) { var kind = t.sharedKind === 'synchronized' ? 'synchronized' : 'shared'; var segments = parentFolderSegments(t); var pathKey = kind + '/' + segments.map(encodeURIComponent).join('/'); var cached = pathNodes[pathKey]; if (cached) { cached.tables.push(t); return; }; var kindTop = PARENT_TOP_FOLDERS[kind]; var rest = segments; var node = segments.length ? findFolder(top.folders, segments[0].toLowerCase()) : null; if (node) { rest = segments.slice(1); } else { node = findTopOfType(kindTop.contentType); if (!node) { var isAtTop = segments.length > 0 && segments[0].toLowerCase() === kindTop.name.toLowerCase(); node = addFolder(top, isAtTop ? segments[0] : kindTop.name, kindTop.contentType, topFolderRank(kindTop.contentType)); if (isAtTop) rest = segments.slice(1); }; }; var start = node; rest.forEach(function (name) { var lname = name.toLowerCase(); node = findFolder(node.folders, lname) || takeOrphan(node, lname, kindTop.folderTypes, start) || addFolder(node, name, '', 0); }); pathNodes[pathKey] = node; node.tables.push(t); }; var entries = []; tables.forEach(function (t) { if (isSystemTableName(t.name)) return; entries.push({ lname: String(t.name || '').toLowerCase(), table: t }); }); entries.sort(compareLowerNames); entries.forEach(function (entry) { if (entry.table.isShared) { placeParentTable(entry.table); return; }; (byId[String(entry.table.categoryId)] || unlisted).tables.push(entry.table); }); touched.forEach(function (node) { node.folders.sort(compareLowerNames); }); top.folders.sort(function (a, b) { return (a.rank - b.rank) || compareLowerNames(a, b); }); var count = function (node) { node.count = node.tables.length; node.folders.forEach(function (child) { node.count += count(child); }); return node.count; }; count(top); unlisted.count = unlisted.tables.length; return { top: top, unlisted: unlisted.count ? unlisted : null }; }; var Schema = { dataViews: [], _dataViewNames: {}, deList: [], byName: {}, _allTables: [], _tableList: [], _fieldPromises: {}, _fieldFailures: {}, /* tableKey -> the error of its failed field load, for the session, until Reload (see autoFields) */ _detailQueue: {}, /* tableKey -> table, waiting for the next getDataExtensionDetails call (_queueDetails) */ _detailTimer: null, _detailFailures: {}, /* tableKey -> true for a details read that failed, for the session, until Reload */ _detailsInFlight: {}, /* tableKey -> true while the getDataExtensionDetails call that asks for it is out (_flushDetails) */ _detailWaits: {}, /* tableKey -> { table, promise, settle }: what waits for a table's details (details) */ _isDetailsUnsupported: false, /* an older Backend has no getDataExtensionDetails: on-use details stop for the session */ _deListSaveTimer: null, _deListPromise: null, _deListLoading: false, _deListError: null, _deListSavedAt: null, _isParentListCapped: false, _hasParentList: false, _isParentListRead: false, _extrasStore: null, _deFolders: null, _deFolderState: 'none', _deLoadNumber: 0, _deTree: null, _workspacePromise: null, _foldersPromise: null, _queryFolderList: null, foldersById: {}, init: function () { this.dataViews = (SQLData.dataViews || []).map(function (dv) { return { name: dv.name, key: dv.name, kind: 'dataview', folderPath: 'System Data Views / ' + dataViewGroup(dv.name), group: dataViewGroup(dv.name), description: dv.description || '', docs: dv.docs, officialDocs: dv.officialDocs, fields: dv.fields || [], alias: dv.alias || '', fullQuery: dv.fullQuery || null }; }); this._dataViewNames = {}; var names = this._dataViewNames; this.dataViews.forEach(function (dv) { names[String(dv.name).toLowerCase()] = true; }); this._rebuildIndex(); }, _rebuildIndex: function () { var self = this; this._deTree = null; this._markSynchronized(); this._allTables = this.dataViews.concat(this.deList); this._tableList = this.dataViews.concat(this.deList.filter(function (t) { return !self.isDataViewName(t.name); })); this.byName = Tools.buildIndex({ tables: this._allTables }).byName; }, _markSynchronized: function () { var types = {}; (this._deFolders || []).forEach(function (f) { if (f && f.id !== null && f.id !== undefined) types[String(f.id)] = String(f.contentType || ''); }); this.deList.forEach(function (t) { if (!t) return; var isInSyncedFolder = !t.isShared && t.categoryId !== null && t.categoryId !== undefined && types[String(t.categoryId)] === PARENT_TOP_FOLDERS.synchronized.contentType; t.isSynchronized = t.sharedKind === 'synchronized' || isInSyncedFolder; }); }, allTables: function () { return this._tableList; }, getIndex: function () { var self = this; var isComplete = this.isDeListComplete(); return { tables: this._allTables, byName: this.byName, isDataExtensionListLoaded: this._deListSavedAt !== null, isDataExtensionListComplete: isComplete, dataExtensionListLoadedAt: this._deListSavedAt, isParentDataExtensionListLoaded: isComplete && this.isParentListLoaded(), isDetailsPending: function (table) { return self.isDetailsPending(table); } }; }, isDeListComplete: function () { if (this._deListSavedAt === null || this._deListLoading || this._deListError) return false; if (this.isChildSession() && (!this._hasParentList || this._isParentListCapped)) return false; return true; }, isParentListLoaded: function () { return this.isChildSession() && this._isParentListRead && !this._isParentListCapped; }, isDeListLoading: function () { return this._deListLoading; }, isListDetailsLoading: function () { return this._isListDetailsLoading === true; }, deListError: function () { return this._deListError; }, deListSavedAt: function () { return this._deListSavedAt; }, isChildSession: function () { return typeof Session.isBackendBu === 'function' && !Session.isBackendBu(); }, tableKey: function (t) { return t.isShared ? 'ENT.' + t.key : t.key; }, parentCount: function () { return this.deList.filter(function (t) { return t.isShared; }).length; }, isDataViewName: function (name) { return Object.prototype.hasOwnProperty.call(this._dataViewNames, String(name === null || name === undefined ? '' : name).toLowerCase()); }, searchableList: function () { var self = this; return this.deList.filter(function (t) { return !self.isDataViewName(t.name); }); }, isParentListCapped: function () { return this._isParentListCapped; }, deTree: function () { if (this._deFolderState !== 'loaded' || !this._deFolders || !this._deFolders.length) return null; if (!this._deTree) this._deTree = buildDeTree(this.deList, this._deFolders); return this._deTree; }, areDeFoldersLoading: function () { return this._deFolderState === 'loading'; }, hasDeFolders: function () { return this._deFolderState === 'loaded' && Array.isArray(this._deFolders); }, deFolderPaths: function () { var folders = (this._deFolderState === 'loaded' && Array.isArray(this._deFolders)) ? this._deFolders : []; var byId = {}; folders.forEach(function (f) { if (f && f.id !== null && f.id !== undefined) byId[String(f.id)] = f; }); var paths = {}; Object.keys(byId).forEach(function (id) { var names = []; var seen = {}; var node = byId[id]; while (node && !seen[String(node.id)]) { seen[String(node.id)] = true; names.unshift(String(node.name === null || node.name === undefined ? '' : node.name)); node = (node.parentId === null || node.parentId === undefined) ? null : byId[String(node.parentId)]; }; paths[id] = names.join(' / '); }); return paths; }, ownFolderChoices: function () { var folders = (this._deFolderState === 'loaded' && Array.isArray(this._deFolders)) ? this._deFolders : []; var byId = {}; folders.forEach(function (f) { if (f && f.id !== null && f.id !== undefined) byId[String(f.id)] = f; }); var isTop = function (f) { return f.parentId === null || f.parentId === undefined || String(f.parentId) === '' || String(f.parentId) === '0'; }; var own = folders.filter(function (f) { return f && f.id !== null && f.id !== undefined && String(f.contentType) === 'dataextension'; }); var topFolder = own.filter(isTop)[0] || null; var namesOf = function (f) { var names = []; var seen = {}; var node = f; while (node && !seen[String(node.id)]) { seen[String(node.id)] = true; names.unshift(String(node.name === null || node.name === undefined ? '' : node.name)); node = isTop(node) ? null : byId[String(node.parentId)]; }; return names; }; var choices = own.filter(function (f) { return !isTop(f); }).map(function (f) { var names = namesOf(f); return { id: f.id, path: names.join(' / '), name: names[names.length - 1], parent: names.slice(0, -1).join(' / ') }; }); choices.sort(function (a, b) { return a.path.toLowerCase() < b.path.toLowerCase() ? -1 : (a.path.toLowerCase() > b.path.toLowerCase() ? 1 : 0); }); return { root: topFolder ? { id: topFolder.id, name: String(topFolder.name) } : null, choices: choices }; }, addCreatedDataExtension: function (made, model) { var lowerKey = String(made.key).toLowerCase(); var isKnown = this.deList.some(function (t) { return !!t && !t.isShared && String(t.key).toLowerCase() === lowerKey; }); if (isKnown) return; var folders = this.ownFolderChoices(); var categoryId = model.folderId !== '' ? Number(model.folderId) : (folders.root ? folders.root.id : null); var sendableField = model.sendable.isOn ? saveSendableField(model) : null; var entry = ownListEntry({ name: made.name, key: made.key, objectId: made.objectId, categoryId: categoryId, isSendable: !!sendableField, sendableField: sendableField ? sendableField.name.trim() : null, sendableSubscriberField: sendableField ? (model.sendable.kind === 'emailAddress' ? 'Email Address' : '_SubscriberKey') : null }); entry.hasDetails = true; entry.fields = model.fields.map(function (f, i) { return { name: f.name.trim(), type: f.type, length: f.type === 'Text' ? (f.length.trim() !== '' ? parseInt(f.length, 10) : null) : (f.type === 'Decimal' ? parseInt(f.precision, 10) || SAVE_DECIMAL_DEFAULT_PRECISION : null), scale: f.type === 'Decimal' ? (f.scale.trim() !== '' ? parseInt(f.scale, 10) : SAVE_DECIMAL_DEFAULT_SCALE) : null, isPrimaryKey: f.isPrimaryKey, isNullable: f.isNullable, ordinal: i + 1, defaultValue: f.defaultValue !== '' ? f.defaultValue : null }; }); this.deList.push(entry); this._rebuildIndex(); this._scheduleDeListSave(); UI.renderSidebarTree(); this._relintForList(); }, _readExtrasStore: function () { if (this._extrasStore === null) { var stored = readJSON(LS.deExtras, null); var isCurrent = !!stored && typeof stored === 'object' && stored.version === APP_VERSION; var names = []; var unnamed = {}; var isDropped = false; if (isCurrent && Array.isArray(stored.names)) { stored.names.forEach(function (name) { var text = String(name); if (DE_LIST_EXTRAS.indexOf(text) === -1 || names.indexOf(text) !== -1) { isDropped = true; } else { names.push(text); }; }); }; if (isCurrent && stored.unnamed && typeof stored.unnamed === 'object') { Object.keys(stored.unnamed).forEach(function (name) { var storedAt = Number(stored.unnamed[name]); if (DE_LIST_EXTRAS.indexOf(name) !== -1 && isFinite(storedAt)) { unnamed[name] = storedAt; } else { isDropped = true; }; }); }; this._extrasStore = { names: names, unnamed: unnamed }; if (isDropped) { writeJSON(LS.deExtras, { version: APP_VERSION, names: names, unnamed: unnamed }); }; }; return this._extrasStore; }, _readExtrasUnavailable: function () { var store = this._readExtrasStore(); var now = Date.now(); var names = store.names.slice(); Object.keys(store.unnamed).forEach(function (name) { var age = now - store.unnamed[name]; if (age >= 0 && age < DE_EXTRAS_UNNAMED_MS && names.indexOf(name) === -1) names.push(name); }); return names; }, _rememberExtrasUnavailable: function (names, said, unnamed, isRestart) { var store = this._readExtrasStore(); var skipped = this._readExtrasUnavailable(); var unnamedNames = Array.isArray(unnamed) ? unnamed.map(String) : []; var now = Date.now(); var addedNamed = []; var addedUnnamed = []; (names || []).forEach(function (name) { var text = String(name); if (skipped.indexOf(text) !== -1 || DE_LIST_EXTRAS.indexOf(text) === -1) return; if (unnamedNames.indexOf(text) !== -1) { store.unnamed[text] = now; addedUnnamed.push(text); } else { store.names.push(text); delete store.unnamed[text]; addedNamed.push(text); }; }); if (!addedNamed.length && !addedUnnamed.length) return; writeJSON(LS.deExtras, { version: APP_VERSION, names: store.names, unnamed: store.unnamed }); if (typeof console === 'undefined') return; var quote = said ? ' It said: ' + String(said) : ''; if (addedNamed.length) console.info('[SQL Studio] Marketing Cloud Engagement did not accept ' + addedNamed.join(', ') + ' in the Data Extension list, so SQL Studio asks without ' + (addedNamed.length === 1 ? 'it' : 'them') + ' until its next version.' + quote); var them = addedUnnamed.length === 1 ? 'it' : 'them'; if (addedUnnamed.length && isRestart) { console.info('[SQL Studio] Marketing Cloud Engagement refused a later page of the Data Extension list, which asked for ' + addedUnnamed.join(', ') + ', so SQL Studio loaded the list again without ' + them + ' and asks without ' + them + ' for a day.' + quote); } else if (addedUnnamed.length) { console.info('[SQL Studio] Marketing Cloud Engagement refused the Data Extension list with ' + addedUnnamed.join(', ') + ' with an error that names no property, so SQL Studio asks without ' + them + ' for a day.' + quote); }; }, _showPartialList: function (loadNumber, entries) { if (loadNumber !== this._deLoadNumber || this._deListSavedAt) return; this.deList = entries.slice(); this._rebuildIndex(); UI.renderSidebarTree(); }, _loadListDetails: function (loadNumber, count) { var self = this; var skipped = self._readExtrasUnavailable(); if (skipped.length >= DE_LIST_EXTRAS.length) return Promise.resolve(); var payload = skipped.length ? { skipExtras: skipped.slice() } : {}; var startedAt = Date.now(); self._isListDetailsLoading = true; self._listDetailsLoadNumber = loadNumber; var queueWaiting = function () { var waiting = self._detailsWaiting || []; self._detailsWaiting = []; waiting.forEach(function (t) { if (t.hasDetails !== true) self._queueDetails(t); }); }; return Api.call('listDataExtensions', payload).then(function (res) { if (self._listDetailsLoadNumber === loadNumber) self._isListDetailsLoading = false; var left = Array.isArray(res.extrasUnavailable) ? res.extrasUnavailable.map(String) : []; if (left.length) self._rememberExtrasUnavailable(left, res.extrasError, res.extrasUnnamed, false); if (loadNumber !== self._deLoadNumber) { queueWaiting(); self._settleDetailWaits(); return; }; var byKey = {}; (res.items || []).forEach(function (item) { byKey[String(item.key).toLowerCase()] = item; }); self.deList.forEach(function (t) { var item = (t && !t.isShared) ? byKey[String(t.key).toLowerCase()] : null; if (item) applyDeDetails(t, item); }); self._saveDeList(); UI.renderSidebarTree(); queueWaiting(); self._settleDetailWaits(); if (typeof console !== 'undefined') console.info('[SQL Studio] The details of the list\'s ' + count + ' Data Extensions came in ' + fmtSeconds(Date.now() - startedAt) + '.'); }, function (err) { if (self._listDetailsLoadNumber === loadNumber) self._isListDetailsLoading = false; queueWaiting(); self._settleDetailWaits(); if (typeof console !== 'undefined') console.info('[SQL Studio] The details of the list\'s ' + count + ' Data Extensions did not come, after ' + fmtSeconds(Date.now() - startedAt) + ' (' + (err && err.message ? err.message : String(err)) + '). Each Data Extension reads its own when it is used.'); }); }, _queueDetails: function (table) { var self = this; if (!self._canReadDetails(table)) return; if (self._isListDetailsLoading && !table.isShared) { if (!self._detailsWaiting) self._detailsWaiting = []; if (self._detailsWaiting.indexOf(table) === -1) self._detailsWaiting.push(table); return; }; var lookupKey = self.tableKey(table); if (self._detailsInFlight[lookupKey]) return; self._detailQueue[lookupKey] = table; if (self._detailTimer === null) self._detailTimer = setTimeout(function () { self._flushDetails(); }, DE_DETAILS_GATHER_MS); }, _canReadDetails: function (table) { if (!table || table.kind !== 'de' || table.hasDetails === true || this._isDetailsUnsupported) return false; if (this._readExtrasUnavailable().length >= DE_LIST_EXTRAS.length) return false; return !this._detailFailures[this.tableKey(table)]; }, isDetailsPending: function (table) { return !!table && table.kind === 'de' && table.isSendable === true && table.hasDetails === false && this._canReadDetails(table); }, details: function (table) { var self = this; if (!self.isDetailsPending(table)) return Promise.resolve(); var lookupKey = self.tableKey(table); var wait = self._detailWaits[lookupKey]; if (!wait) { wait = { table: table }; wait.promise = new Promise(function (resolve) { wait.settle = resolve; }); self._detailWaits[lookupKey] = wait; }; self._queueDetails(table); return wait.promise; }, _settleDetailWaits: function (keys) { var self = this; Object.keys(self._detailWaits).forEach(function (key) { var wait = self._detailWaits[key]; if ((keys || []).indexOf(key) === -1 && self.isDetailsPending(wait.table)) return; delete self._detailWaits[key]; wait.settle(); }); }, _flushDetails: function () { var self = this; self._detailTimer = null; var keys = Object.keys(self._detailQueue).slice(0, DE_DETAILS_BATCH_MAX); if (!keys.length) return Promise.resolve(); var tables = {}; keys.forEach(function (key) { tables[key] = self._detailQueue[key]; delete self._detailQueue[key]; self._detailsInFlight[key] = true; }); var ended = function () { keys.forEach(function (key) { delete self._detailsInFlight[key]; }); self._settleDetailWaits(keys); UI._scheduleJoinRefresh(); }; if (Object.keys(self._detailQueue).length) self._detailTimer = setTimeout(function () { self._flushDetails(); }, 0); var skipped = self._readExtrasUnavailable(); var payload = { keys: keys }; if (skipped.length) payload.skipExtras = skipped.slice(); var startedAt = Date.now(); return Api.call('getDataExtensionDetails', payload).then(function (res) { var left = Array.isArray(res.extrasUnavailable) ? res.extrasUnavailable.map(String) : []; if (left.length) self._rememberExtrasUnavailable(left, res.extrasError, res.extrasUnnamed, false); (res.items || []).forEach(function (item) { var table = tables[String(item.key)]; if (table) applyDeDetails(table, item); }); (res.missing || []).forEach(function (key) { if (tables[String(key)]) tables[String(key)].hasDetails = true; }); self._scheduleDeListSave(); ended(); if (typeof console !== 'undefined') console.info('[SQL Studio] The details of ' + keys.length + ' Data Extension' + (keys.length === 1 ? '' : 's') + ' came in ' + fmtSeconds(Date.now() - startedAt) + '.'); }, function (err) { if (err && err.code === 'UNKNOWN_ACTION') { self._isDetailsUnsupported = true; self._detailQueue = {}; } else if (!err || (err.code !== 'SESSION_EXPIRED' && err.code !== 'SESSION_INVALID')) { keys.forEach(function (key) { self._detailFailures[key] = true; }); }; ended(); }); }, _scheduleDeListSave: function () { var self = this; if (self._deListSaveTimer !== null) return; self._deListSaveTimer = setTimeout(function () { self._deListSaveTimer = null; if (self._deListSavedAt && !self._deListLoading) { self._saveDeList(); UI.renderSidebarTree(); }; }, DE_LIST_SAVE_DELAY_MS); }, _saveDeList: function () { var isFolderAnswerKnown = this._deFolderState === 'loaded' || this._deFolderState === 'failed'; var record = { savedAt: this._deListSavedAt, list: packDeEntries(this.deList), folders: this._deFolderState === 'loaded' ? this._deFolders : null, hasFolders: isFolderAnswerKnown, hasExtras: true, hasParentList: this._hasParentList, isParentCapped: this._isParentListCapped, isParentRead: this._isParentListRead }; safeRemove(legacyDeListKey()); if (writeJSON(deListKey(), record)) return; safeRemove(deListKey()); record.list = packDeEntries(this.deList.map(function (t) { return (t && t.description) ? Object.assign({}, t, { description: '', hasDetails: false }) : t; })); var isKept = writeJSON(deListKey(), record); if (this._isCacheSizeLogged || typeof console === 'undefined') return; this._isCacheSizeLogged = true; console.info('[SQL Studio] The Data Extension list of ' + this.deList.length + ' is too large for the browser\'s storage, so ' + (isKept ? 'its cache leaves out the descriptions, which load again on use.' : 'it loads again on each page load.')); }, _takeCachedFolders: function (cached) { if (!cached.hasFolders) return false; this._deFolders = Array.isArray(cached.folders) ? cached.folders : null; this._deFolderState = this._deFolders ? 'loaded' : 'failed'; this._deTree = null; return true; }, _loadDeFolders: function (loadNumber) { var self = this; self._deFolders = null; self._deFolderState = 'loading'; self._deTree = null; var settle = function (folders) { if (loadNumber !== self._deLoadNumber) return; self._deFolders = folders; self._deFolderState = folders ? 'loaded' : 'failed'; self._deTree = null; self._markSynchronized(); if (!self._deListLoading && !self._deListError && self._deListSavedAt) self._saveDeList(); UI.renderSidebarTree(); if (UI._saveAsTargetRefresh) UI._saveAsTargetRefresh(); if (UI._saveTargetRefresh) UI._saveTargetRefresh(); if (UI._saveFolderRefresh) UI._saveFolderRefresh(); }; Api.call('listFolders', { contentTypes: DE_FOLDER_TYPES }).then(function (res) { settle((res.items || []).map(function (f) { return { id: f.id, parentId: f.parentId, name: f.name, contentType: f.contentType }; })); }, function (err) { if (loadNumber === self._deLoadNumber && typeof console !== 'undefined') console.info('[SQL Studio] The Data Extension folders did not load' + (err && err.message ? ' (' + err.message + ')' : '') + ', so the sidebar lists Data Extensions by search only until the list loads again.'); settle(null); }); }, _relintForList: function () { if (Editor.mode) Editor.lintNow(); }, loadDataExtensionList: function (isForced) { var self = this; if (!isForced && self._deListPromise) return self._deListPromise; if (isForced) { self._fieldFailures = {}; self._detailFailures = {}; }; if (!Session._hasAnswered) { self._deListLoading = true; self._deListError = null; UI.renderSidebarTree(); var waiting = Session.whenKnown().then(function () { if (self._deListPromise !== waiting) return self._deListPromise; self._deListPromise = null; return self.loadDataExtensionList(isForced); }, function (err) { if (self._deListPromise === waiting) { self._deListPromise = null; self._deListLoading = false; self._deListError = err; UI.renderSidebarTree(); }; throw err; }); self._deListPromise = waiting; return waiting; }; var isChild = self.isChildSession(); var ownCache = null; self._deLoadNumber += 1; var loadNumber = self._deLoadNumber; if (!isForced) { var cached = readJSON(deListKey(), null) || readJSON(legacyDeListKey(), null); if (cached && cached.list) { cached.items = unpackDeEntries(cached.list); } else if (cached && Array.isArray(cached.items)) { cached.items.forEach(function (t) { if (t && t.kind === 'de' && !t.isShared && t.hasDetails === undefined) t.hasDetails = true; }); }; var isCacheFresh = !!cached && !!cached.savedAt && cached.hasExtras === true && Date.now() - cached.savedAt < DE_LIST_CACHE_MS; var isCacheUsable = isCacheFresh && (!isChild || !!cached.hasParentList); if (isCacheFresh && !isCacheUsable) ownCache = cached; if (isCacheUsable) { self.deList = cached.items || []; self._deListSavedAt = cached.savedAt; self._hasParentList = !!cached.hasParentList; self._isParentListCapped = !!cached.isParentCapped; self._isParentListRead = cached.isParentRead === true; self._deListError = null; self._deListLoading = false; if (!self._takeCachedFolders(cached)) self._loadDeFolders(loadNumber); self._rebuildIndex(); UI.renderSidebarTree(); self._relintForList(); self._deListPromise = Promise.resolve(self.deList); return self._deListPromise; }; }; self._deListLoading = true; self._deListError = null; if (!ownCache || !self._takeCachedFolders(ownCache)) self._loadDeFolders(loadNumber); UI.renderSidebarTree(); UI.syncProblemActions(); var pages = 0; var listed = []; var loadPage = function (requestId) { pages += 1; var payload = requestId ? { requestId: requestId } : { skipExtras: DE_LIST_EXTRAS.slice() }; return Api.call('listDataExtensions', payload).then(function (res) { listed = listed.concat((res.items || []).map(ownListEntry)); if (res.nextRequestId && pages < 40) { self._showPartialList(loadNumber, listed); return loadPage(res.nextRequestId); }; return listed; }); }; var loadParentList = function () { if (!isChild) { return Promise.resolve({ entries: [], isCapped: false, hasParentList: false, isRead: false }); }; return Api.call('listParentDataExtensions', {}).then(function (res) { return { entries: (res.items || []).map(function (item) { return { name: item.name, key: item.key, kind: 'de', objectId: item.objectId || null, categoryId: item.categoryId || null, isSendable: false, sendableField: null, sendableSubscriberField: null, templateKey: null, description: '', isShared: true, folderPath: item.folderPath || '', folderPathSegments: Array.isArray(item.folderPathSegments) ? item.folderPathSegments.map(String) : null, sharedKind: item.kind === 'synchronized' ? 'synchronized' : 'shared', fields: null }; }), isCapped: !!res.isCapped, hasParentList: true, isRead: (res.items || []).length > 0 }; }, function (err) { return { entries: [], isCapped: false, hasParentList: !!err && err.code === 'UNKNOWN_ACTION', isRead: false }; }); }; var loadOwnList = function () { if (ownCache) { return Promise.resolve((ownCache.items || []).filter(function (t) { return !!t && !t.isShared; })); }; return loadPage(null); }; self._deListPromise = loadOwnList().then(function (ownItems) { return loadParentList().then(function (parent) { return { ownItems: ownItems, parent: parent }; }); }).then(function (loaded) { var previous = {}; (self.deList || []).forEach(function (t) { if (t && t.hasDetails === true) previous[(t.isShared ? 'ENT.' : '') + String(t.key).toLowerCase()] = t; }); loaded.ownItems.concat(loaded.parent.entries).forEach(function (t) { var before = (t && t.hasDetails !== true) ? previous[(t.isShared ? 'ENT.' : '') + String(t.key).toLowerCase()] : null; if (before) applyDeDetails(t, before); }); self.deList = loaded.ownItems.concat(loaded.parent.entries); self._hasParentList = loaded.parent.hasParentList; self._isParentListCapped = loaded.parent.isCapped; self._isParentListRead = loaded.parent.isRead; self._deListLoading = false; self._deListSavedAt = ownCache ? ownCache.savedAt : Date.now(); self._saveDeList(); self._rebuildIndex(); var ownCount = loaded.ownItems.length; if (!ownCache && pages === 1 && ownCount > 0 && ownCount <= DE_LIST_DETAILS_MAX) self._loadListDetails(loadNumber, ownCount); UI.renderSidebarTree(); self._relintForList(); return self.deList; }).catch(function (err) { self._deListLoading = false; self._deListError = err; self._deListPromise = null; /* allow Retry/the next trigger to try again */ UI.renderSidebarTree(); UI.syncProblemActions(); throw err; }); return self._deListPromise; }, ensureFieldsFor: function (name) { var self = this; var existing = Tools.lookupTable(self.byName, name); if (existing) return self.fields(existing); return self.loadDataExtensionList().then(function () { var found = Tools.lookupTable(self.byName, name); return found ? self.fields(found) : null; }).catch(function () { return null; }); }, fields: function (tableRef) { var self = this; var table = (tableRef && typeof tableRef === 'object') ? tableRef : Tools.lookupTable(self.byName, String(tableRef)); if (!table) return Promise.resolve(null); if (table.fields) return Promise.resolve(table.fields); var lookupKey = self.tableKey(table); if (self._fieldPromises[lookupKey]) return self._fieldPromises[lookupKey]; self._queueDetails(table); var p = Api.call('getDataExtensionFields', { key: lookupKey }).then(function (res) { table.fields = res.fields || []; delete self._fieldPromises[lookupKey]; delete self._fieldFailures[lookupKey]; UI._scheduleJoinRefresh(); return table.fields; }).catch(function (err) { delete self._fieldPromises[lookupKey]; if (!err || (err.code !== 'SESSION_EXPIRED' && err.code !== 'SESSION_INVALID')) self._fieldFailures[lookupKey] = err; throw err; }); self._fieldPromises[lookupKey] = p; return p; }, autoFields: function (tableRef) { var table = (tableRef && typeof tableRef === 'object') ? tableRef : Tools.lookupTable(this.byName, String(tableRef)); if (table && !table.fields && this.hasFieldLoadFailed(table)) return Promise.reject(this._fieldFailures[this.tableKey(table)]); return this.fields(table || tableRef); }, hasFieldLoadFailed: function (table) { return !!table && Object.prototype.hasOwnProperty.call(this._fieldFailures, this.tableKey(table)); }, workspace: function () { var self = this; if (self._workspacePromise) return self._workspacePromise; var cached = readJSON(workspaceKey(), null); var cacheMs = cached && cached.workspace && cached.workspace.warnings ? WORKSPACE_WARNING_CACHE_MS : WORKSPACE_CACHE_MS; if (cached && cached.v === WORKSPACE_CACHE_VERSION && cached.savedAt && Date.now() - cached.savedAt < cacheMs) { self._workspacePromise = Promise.resolve(cached.workspace); self._isWorkspaceCached = true; logWorkspace(cached.workspace); return self._workspacePromise; }; if (self._workspaceError && Date.now() - self._workspaceFailedAt < WORKSPACE_RETRY_MS) { return Promise.reject(self._workspaceError); }; self._workspacePromise = Api.call('getWorkspace', {}).then(function (ws) { writeJSON(workspaceKey(), { v: WORKSPACE_CACHE_VERSION, savedAt: Date.now(), workspace: ws }); self._isWorkspaceCached = false; logWorkspace(ws); return ws; }).catch(function (err) { self._workspacePromise = null; self._workspaceError = err; self._workspaceFailedAt = Date.now(); throw err; }); return self._workspacePromise; }, forgetWorkspace: function () { var wasCached = !!this._isWorkspaceCached; safeRemove(workspaceKey()); this._workspacePromise = null; this._isWorkspaceCached = false; return wasCached; }, queryFolders: function () { var self = this; if (self._foldersPromise) return self._foldersPromise; self._foldersPromise = Api.call('listFolders', { contentTypes: ['queryactivity'] }).then(function (res) { var map = {}; (res.items || []).forEach(function (f) { map[String(f.id)] = f; }); self.foldersById = map; function path(categoryId) { var parts = [], id = categoryId, guard = 0; while (id !== null && id !== undefined && map[String(id)] && guard < 25) { var f = map[String(id)]; parts.unshift(f.name); id = f.parentId; guard++; }; return parts.join(' / '); }; var folders = []; for (var id in map) { if (!Object.prototype.hasOwnProperty.call(map, id)) continue; if (map[id].contentType === 'queryactivity') folders.push({ id: map[id].id, name: map[id].name, path: path(map[id].id), isTop: !map[String(map[id].parentId)], parentId: map[id].parentId, parentPath: path(map[id].parentId) }); }; folders.sort(function (a, b) { return a.path < b.path ? -1 : a.path > b.path ? 1 : 0; }); self._queryFolderPaths = {}; folders.forEach(function (f) { self._queryFolderPaths[String(f.id)] = f.path; }); self._queryFolderList = folders; return folders; }).catch(function (err) { self._foldersPromise = null; throw err; }); return self._foldersPromise; }, queryFolderPath: function (categoryId) { if (categoryId === null || categoryId === undefined || !this._queryFolderPaths) return ''; return this._queryFolderPaths[String(categoryId)] || ''; }, queryFolderChoices: function () { var folders = Array.isArray(this._queryFolderList) ? this._queryFolderList : []; var tops = folders.filter(function (f) { return f.isTop; }); var top = tops.length === 1 ? tops[0] : null; var choices = folders.filter(function (f) { return !top || f.id !== top.id; }).map(function (f) { return { id: f.id, path: f.path || String(f.name), name: String(f.name), parent: f.parentPath || '' }; }); choices.sort(function (a, b) { return a.path.toLowerCase() < b.path.toLowerCase() ? -1 : (a.path.toLowerCase() > b.path.toLowerCase() ? 1 : 0); }); return { root: top ? { id: top.id, name: String(top.name) } : null, choices: choices }; } }; var Editor = { mode: null, /* 'monaco' | 'textarea' */ monaco: null, instance: null, model: null, textarea: null, container: null, _ready: null, _lastFindings: [], _lintedText: null, /* the text _lastFindings were computed on (see isLintCurrent) */ _onChangeCbs: [], _fieldAutoLoadWaits: {}, /* lowercased table key -> true while _loadReferencedFields waits on its fields (section 7.3) */ _deListLoadTriggered: false, _joinLabels: {}, /* label -> true for each recommended join the completion has offered, so the details panel can tell a join's text from any other item's (section 8.5) */ _isJoinListStale: false, /* true while the last list of recommended joins was built without the fields of a Data Extension it waits for */ _onlyJoinsAt: null, /* the offset where the space typed after ON, or after the = of a condition, asked for the recommended joins alone (section 8.4), while the cursor stays there and a join is there to show or on its way, so a list asked for again there stays that list */ _suggestDetailsMemory: null, /* the user's own choice for the details panel, kept in memory for a browser whose storage fails (section 8.5) */ init: function (container, initialValue) { this.container = container; var self = this; this.onDidChangeContent(debounce(function (text) { self._loadReferencedFields(text); }, DE_FIELD_AUTOLOAD_DEBOUNCE_MS)); this.onDidChangeContent(function () { UI._scheduleJoinRefresh(); }); this._ready = new Promise(function (resolve) { var isSettled = false; var timer = setTimeout(function () { if (isSettled) return; isSettled = true; self._initTextarea(container, initialValue); UI.showBanner('Monaco failed to load within 15 seconds (the CDN may be blocked by a corporate policy). Using a plain text editor instead. Run and Save still work.', 'warning'); resolve(); }, MONACO_TIMEOUT_MS); function tryRequire(attemptsLeft) { if (isSettled) return; if (typeof global.require !== 'function') { if (attemptsLeft <= 0) return; /* let the 15s timeout fire */ setTimeout(function () { tryRequire(attemptsLeft - 1); }, 200); return; }; var BASE = Config.data.monacoBase; try { global.require.config({ paths: { vs: BASE } }); global.MonacoEnvironment = { getWorkerUrl: function () { return URL.createObjectURL(new Blob([ "self.MonacoEnvironment={baseUrl:'" + BASE.replace(/\/vs$/, '/') + "'};" + "importScripts('" + BASE + "/base/worker/workerMain.js');" ], { type: 'text/javascript' })); } }; } catch (e) { return; }; global.require(['vs/editor/editor.main'], function (monacoNS) { if (isSettled) return; isSettled = true; clearTimeout(timer); self._initMonaco(container, initialValue, monacoNS || global.monaco); resolve(); }, function () { if (isSettled) return; isSettled = true; clearTimeout(timer); self._initTextarea(container, initialValue); UI.showBanner('Monaco failed to load (the CDN may be blocked by a corporate policy). Using a plain text editor instead. Run and Save still work.', 'warning'); resolve(); }); }; tryRequire(60); }); return this._ready; }, whenReady: function () { return this._ready || Promise.resolve(); }, _initTextarea: function (container, initialValue) { this.mode = 'textarea'; var onInputSettled = debounce(function (inputTab) { if (inputTab !== QueryTabs.active()) return; self._onContentChanged(); self._runLint(); }, SQL_SAVE_DEBOUNCE_MS); var ta = h('textarea', { class: 'ss-editor-fallback', spellcheck: false, 'aria-label': 'SQL editor (plain text fallback)', onkeydown: function (ev) { var isModKey = ev.metaKey || ev.ctrlKey; if (isModKey && ev.key === 'Enter') { ev.preventDefault(); Runner.run(); } else if (isModKey && ev.shiftKey && (ev.key === 'f' || ev.key === 'F')) { ev.preventDefault(); self.format(); } else if (isModKey && (ev.key === 's' || ev.key === 'S')) { ev.preventDefault(); Dialogs.saveCurrent(); }; }, oninput: function () { onInputSettled(QueryTabs.active()); } }, null); var self = this; ta.value = initialValue || ''; container.appendChild(ta); this.textarea = ta; }, _initMonaco: function (container, initialValue, monacoNS) { this.mode = 'monaco'; this.monaco = monacoNS; var self = this; try { this._registerLanguage(monacoNS); } catch (e) { logWarn('registerLanguage failed', e); }; try { this._registerThemes(monacoNS); } catch (e) { logWarn('registerThemes failed', e); }; try { this._registerProviders(monacoNS); } catch (e) { logWarn('registerProviders failed', e); }; var editorEl = h('div', { class: 'ss-editor-container' }, null); container.appendChild(editorEl); var firstModel = monacoNS.editor.createModel(initialValue || '', 'sfmc-sql'); this.instance = monacoNS.editor.create(editorEl, { model: firstModel, language: 'sfmc-sql', theme: Settings.effectiveTheme() === 'dark' ? 'ss-dark' : 'ss-light', automaticLayout: true, wordBasedSuggestions: 'off', fixedOverflowWidgets: true, minimap: { enabled: true, renderCharacters: false, showSlider: 'always', maxColumn: 120 }, overviewRulerBorder: false, fontSize: 13, wordWrap: 'off', scrollBeyondLastLine: false, renderValidationDecorations: 'on', tabSize: 4 }); this.model = this.instance.getModel(); this.instance.addCommand(monacoNS.KeyCode.F1, function () { /* disabled: command palette not needed */ }); try { this._watchSuggestDetails(); } catch (e) { logWarn('watchSuggestDetails failed', e); }; this.instance.addAction({ id: 'ss.run', label: 'Run query', keybindings: [monacoNS.KeyMod.CtrlCmd | monacoNS.KeyCode.Enter], run: function () { Runner.run(); } }); this.instance.addAction({ id: 'ss.save', label: 'Save query', keybindings: [monacoNS.KeyMod.CtrlCmd | monacoNS.KeyCode.KeyS], run: function () { Dialogs.saveCurrent(); } }); this.instance.addAction({ id: 'ss.format', label: 'Format SQL', keybindings: [monacoNS.KeyMod.CtrlCmd | monacoNS.KeyMod.Shift | monacoNS.KeyCode.KeyF], run: function () { self.format(); } }); var lintSoon = debounce(function () { self._runLint(); }, LINT_DEBOUNCE_MS); this.instance.onDidChangeModelContent(function () { self._onContentChanged(); lintSoon(); }); this._runLint(); }, _onContentChanged: function () { var text = this.getValue(); state.dirty = text !== state.lastLoadedText; QueryTabs.active().changedAt = Date.now(); scheduleSaveTabs(); UI.renderOpenedQueryBadge(); UI.syncFormatNotice(); this._syncSoon(); if (UI._saveAsEdited) UI._saveAsEdited(); if (!text.trim()) UI.setActiveTab('status'); /* editor cleared: back to Status */ for (var i = 0; i < this._onChangeCbs.length; i++) { try { this._onChangeCbs[i](text); } catch (e) { }; }; }, _syncSoon: debounce(function () { UI.syncEditedState(); }, LINT_DEBOUNCE_MS), onDidChangeContent: function (cb) { this._onChangeCbs.push(cb); }, getValue: function () { if (this.mode === 'monaco' && this.model) return this.model.getValue(); if (this.textarea) return this.textarea.value; return ''; }, setValue: function (text, isLoaded) { text = text || ''; if (this.mode === 'monaco' && this.model) this.model.setValue(text); else if (this.textarea) this.textarea.value = text; if (isLoaded) { state.lastLoadedText = text; state.dirty = false; }; this._onContentChanged(); this._runLint(); }, isDirty: function () { if (this.mode === 'textarea') return this.getValue() !== state.lastLoadedText; return state.dirty; }, markSaved: function () { state.lastLoadedText = this.getValue(); state.dirty = false; UI.renderOpenedQueryBadge(); }, textFor: function (tab) { if (tab === QueryTabs.active()) return this.getValue(); if (this.mode === 'monaco' && tab.monacoModel) return tab.monacoModel.getValue(); if (tab.pendingText !== null && tab.pendingText !== undefined) return tab.pendingText; return tab.textareaText || ''; }, _saveTab: function (tab) { if (!tab) return; if (this.mode === 'monaco' && this.instance) { tab.monacoViewState = this.instance.saveViewState(); } else if (this.mode === 'textarea' && this.textarea) { if (this.textarea.value !== tab.textareaText) tab.changedAt = Date.now(); tab.textareaText = this.textarea.value; tab.state.dirty = tab.textareaText !== tab.state.lastLoadedText; }; tab.state.serverAcceptedText = this._serverAcceptedText; }, _loadTab: function (tab) { this._serverAcceptedText = tab.state.serverAcceptedText || null; if (this.mode === 'monaco' && this.instance) { if (!tab.monacoModel) { tab.monacoModel = this.monaco.editor.createModel(tab.pendingText || '', 'sfmc-sql'); tab.pendingText = null; }; this.instance.setModel(tab.monacoModel); this.model = tab.monacoModel; if (tab.monacoViewState) this.instance.restoreViewState(tab.monacoViewState); this.instance.focus(); } else if (this.mode === 'textarea' && this.textarea) { this.textarea.value = (tab.pendingText !== null && tab.pendingText !== undefined) ? tab.pendingText : (tab.textareaText || ''); tab.pendingText = null; this.textarea.focus(); }; this._runLint(); }, getRunTarget: function () { if (this.mode === 'monaco' && this.instance) { var sel = this.instance.getSelection(); if (sel && !sel.isEmpty()) { var text = this.model.getValueInRange(sel); if (text && text.trim()) return { sql: text, offset: this.model.getOffsetAt(sel.getStartPosition()) }; }; }; return { sql: this.getValue(), offset: 0 }; }, lineColAt: function (offset) { if (this.mode === 'monaco' && this.model) { var pos = this.model.getPositionAt(offset); return { line: pos.lineNumber, col: pos.column }; }; return offsetToLineCol(this.getValue(), offset); }, toDocumentFindings: function (findings, baseOffset) { if (!baseOffset) return findings; var self = this; return findings.map(function (f) { var start = self.lineColAt(f.start + baseOffset); var end = self.lineColAt(f.end + baseOffset); return Object.assign({}, f, { start: f.start + baseOffset, end: f.end + baseOffset, line: start.line, col: start.col, endLine: end.line, endCol: end.col, fixes: f.fixes ? f.fixes.map(function (fix) { return Object.assign({}, fix, { edits: shiftEdits(fix.edits, baseOffset) }); }) : f.fixes }); }); }, isLintCurrent: function () { return this._lintedText === this.getValue(); }, focus: function () { if (this.mode === 'monaco' && this.instance) this.instance.focus(); else if (this.textarea) this.textarea.focus(); }, selectAll: function () { this.focus(); if (this.mode === 'monaco' && this.instance && this.instance.getModel()) { this.instance.setSelection(this.instance.getModel().getFullModelRange()); } else if (this.textarea) { this.textarea.select(); }; }, layout: function () { if (this.mode === 'monaco' && this.instance) this.instance.layout(); }, applyFixEdits: function (edits) { if (!edits || !edits.length) return; var current = this.getValue(); var out = current; edits.slice().sort(function (a, b) { return b.start - a.start; }).forEach(function (e) { out = out.slice(0, e.start) + e.text + out.slice(e.end); }); if (this.mode === 'monaco' && this.instance) { var startPos = this.model.getPositionAt(0); var endPos = this.model.getPositionAt(current.length); var range = new this.monaco.Range(startPos.lineNumber, startPos.column, endPos.lineNumber, endPos.column); this.instance.pushUndoStop(); this.instance.executeEdits('ss-fix', [{ range: range, text: out }]); this.instance.pushUndoStop(); } else { this.setValue(out, false); }; this._onContentChanged(); this._runLint(); }, lintContext: function (isRunTarget) { var options = { disabled: DISABLED_LINT_RULES, isParentBusinessUnit: isParentBusinessUnit(), isSqlStudio: true }; if (isRunTarget === true) options.isRunTarget = true; return { schema: Schema.getIndex(), targetKey: state.openedQuery ? state.openedQuery.targetKey : null, targetName: state.openedQuery ? state.openedQuery.targetName || null : null, targetAction: state.openedQuery ? state.openedQuery.updateType || null : null, options: options }; }, fixAll: function () { var sql = this.getValue(); var result = Tools.fixAll(sql, this.lintContext(), { isFoundOnly: true }); if (!result.appliedCount) return; this.applyFixEdits([{ start: 0, end: sql.length, text: result.sql }]); var text = 'Applied ' + result.appliedCount + (result.appliedCount === 1 ? ' fix.' : ' fixes.'); if (result.newCount) text += ' ' + result.newCount + (result.newCount === 1 ? ' new issue showed up.' : ' new issues showed up.'); Toast.show(text, 'success'); }, jumpTo: function (offset) { if (this.mode === 'monaco' && this.instance && this.model) { var pos = this.model.getPositionAt(offset); this.instance.setPosition(pos); this.instance.revealPositionInCenter(pos); this.instance.focus(); }; }, getProblems: function () { return this._lastFindings; }, setServerMarkers: function (markers) { if (this.mode !== 'monaco' || !this.model) return; this.monaco.editor.setModelMarkers(this.model, 'sfmc-server', markers || []); }, clearServerMarkers: function () { if (this.mode !== 'monaco' || !this.model) return; this.monaco.editor.setModelMarkers(this.model, 'sfmc-server', []); }, lintNow: function () { this._runLint(); }, _serverAcceptedText: null, markServerAccepted: function (sql) { this._serverAcceptedText = sql; this.lintNow(); }, withServerVerdict: function (sql, findings) { if (this._serverAcceptedText === null || this._serverAcceptedText !== sql) return findings; return findings.map(function (f) { if (f.severity !== 'error' || (f.id !== 'MCE000' && f.id !== 'MCE060')) return f; return Object.assign({}, f, { severity: 'info', message: 'Marketing Cloud Engagement accepted this SQL, so this is a mistake in SQL Studio\'s own syntax check: ' + f.message }); }); }, _runLint: function () { var self = this; var sql = this.getValue(); var referenced = {}; var statements = Tools.splitStatements(sql); var parts = statements.length > 1 ? statements.map(function (st) { return sql.slice(st.start, st.end); }) : [sql]; parts.forEach(function (part) { try { Tools.parse(part, Schema.getIndex()).from.forEach(function (f) { if (f.kind === 'table' && !f.isFunction && f.name) referenced[f.name.replace(/^\[|\]$/g, '').toLowerCase()] = true; }); } catch (e) { /* ignore */ } }); var neededFields =Object.keys(referenced).map(function (n) { return Tools.lookupTable(Schema.byName, n); }).filter(function (t) { return !!t && !t.fields && !Schema.hasFieldLoadFailed(t); }); var run = function () { if (sql !== self.getValue()) return; var findings = []; try { findings = self.withServerVerdict(sql, Tools.lint(sql, self.lintContext())); } catch (e) { logWarn('lint failed', e); } self._lintedText = sql; self._lastFindings = findings; self._applyMarkers(findings); UI.renderProblems(findings); }; if (neededFields.length) { Promise.all(neededFields.map(function (t) { return Schema.autoFields(t).catch(function () { return null; }); })).then(run); } else { run(); }; }, _applyMarkers: function (findings) { if (this.mode !== 'monaco' || !this.model) return; var sevMap = { error: this.monaco.MarkerSeverity.Error, warning: this.monaco.MarkerSeverity.Warning, info: this.monaco.MarkerSeverity.Info }; var markers = findings.filter(function (f) { return isShownAtStatusLevel(f.severity); }).map(function (f) { return { severity: sevMap[f.severity] || sevMap.info, message: f.message + (f.docs ? '' : ''), code: f.id, source: 'sfmc-lint', startLineNumber: f.line, startColumn: f.col, endLineNumber: f.endLine, endColumn: f.endCol }; }); this.monaco.editor.setModelMarkers(this.model, 'sfmc-lint', markers); }, format: function () { var hasSelection = false; var sel = null; if (this.mode === 'monaco' && this.instance) { sel = this.instance.getSelection(); hasSelection = !!(sel && !sel.isEmpty() && this.model.getValueInRange(sel).trim()); }; var input = hasSelection ? this.model.getValueInRange(sel) : this.getValue(); if (!input.trim()) { Toast.show('Nothing to format.', 'warning'); return; }; var result; try { result = Tools.formatSql(input); } catch (e) { Toast.show('Could not format this SQL: ' + e.message, 'warning'); return; }; if (!result.changed && result.reason) { Toast.show(result.reason, 'warning'); return; }; if (result.changed) { if (hasSelection && this.mode === 'monaco' && this.instance) { this.instance.executeEdits('ss-format', [{ range: sel, text: result.sql }]); this._onContentChanged(); this._runLint(); } else { this.setValue(result.sql, false); }; }; UI.showFormatDone(); }, _retriggerSuggest: function () { if (this.mode === 'monaco' && this.instance) { try { this.instance.trigger('ss', 'editor.action.triggerSuggest'); } catch (e) { }; }; }, _reopenSuggest: function () { if (this.mode !== 'monaco' || !this.instance) return; var widget = document.querySelector('.suggest-widget.visible'); if (!widget) return; var focused = widget.querySelector('.monaco-list-row.focused'); if (focused && focused.getAttribute('data-index') !== '0') return; try { this.instance.trigger('ss', 'hideSuggestWidget', {}); this.instance.trigger('ss', 'editor.action.triggerSuggest', {}); } catch (e) { }; }, _triggerDeListLoad: function () { var self = this; if (self._deListLoadTriggered) return; self._deListLoadTriggered = true; Schema.loadDataExtensionList().then(function () { if (self.mode !== 'monaco' || !self.instance || !self.instance.hasTextFocus()) return; var model = self.instance.getModel(); var ctx; try { ctx = Tools.completionContext(model.getValue(), model.getOffsetAt(self.instance.getPosition()), Schema.getIndex()); } catch (e) { return; }; if (ctx.kind !== 'table') return; if (document.querySelector('.suggest-widget.visible')) self._reopenSuggest(); else self._retriggerSuggest(); }).catch(function () { /* the sidebar/Save As Retry link covers a failed load */ }); }, _loadReferencedFields: function (text) { var self = this; var names; try { names = referencedTopLevelTableNames(text); } catch (e) { return; }; names.forEach(function (name) { var table = Tools.lookupTable(Schema.byName, name); if (!table || table.kind !== 'de' || table.fields || Schema.hasFieldLoadFailed(table)) return; var key = Schema.tableKey(table).toLowerCase(); if (self._fieldAutoLoadWaits[key]) return; self._fieldAutoLoadWaits[key] = true; Schema.autoFields(table).then(function () { delete self._fieldAutoLoadWaits[key]; self.lintNow(); self._reopenSuggest(); }, function () { delete self._fieldAutoLoadWaits[key]; }); }); }, prefetchFields: function (t) { if (!t || t.kind !== 'de' || t.fields || Schema.hasFieldLoadFailed(t)) return; Schema.autoFields(t).catch(function () { /* Schema remembers the failure until Reload: no retry here. */ }); } }; function logWarn() { if (global.console && global.console.warn) global.console.warn.apply(global.console, arguments); }; Editor._registerLanguage = function (monacoNS) { monacoNS.languages.register({ id: 'sfmc-sql' }); var kwNames = (SQLData.keywords || []).map(function (k) { return k.name.toUpperCase(); }).filter(function (n) { return n.indexOf(' ') === -1; }); var fnNames = (SQLData.functions || []).map(function (f) { return f.name.toUpperCase(); }); var typeNames = (SQLData.dataTypes || []).map(function (t) { return String(t.name || t).toUpperCase(); }).concat(Tools.HIGHLIGHT_TYPES || []); function finish(base) { var lang = base.language; var extended = {}; for (var k in lang) if (Object.prototype.hasOwnProperty.call(lang, k)) extended[k] = lang[k]; extended.ignoreCase = true; extended.tokenPostfix = '.sql'; extended.keywords = uniqueUpper((Tools.HIGHLIGHT_KEYWORDS || lang.keywords || []).concat(typeNames, kwNames)); extended.builtinFunctions = uniqueUpper((lang.builtinFunctions || []).concat(fnNames)); extended.tokenizer = {}; for (var tk in lang.tokenizer) if (Object.prototype.hasOwnProperty.call(lang.tokenizer, tk)) extended.tokenizer[tk] = lang.tokenizer[tk]; extended.tokenizer.root = [[/@[a-zA-Z_]\w*/, 'variable.sql'], [/_[a-zA-Z_][\w]*/, 'type.identifier.sql']].concat(lang.tokenizer.root || []); monacoNS.languages.setMonarchTokensProvider('sfmc-sql', extended); var conf = {}; var baseConf = base.conf || { brackets: [['(', ')'], ['[', ']']], autoClosingPairs: [{ open: '(', close: ')' }, { open: '[', close: ']' }, { open: "'", close: "'" }] }; for (var ck in baseConf) if (Object.prototype.hasOwnProperty.call(baseConf, ck)) conf[ck] = baseConf[ck]; conf.comments = { blockComment: ['/*', '*/'] }; monacoNS.languages.setLanguageConfiguration('sfmc-sql', conf); }; try { global.require(['vs/basic-languages/sql/sql'], finish, function () { logWarn('Could not load the built-in sql Monarch definition; registering sfmc-sql with a minimal fallback.'); finish({ language: { defaultToken: '', tokenPostfix: '.sql', keywords: [], builtinFunctions: [], tokenizer: { root: [] } }, conf: null }); }); } catch (e) { logWarn('require(vs/basic-languages/sql/sql) threw', e); }; }; function uniqueUpper(arr) { var seen = {}; var uniqueNames = []; arr.forEach(function (w) { var u = String(w).toUpperCase(); if (!seen[u]) { seen[u] = true; uniqueNames.push(u); }; }); return uniqueNames; }; Editor._registerThemes = function (monacoNS) { ['ss-light', 'ss-dark'].forEach(function (name) { var base = name === 'ss-dark' ? 'vs-dark' : 'vs'; var root = document.querySelector('.ss'); var prevTheme = root ? root.getAttribute('data-theme') : null; if (root) root.setAttribute('data-theme', name === 'ss-dark' ? 'dark' : 'light'); var cs = root ? getComputedStyle(root) : null; function v(name2, fallback) { return (cs && cs.getPropertyValue(name2).trim()) || fallback; } var warningFallback = base === 'vs-dark' ? '#F6B355' : '#B47B1A'; var colors = { 'editor.background': v('--editor-bg', base === 'vs-dark' ? '#1e1e1e' : '#ffffff'), 'editor.foreground': v('--editor-fg', base === 'vs-dark' ? '#d4d4d4' : '#000000'), 'minimap.background': v('--editor-bg', base === 'vs-dark' ? '#1e1e1e' : '#ffffff'), 'editorOverviewRuler.border': '#00000000', 'scrollbar.shadow': '#00000000', 'scrollbarSlider.background': '#79797966', 'scrollbarSlider.hoverBackground': '#646464B3', 'scrollbarSlider.activeBackground': '#BFBFBF66', 'minimapSlider.background': '#79797933', 'minimapSlider.hoverBackground': '#79797959', 'minimapSlider.activeBackground': '#79797980', 'editorError.foreground': v('--color-danger', '#DA4E55'), 'editorWarning.foreground': v('--warning-color', warningFallback), 'editorInfo.foreground': v('--brand-blue', '#1D73C9'), 'editorOverviewRuler.errorForeground': v('--color-danger', '#DA4E55'), 'editorOverviewRuler.warningForeground': v('--warning-color', warningFallback), 'editorOverviewRuler.infoForeground': v('--brand-blue', '#1D73C9'), 'minimap.errorHighlight': v('--color-danger', '#DA4E55'), 'minimap.warningHighlight': v('--warning-color', warningFallback), 'editorSuggestWidget.selectedBackground': v('--focus-band', '#1D73C9'), 'editorSuggestWidget.selectedForeground': v('--focus-band-fg', '#FFFFFF'), 'editorSuggestWidget.selectedIconForeground': v('--focus-band-fg', '#FFFFFF'), 'editorSuggestWidget.focusHighlightForeground': v('--focus-band-fg', '#FFFFFF'), 'editorHoverWidget.background': v('--tooltip-bg', base === 'vs-dark' ? '#333437' : '#FFFFFF'), 'editorHoverWidget.foreground': v('--tooltip-fg', base === 'vs-dark' ? '#F5F6F7' : '#1C1E21'), 'editorHoverWidget.border': v('--tooltip-border', base === 'vs-dark' ? '#6A6D72' : '#A9AFB6') }; var bracket2 = v('--editor-bracket-2', ''); if (bracket2) colors['editorBracketHighlight.foreground2'] = bracket2; var rules = [ { token: 'keyword.sql', foreground: hexOf(v('--editor-keyword', '#0000ff')) }, { token: 'keyword.block.sql', foreground: hexOf(v('--editor-keyword', '#0000ff')) }, { token: 'keyword.choice.sql', foreground: hexOf(v('--editor-keyword', '#0000ff')) }, { token: 'keyword.try.sql', foreground: hexOf(v('--editor-keyword', '#0000ff')) }, { token: 'keyword.catch.sql', foreground: hexOf(v('--editor-keyword', '#0000ff')) }, { token: 'operator.sql', foreground: hexOf(v('--editor-operator', '#778899')) }, { token: 'comment.quote.sql', foreground: hexOf(v('--editor-comment', '#6a737d')) }, { token: 'predefined.sql', foreground: hexOf(v('--editor-function', '#795e26')) }, { token: 'string.sql', foreground: hexOf(v('--editor-string', '#008000')) }, { token: 'comment.sql', foreground: hexOf(v('--editor-comment', '#6a737d')) }, { token: 'number.sql', foreground: hexOf(v('--editor-identifier', '#098658')) }, { token: 'identifier.sql', foreground: hexOf(v('--editor-identifier', '#001080')) }, { token: 'type.identifier.sql', foreground: hexOf(v('--editor-dataview', '#1D73C9')), fontStyle: 'bold' } ]; monacoNS.editor.defineTheme(name, { base: base, inherit: true, rules: rules, colors: colors }); if (root && prevTheme !== null) root.setAttribute('data-theme', prevTheme); else if (root) root.removeAttribute('data-theme'); }); }; function hexOf(v) { v = String(v || '').trim(); if (v.charAt(0) === '#') return v.slice(1); return v; }; var functionsByName = {}; (SQLData.functions || []).forEach(function (f) { functionsByName[f.name.toUpperCase()] = f; }); var keywordsByName = {}; var multiWordKeywords = []; (SQLData.keywords || []).forEach(function (k) { keywordsByName[k.name.toUpperCase()] = k; if (k.name.indexOf(' ') !== -1) multiWordKeywords.push(k); }); function functionDoc(f) { var lines = ['**' + f.signature + '**', '', f.description || '']; if (f.unverified) lines.push('', '_Not confirmed in Marketing Cloud Engagement.' + (f.sfmc ? ' ' + f.sfmc : '') + '_'); else if (f.sfmc) lines.push('', '_Marketing Cloud Engagement: ' + f.sfmc + '_'); if (f.example) lines.push('', '```sql', f.example, '```'); if (f.returns) lines.push('', '_Returns: ' + f.returns + '_'); return lines.join('\n'); }; function keywordDoc(k) { var lines = ['**' + k.name + '**', '', k.description || '']; if (k.syntax) lines.push('', '```sql', k.syntax, '```'); if (k.sfmc) lines.push('', '_Marketing Cloud Engagement: ' + k.sfmc + '_'); return lines.join('\n'); }; function docsLink(url) { var label = 'Docs'; if (/^https:\/\/mateuszdabrowski\.pl\//.test(url)) { label = 'MD docs'; } else if (/^https:\/\/(learn|docs)\.microsoft\.com\//.test(url)) { label = 'MS T-SQL docs'; } else if (/^https:\/\/(help|developer)\.salesforce\.com\//.test(url)) { label = 'SF docs'; }; return '[' + label + '](' + url + ')'; }; function docsRow(urls) { return urls.filter(Boolean).map(docsLink).join('   '); }; function withDocsRow(markdown, urls) { var contents = [{ value: markdown }]; var links = docsRow(urls); if (links) { contents.push({ value: links, supportThemeIcons: true }); }; return contents; }; function docsDocumentation(markdown, urls) { var links = docsRow(urls); return { value: markdown + (links ? '\n\n' + links : ''), supportThemeIcons: true }; }; function ownListEntry(item) { return { name: item.name, key: item.key, kind: 'de', objectId: item.objectId || null, categoryId: item.categoryId || null, isSendable: !!item.isSendable, sendableField: item.sendableField || null, sendableSubscriberField: item.sendableSubscriberField || null, templateKey: item.templateKey || null, description: cacheDescription(item.description), hasDetails: false, isShared: false, fields: null }; }; function applyDeDetails(table, item) { table.sendableField = item.sendableField || null; table.sendableSubscriberField = item.sendableSubscriberField || null; table.templateKey = item.templateKey || null; table.description = cacheDescription(item.description); table.hasDetails = true; }; function packDeEntries(entries) { var cols = []; entries.forEach(function (t) { Object.keys(t || {}).forEach(function (name) { if (name !== 'fields' && cols.indexOf(name) === -1) cols.push(name); }); }); var rows = entries.map(function (t) { return cols.map(function (name) { return (t && t[name] !== undefined) ? t[name] : null; }); }); return { cols: cols, rows: rows }; }; function unpackDeEntries(packed) { var cols = (packed && Array.isArray(packed.cols)) ? packed.cols.map(String) : []; var rows = (packed && Array.isArray(packed.rows)) ? packed.rows : []; return rows.filter(Array.isArray).map(function (row) { var entry = { fields: null }; cols.forEach(function (name, i) { if (name !== 'fields') entry[name] = (row[i] === undefined) ? null : row[i]; }); return entry; }); }; function cacheDescription(value) { var text = String(value === null || value === undefined ? '' : value).trim(); if (text.length <= DE_DESCRIPTION_MAX) return text; var cut = text.slice(0, DE_DESCRIPTION_MAX); if (/[\uD800-\uDBFF]$/.test(cut)) cut = cut.slice(0, -1); return cut.trim(); }; function mdPlain(text) { return mdEscape(text).replace(/[=~$]/g, function (c) { return '\\' + c; }).replace(/:\/\//g, '\\://'); }; function trimAddress(address) { var trimmed = address; var isTrimming = true; while (isTrimming && trimmed) { var last = trimmed.charAt(trimmed.length - 1); var isUnbalanced = last === ')' && trimmed.split(')').length > trimmed.split('(').length; if (".,:;!?'*_~".indexOf(last) !== -1 || isUnbalanced) { trimmed = trimmed.slice(0, -1); } else { isTrimming = false; }; }; return trimmed; }; function mdText(text) { var source = String(text === null || text === undefined ? '' : text); var pattern = /https?:\/\/[^\s<>"`\\]+/gi; var markdown = ''; var from = 0; var match = pattern.exec(source); while (match !== null) { var address = trimAddress(match[0]); if (/^https?:\/\/[^\/?#]/i.test(address)) { markdown += mdPlain(source.slice(from, match.index)) + '[' + mdPlain(address) + '](' + address.replace(/[()&]/g, function (c) { return '\\' + c; }) + ')'; from = match.index + address.length; }; match = pattern.exec(source); }; return markdown + mdPlain(source.slice(from)); }; function mdDescription(text) { var paragraphs = []; var lines = []; String(text === null || text === undefined ? '' : text).split(/\r\n|\r|\n/).forEach(function (line) { var content = line.replace(/\s+$/, ''); if (!content) { if (lines.length) paragraphs.push(lines.join(' \n')); lines = []; return; }; var indent = /^[ \t]*/.exec(content)[0]; lines.push(indent.replace(/\t/g, ' ').replace(/ /g, '\u00a0') + mdText(content.slice(indent.length))); }); if (lines.length) paragraphs.push(lines.join(' \n')); return paragraphs.join('\n\n'); }; function sendableSubscriberLabel(name) { var kind = Tools.subscriberFieldKind(name); if (kind === 'subscriberKey') return 'Subscriber Key'; if (kind === 'emailAddress') return 'Email Address'; return String(name); }; function sendableText(t) { if (t.kind !== 'de' || !t.isSendable) return ''; var field = String(t.sendableField || '').trim(); var mapped = String(t.sendableSubscriberField || '').trim(); if (field && mapped) return 'Sendable: ' + field + ' = ' + sendableSubscriberLabel(mapped); return field ? 'Sendable: ' + field : 'Sendable'; }; function templateText(t) { var key = String(t.templateKey || '').trim(); if (/^\{?[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}\}?$/i.test(key)) return ''; return (t.kind === 'de' && key) ? 'Template: ' + key : ''; }; function tableTitle(t, wholeName) { if (t.kind !== 'de') return wholeName; var lines = [wholeName]; var description = String(t.description || '').trim(); if (description) lines.push(description); if (sendableText(t)) lines.push(sendableText(t)); if (templateText(t)) lines.push(templateText(t)); return lines.join('\n'); }; function tableDoc(t) { var lines = ['**' + mdEscape(t.name) + '**' + (t.isShared ? ' _(shared)_' : '')]; var description = String(t.description || '').trim(); if (description) lines.push('', mdDescription(description)); if (sendableText(t)) lines.push('', mdDescription(sendableText(t))); if (templateText(t)) lines.push('', mdDescription(templateText(t))); var fieldCount = (t.fields && t.fields.length) ? t.fields.length + (t.fields.length === 1 ? ' field' : ' fields') : ''; var place = [t.folderPath ? mdText(t.folderPath) : '', fieldCount].filter(Boolean).join(' · '); if (place) lines.push('', place); return lines.join('\n'); }; function tableHoverContents(t) { return withDocsRow(tableDoc(t), [t.docs, t.officialDocs]); }; function columnDoc(f, table) { var typeText = String(f.type || ''); if (f.length) { typeText += '(' + f.length + ')'; } else if (f.precision != null) { typeText += '(' + f.precision + (f.scale != null ? ', ' + f.scale : '') + ')'; }; var lines = ['**' + mdEscape(f.name) + '** ' + mdEscape(typeText)]; if (f.isPrimaryKey) lines.push('', '_Primary key_'); if (f.nullable) lines.push('', '_Nullable_'); if (f.description) lines.push('', mdEscape(f.description)); if (f.values && f.values.length) { var shownValues = f.values.slice(0, 20); var hasMeanings = shownValues.some(function (v) { return !!v.label; }); if (hasMeanings) { lines.push('', '| Value | Meaning |', '| --- | --- |'); shownValues.forEach(function (v) { lines.push('| `' + String(v.value) + '` | ' + mdEscape(v.label || '') + ' |'); }); } else { lines.push('', 'Values: ' + shownValues.map(function (v) { return '`' + String(v.value) + '`'; }).join(', ')); }; if (f.values.length > shownValues.length) lines.push('', '_...and ' + (f.values.length - shownValues.length) + ' more_'); }; if (table && table.name) lines.push('', 'on ' + mdEscape(table.name)); return lines.join('\n'); }; function rangeOf(monacoNS, model, position, wordInfo) { return new monacoNS.Range(position.lineNumber, wordInfo.startColumn, position.lineNumber, wordInfo.endColumn); }; Editor._registerProviders = function (monacoNS) { var self = this; var prefetchCommand = null; if (monacoNS.editor && typeof monacoNS.editor.registerCommand === 'function') { monacoNS.editor.registerCommand('sqlstudio.prefetchFields', function () { var t = Array.prototype.slice.call(arguments).filter(function (arg) { return !!arg && arg.kind === 'de'; })[0]; self.prefetchFields(t); }); prefetchCommand = 'sqlstudio.prefetchFields'; }; function tableSortText(t, isPrefixUnderscore) { var isDv = t.kind === 'dataview'; var rank = isPrefixUnderscore ? (isDv ? '0' : '1') : (isDv ? '1' : '0'); return rank + t.name.toUpperCase(); }; function tableItem(t, range, isPrefixUnderscore) { var insertText = t.isShared ? ('ENT.' + Tools.formatIdentifier(t.name)) : (t.kind === 'dataview' ? Tools.dataViewTable(t.name, SQLData, isParentBusinessUnit() === false) : Tools.formatIdentifier(t.name)); var item = { label: t.name, kind: t.kind === 'dataview' ? monacoNS.languages.CompletionItemKind.Module : monacoNS.languages.CompletionItemKind.Class, detail: t.folderPath || (t.isShared ? 'Shared' : ''), documentation: docsDocumentation(tableDoc(t), [t.docs, t.officialDocs]), insertText: insertText, sortText: tableSortText(t, isPrefixUnderscore), range: range }; if (t.kind === 'de' && prefetchCommand) item.command = { id: prefetchCommand, title: 'Load the fields', arguments: [t] }; return item; }; function columnItem(f, range) { var detail = String(f.type || '') + (f.length ? '(' + f.length + ')' : '') + (f.isPrimaryKey ? ' • PK' : ''); return { label: f.name, kind: monacoNS.languages.CompletionItemKind.Field, detail: detail, documentation: { value: columnDoc(f, null) }, insertText: Tools.formatIdentifier(f.name), sortText: '0' + f.name.toUpperCase(), range: range }; }; function functionItem(f, range) { var isBare = String(f.signature || '').indexOf('(') === -1; var item = { label: isBare ? f.name : f.name + '()', kind: monacoNS.languages.CompletionItemKind.Function, detail: f.unverified ? 'Not confirmed in Marketing Cloud Engagement · ' + f.signature : f.signature, documentation: docsDocumentation(functionDoc(f), [f.docs, f.officialDocs]), insertText: isBare ? f.name : f.name + '(${1})', sortText: (f.unverified ? '3' : '1') + f.name, range: range }; if (!isBare) item.insertTextRules = monacoNS.languages.CompletionItemInsertTextRule.InsertAsSnippet; return item; }; function keywordItem(k, range) { return { label: k.name, kind: monacoNS.languages.CompletionItemKind.Keyword, detail: k.category, documentation: docsDocumentation(keywordDoc(k), [k.docs, k.officialDocs]), insertText: k.category === 'join' ? k.name + ' ' : k.name, sortText: '2' + k.name, range: range }; }; function isOffsetInsideString(sql, offset) { var tokens; try { tokens = Tools.tokenize(sql.slice(0, offset)); } catch (e) { return false; }; var last = tokens[tokens.length - 1]; if (!last || last.type !== 'string') return false; return !!last.unterminated; }; function valueSuggestions(field, range) { var isQuoted = field.type === 'Text'; return (field.values || []) .filter(function (v) { return String(v.value).toLowerCase() !== 'null'; }) .map(function (v, idx) { var rawValue = String(v.value); var insertText = isQuoted ? ("'" + rawValue.replace(/'/g, "''") + "'") : rawValue; var label = v.label ? (rawValue + ' - ' + v.label) : rawValue; return { label: label, kind: monacoNS.languages.CompletionItemKind.EnumMember, insertText: insertText, sortText: '0' + String(1000 + idx), range: range }; }); }; function columnSuggestions(ctx, range) { if (ctx.atAtRange) { var typedAfter = String(ctx.prefix || '').toUpperCase(); return Promise.resolve((SQLData.functions || []).filter(function (f) { return /^@@/.test(f.name) && f.name.slice(2).toUpperCase().indexOf(typedAfter) === 0; }).map(function (f) { return functionItem(f, ctx.atAtRange); })); }; var loads; if (ctx.alias) { loads = ctx.table ? [Schema.autoFields(ctx.table).catch(function () { return null; })] : []; } else { loads = ctx.tablesInScope.filter(function (e) { return e.table; }).map(function (e) { return Schema.autoFields(e.table).catch(function () { return null; }); }); }; return Promise.all(loads).then(function () { var items = []; var seen = {}; function addFields(table) { if (!table || !table.fields) return; table.fields.forEach(function (f) { var key = f.name.toLowerCase(); if (seen[key]) return; seen[key] = true; items.push(columnItem(f, range)); }); }; if (ctx.alias) { addFields(ctx.table); } else { ctx.tablesInScope.forEach(function (e) { addFields(e.table); }); var typed = String(ctx.prefix || '').toUpperCase(); var isStartedBy = function (name) { return !typed || String(name).toUpperCase().indexOf(typed) === 0; }; (SQLData.functions || []).forEach(function (f) { if (isStartedBy(f.name)) items.push(functionItem(f, range)); }); (SQLData.keywords || []).forEach(function (k) { if (k.name.indexOf(' ') === -1 && isStartedBy(k.name)) items.push(keywordItem(k, range)); }); }; return items; }); }; var JOIN_FENCE_LANGUAGE = 'sfmc-sql'; function joinItem(item, model, index) { function rangeAt(start, end) { var a = model.getPositionAt(start); var b = model.getPositionAt(end); return new monacoNS.Range(a.lineNumber, a.column, b.lineNumber, b.column); }; var isSide = item.mode === 'side'; var sqlLines = isSide ? item.conditions : item.conditions.map(function (condition, i) { return (i === 0 ? 'ON ' : 'AND ') + condition; }); if (item.mode === 'join') sqlLines.unshift(item.table + ' AS ' + item.alias); var lines = ['**' + mdEscape(isSide ? item.detail : item.label) + '**', '']; if (isSide) lines.push(mdEscape('The other side of ' + item.left), ''); else if (item.detail) lines.push(mdEscape(item.detail), ''); lines.push('```' + JOIN_FENCE_LANGUAGE, sqlLines.join('\n'), '```'); if (item.note) lines.push('', mdEscape(item.note)); self._joinLabels[item.label] = true; var kinds = monacoNS.languages.CompletionItemKind; var joinedTable = item.mode === 'join' ? Tools.lookupTable(Schema.byName, item.table) : null; var completion = { label: item.label, kind: item.mode === 'join' ? (joinedTable && joinedTable.kind === 'dataview' ? kinds.Module : kinds.Class) : (isSide ? kinds.Field : kinds.Operator), detail: item.detail, documentation: { value: lines.join('\n') }, insertText: item.insertText, insertTextRules: monacoNS.languages.CompletionItemInsertTextRule.KeepWhitespace, filterText: item.filterText, sortText: '!' + ('0000' + index).slice(-4), range: rangeAt(item.start, item.end), additionalTextEdits: item.additionalEdits.map(function (edit) { return { range: rangeAt(edit.start, edit.end), text: edit.text }; }) }; if (item.dataExtension && prefetchCommand) completion.command = { id: prefetchCommand, title: 'Load the fields', arguments: [item.dataExtension] }; return completion; }; function joinSuggestions(ctx, sql, offset, model) { if (!(ctx.kind === 'table' && ctx.afterJoin) && !(ctx.kind === 'column' && (ctx.afterOn || ctx.afterOnEquals))) return []; var found; try { found = Tools.joinCompletions(sql, offset, Schema.getIndex(), SQLData, { isChildBusinessUnit: isParentBusinessUnit() === false }); } catch (e) { return []; }; self._isJoinListStale = found.pendingTables.length > 0; loadJoinFields(found.pendingTables, model); return found.items.map(function (item, index) { return joinItem(item, model, index); }); }; var joinFieldWaits = {}; function loadJoinFields(tables, model) { var toLoad = (tables || []).filter(function (t) { var key = Schema.tableKey(t).toLowerCase(); if (joinFieldWaits[key]) return false; if (t.fields ? !Schema.isDetailsPending(t) : Schema.hasFieldLoadFailed(t)) return false; joinFieldWaits[key] = true; return true; }); if (!toLoad.length) return; Promise.all(toLoad.map(function (t) { return Schema.autoFields(t).then(function () { return Schema.details(t).then(function () { return true; }); }, function () { return false; }); })).then(function (loaded) { toLoad.forEach(function (t) { delete joinFieldWaits[Schema.tableKey(t).toLowerCase()]; }); if (loaded.indexOf(true) === -1) return; refreshJoinList(model, JOIN_LIST_SHOW_TRIES); }); }; function refreshJoinList(model, tries) { if (!self._isJoinListStale) return; if (!self.instance || self.instance.getModel() !== model || !self.instance.hasTextFocus()) return; var position = self.instance.getPosition(); var offset = model.getOffsetAt(position); var ctx; try { ctx = Tools.completionContext(model.getValue(), offset, Schema.getIndex()); } catch (e) { return; }; if (!((ctx.kind === 'table' && ctx.afterJoin) || (ctx.kind === 'column' && (ctx.afterOn || ctx.afterOnEquals)))) return; if (document.querySelector('.suggest-widget.visible')) { self._reopenSuggest(); return; }; if (self._onlyJoinsAt === offset) { if (joinSuggestions(ctx, model.getValue(), offset, model).length) self._retriggerSuggest(); else if (!self._isJoinListStale) self._onlyJoinsAt = null; return; }; if (tries > 0) global.setTimeout(function () { refreshJoinList(model, tries - 1); }, JOIN_LIST_SHOW_WAIT_MS); }; function onlyJoinSuggestions(ctx, sql, offset, model) { var items = joinSuggestions(ctx, sql, offset, model); self._onlyJoinsAt = (items.length || self._isJoinListStale) ? offset : null; return items; }; function onSpaceJoinSuggestions(model, position) { var sql = model.getValue(); var offset = model.getOffsetAt(position); var ctx; try { ctx = Tools.completionContext(sql, offset, Schema.getIndex()); } catch (e) { return []; }; if (isOffsetInsideString(sql, offset) || ctx.kind !== 'column' || !ctx.afterOn || ctx.prefix !== '') { self._onlyJoinsAt = null; return []; }; return onlyJoinSuggestions(ctx, sql, offset, model); }; function joinKeywordSuggestions(sql, offset, model) { var found; try { found = Tools.joinKeywordCompletions(sql, offset); } catch (e) { return []; }; return found.items.map(function (item, index) { var k = keywordsByName[item.keyword] || { name: item.keyword, category: 'join' }; var a = model.getPositionAt(item.start); var b = model.getPositionAt(item.end); return { label: item.label, kind: monacoNS.languages.CompletionItemKind.Keyword, detail: k.category, documentation: docsDocumentation(keywordDoc(k), [k.docs, k.officialDocs]), insertText: item.insertText, filterText: item.filterText, sortText: '!' + ('000' + index).slice(-3), range: new monacoNS.Range(a.lineNumber, a.column, b.lineNumber, b.column) }; }); }; function isAliasPlace(ctx) { return !!ctx.afterTable && !ctx.hasAlias && !ctx.hasHint; }; function joinTableKeywordSuggestions(ctx, range) { var names = ctx.hasAlias || ctx.hasHint ? ['ON'] : ['ON', 'AS']; if (isAliasPlace(ctx)) names = names.filter(function (name) { return name.indexOf(ctx.prefix.toUpperCase()) === 0; }); return names.map(function (name, index) { var item = keywordItem(keywordsByName[name] || { name: name, category: 'clause' }, range); item.insertText = name + ' '; item.sortText = '!' + ('000' + index).slice(-3); return item; }); }; monacoNS.languages.registerCompletionItemProvider('sfmc-sql', { triggerCharacters: ['.', ' ', '(', ','], provideCompletionItems: function (model, position, context, token) { var isValueTrigger = !!context && context.triggerKind === monacoNS.languages.CompletionTriggerKind.TriggerCharacter && context.triggerCharacter !== '.'; if (isValueTrigger) { var lineBefore = model.getLineContent(position.lineNumber).slice(0, position.column - 1).replace(/\s+$/, ''); if (lineBefore && '=>(,'.indexOf(lineBefore.charAt(lineBefore.length - 1)) === -1) { var isOnSpace = context.triggerCharacter === ' ' && /(?:^|[^A-Za-z0-9_\]])ON$/i.test(lineBefore); if (!isOnSpace) self._onlyJoinsAt = null; return { suggestions: isOnSpace ? onSpaceJoinSuggestions(model, position) : [] }; }; }; var sql = model.getValue(); var offset = model.getOffsetAt(position); if (self._onlyJoinsAt !== offset) self._onlyJoinsAt = null; if (isOffsetInsideString(sql, offset)) return { suggestions: [] }; var ctx; try { ctx = Tools.completionContext(sql, offset, Schema.getIndex()); } catch (e) { return { suggestions: [] }; }; var wordInfo = model.getWordUntilPosition(position); var range = new monacoNS.Range(position.lineNumber, wordInfo.startColumn, position.lineNumber, wordInfo.endColumn); var isPrefixUnderscore = ctx.prefix.charAt(0) === '_'; if (ctx.kind === 'column' && !ctx.alias && model.getLineContent(position.lineNumber).slice(0, wordInfo.startColumn - 1).slice(-2) === '@@') { ctx.atAtRange = new monacoNS.Range(position.lineNumber, wordInfo.startColumn - 2, position.lineNumber, wordInfo.endColumn); }; if (ctx.kind === 'value') { return { suggestions: valueSuggestions(ctx.field, range) }; }; if (isValueTrigger) { if (context.triggerCharacter === ' ' && ctx.kind === 'column' && ctx.afterOnEquals && ctx.prefix === '') { return { suggestions: onlyJoinSuggestions(ctx, sql, offset, model) }; }; return { suggestions: [] }; }; if (ctx.kind === 'table') { self._triggerDeListLoad(); var knownTables = Schema.allTables(); return { suggestions: joinSuggestions(ctx, sql, offset, model).concat(knownTables.map(function (t) { return tableItem(t, range, isPrefixUnderscore); })) }; }; if (ctx.kind === 'column') { if ((ctx.afterOn || ctx.afterOnEquals) && ctx.prefix === '' && self._onlyJoinsAt === offset) { var joinsAlone = onlyJoinSuggestions(ctx, sql, offset, model); if (joinsAlone.length) return { suggestions: joinsAlone }; }; return columnSuggestions(ctx, range).then(function (list) { if (token && token.isCancellationRequested) return { suggestions: [] }; return { suggestions: joinSuggestions(ctx, sql, offset, model).concat(list), incomplete: !ctx.alias }; }, function () { return { suggestions: [] }; }); }; if (ctx.afterJoinModifier) return { suggestions: joinKeywordSuggestions(sql, offset, model) }; if (ctx.isAliasNext || (isAliasPlace(ctx) && ctx.prefix.length === 1)) return { suggestions: [] }; if (ctx.afterJoinTable) return { suggestions: joinTableKeywordSuggestions(ctx, range) }; var typedUpper = ctx.prefix.toUpperCase(); var keywords = (SQLData.keywords || []).filter(function (k) { if ((ctx.afterJoinCondition || ctx.afterTable) && k.category === 'case') return false; return !isAliasPlace(ctx) || String(k.name).toUpperCase().indexOf(typedUpper) === 0; }); return { suggestions: keywords.map(function (k) { return keywordItem(k, range); }) }; } }); monacoNS.languages.registerHoverProvider('sfmc-sql', { provideHover: function (model, position) { var wordInfo = model.getWordAtPosition(position); if (!wordInfo) return null; var word = wordInfo.word; var upper = word.toUpperCase(); var range = rangeOf(monacoNS, model, position, wordInfo); var combo = findMultiWordKeyword(model, position, wordInfo); if (combo) return { range: range, contents: withDocsRow(keywordDoc(combo), [combo.docs, combo.officialDocs]) }; var lineBefore = model.getLineContent(position.lineNumber).slice(0, wordInfo.startColumn - 1); var fn = (lineBefore.slice(-2) === '@@' && functionsByName['@@' + upper]) || functionsByName[upper]; if (fn) return { range: fn.name.indexOf('@@') === 0 ? new monacoNS.Range(position.lineNumber, wordInfo.startColumn - 2, position.lineNumber, wordInfo.endColumn) : range, contents: withDocsRow(functionDoc(fn), [fn.docs, fn.officialDocs]) }; var kw = keywordsByName[upper]; if (kw) return { range: range, contents: withDocsRow(keywordDoc(kw), [kw.docs, kw.officialDocs]) }; var lineText = model.getLineContent(position.lineNumber); var isEntPrefixed = /(?:^|[^\w\]])ENT\.\s*$/i.test(lineText.slice(0, wordInfo.startColumn - 1)); var table = Tools.lookupTable(Schema.byName, isEntPrefixed ? 'ENT.' + word : word); if (table) return { range: range, contents: tableHoverContents(table) }; var beforeChar = lineText.charAt(wordInfo.startColumn - 2); if (beforeChar === '.') { var offset = model.getOffsetAt({ lineNumber: position.lineNumber, column: wordInfo.endColumn }); var ctx; try { ctx = Tools.completionContext(model.getValue(), offset, Schema.getIndex()); } catch (e) { ctx = null; }; if (ctx && ctx.table) { return Schema.autoFields(ctx.table).catch(function () { return ctx.table.fields; }).then(function (fields) { var f = (fields || []).filter(function (x) { return x.name.toLowerCase() === word.toLowerCase(); })[0]; return f ? { range: range, contents: [{ value: columnDoc(f, ctx.table) }] } : null; }); }; }; return null; } }); monacoNS.languages.registerSignatureHelpProvider('sfmc-sql', { signatureHelpTriggerCharacters: ['(', ','], provideSignatureHelp: function (model, position) { var tokens; try { tokens = Tools.tokenize(model.getValue()); } catch (e) { return null; }; var offset = model.getOffsetAt(position); var call = findEnclosingCall(tokens, offset); if (!call) return null; var fn = functionsByName[call.name.toUpperCase()]; if (!fn) return null; var params = parseParams(fn.signature); var sig = { label: fn.signature, documentation: { value: fn.description || '' }, parameters: params.map(function (p) { return { label: p }; }) }; var active = Math.min(call.activeParameter, Math.max(0, params.length - 1)); return { value: { signatures: [sig], activeSignature: 0, activeParameter: active }, dispose: function () {} }; } }); var problemActionCommand = null; if (monacoNS.editor && typeof monacoNS.editor.registerCommand === 'function') { monacoNS.editor.registerCommand('sqlstudio.problemAction', function () { var id = Array.prototype.slice.call(arguments).filter(function (arg) { return typeof arg === 'string' && Object.prototype.hasOwnProperty.call(PROBLEM_ACTIONS, arg); })[0]; if (id) runProblemAction(id); }); problemActionCommand = 'sqlstudio.problemAction'; }; monacoNS.languages.registerCodeActionProvider('sfmc-sql', { provideCodeActions: function (model, range) { var actions = []; if (!self.isLintCurrent()) return { actions: actions, dispose: function () {} }; self._lastFindings.forEach(function (f) { var hasFixes = !!f.fixes && f.fixes.length > 0; var hasAction = !!f.action && !!problemActionCommand && Object.prototype.hasOwnProperty.call(PROBLEM_ACTIONS, f.action.id); if (!hasFixes && !hasAction) return; var overlaps = f.start <= model.getOffsetAt({ lineNumber: range.endLineNumber, column: range.endColumn }) && f.end >= model.getOffsetAt({ lineNumber: range.startLineNumber, column: range.startColumn }); if (!overlaps) return; if (hasAction && !hasFixes) { actions.push({ title: f.action.title, kind: 'quickfix', isPreferred: true, command: { id: problemActionCommand, title: f.action.title, arguments: [f.action.id] } }); }; (f.fixes || []).forEach(function (fix, idx) { actions.push({ title: fix.isSafe ? fix.title : (fix.title + ' (changes results)'), kind: 'quickfix', isPreferred: idx === 0, edit: { edits: fix.edits.map(function (e) { var startPos = model.getPositionAt(e.start); var endPos = model.getPositionAt(e.end); return { resource: model.uri, textEdit: { range: new monacoNS.Range(startPos.lineNumber, startPos.column, endPos.lineNumber, endPos.column), text: e.text } }; }) } }); }); }); return { actions: actions, dispose: function () {} }; } }); }; function findMultiWordKeyword(model, position, wordInfo) { var prevInfo = wordInfo.startColumn > 1 ? model.getWordAtPosition({ lineNumber: position.lineNumber, column: wordInfo.startColumn - 1 }) : null; var nextInfo = model.getWordAtPosition({ lineNumber: position.lineNumber, column: wordInfo.endColumn + 1 }); var candidates = []; if (prevInfo) candidates.push((prevInfo.word + ' ' + wordInfo.word).toUpperCase()); if (nextInfo) candidates.push((wordInfo.word + ' ' + nextInfo.word).toUpperCase()); for (var i = 0; i < multiWordKeywords.length; i++) { if (candidates.indexOf(multiWordKeywords[i].name.toUpperCase()) !== -1) return multiWordKeywords[i]; }; return null; }; function findEnclosingCall(tokens, offset) { var stack = []; for (var i = 0; i < tokens.length; i++) { var t = tokens[i]; if (t.start >= offset) break; if (t.type === 'punct' && t.value === '(') stack.push(i); else if (t.type === 'punct' && t.value === ')') { if (stack.length) stack.pop(); } }; if (!stack.length) return null; var openIdx = stack[stack.length - 1]; var nameTok = openIdx > 0 ? tokens[openIdx - 1] : null; if (!nameTok || nameTok.type !== 'identifier') return null; var commaCount = 0, innerDepth = 0; for (var k = openIdx + 1; k < tokens.length; k++) { var tk = tokens[k]; if (tk.start >= offset) break; if (tk.type === 'punct' && tk.value === '(') innerDepth++; else if (tk.type === 'punct' && tk.value === ')') innerDepth--; else if (tk.type === 'punct' && tk.value === ',' && innerDepth === 0) commaCount++; }; return { name: nameTok.value, activeParameter: commaCount }; }; function parseParams(signature) { var open = signature.indexOf('('); var close = signature.lastIndexOf(')'); if (open === -1 || close === -1 || close <= open) return []; var inner = signature.slice(open + 1, close).trim(); if (!inner) return []; var parts = []; var depth = 0, cur = ''; for (var i = 0; i < inner.length; i++) { var c = inner.charAt(i); if (c === '(') depth++; if (c === ')') depth--; if (c === ',' && depth === 0) { parts.push(cur.trim()); cur = ''; } else cur += c; }; if (cur.trim()) parts.push(cur.trim()); return parts; }; var SUGGEST_DETAILS_WIDTH = 420; function suggestDetailsWanted() { if (Editor._suggestDetailsMemory !== null) return Editor._suggestDetailsMemory; var stored = safeGet(suggestDetailsKey()); if (stored === '0') return false; return true; }; function rememberSuggestDetails(isOpen) { Editor._suggestDetailsMemory = isOpen; safeSet(suggestDetailsKey(), isOpen ? '1' : '0'); }; Editor._watchSuggestDetails = function () { var self = this; var controller = this.instance.getContribution('editor.contrib.suggestController'); var widget = controller && controller.widget && controller.widget.value; var overlay = widget && widget._details; var listEl = widget && widget.element && widget.element.domNode; var panelEl = overlay && typeof overlay.getDomNode === 'function' ? overlay.getDomNode() : null; var textEl = overlay && overlay.widget ? overlay.widget.domNode : null; if (!listEl || !panelEl || !textEl || typeof widget._isDetailsVisible !== 'function' || typeof widget.toggleDetails !== 'function' || typeof widget.onDidFocus !== 'function' || typeof widget.onDidShow !== 'function') return; var isSyncing = false; function isOpen() { return !!widget._isDetailsVisible(); }; var lastSeen = isOpen(); function readBack() { var now = isOpen(); if (now === lastSeen) return; lastSeen = now; if (!isSyncing) rememberSuggestDetails(now); }; function syncChoice() { if (isOpen() === suggestDetailsWanted()) return; isSyncing = true; try { widget.toggleDetails(); } catch (e) { } finally { isSyncing = false; }; }; var toggleDetails = widget.toggleDetails; widget.toggleDetails = function () { var result = toggleDetails.apply(widget, arguments); readBack(); return result; }; if (typeof overlay.widget.onDidClose === 'function') overlay.widget.onDidClose(readBack); widget.onDidShow(syncChoice); widget.onDidFocus(syncChoice); var renderItem = overlay.widget.renderItem; if (typeof renderItem === 'function') { overlay.widget.renderItem = function (item) { var label = item && item.completion ? item.completion.label : null; textEl.classList.toggle('ss-join-details', typeof label === 'string' && Object.prototype.hasOwnProperty.call(self._joinLabels, label)); return renderItem.apply(overlay.widget, arguments); }; }; function markPanel() { var place = ''; if (listEl && panelEl.isConnected && listEl.classList.contains('visible') && listEl.classList.contains('shows-details')) { var list = listEl.getBoundingClientRect(); var panel = panelEl.getBoundingClientRect(); if (panel.width && list.width) { if (panel.top >= list.bottom - 3) place = 'below'; else if (panel.left >= list.right - 6) place = 'right'; else if (panel.right <= list.left + 6) place = 'left'; }; }; [listEl, panelEl].forEach(function (el) { if (!el) return; if (place) el.setAttribute('data-ss-details', place); else el.removeAttribute('data-ss-details'); }); }; if (global.MutationObserver && listEl) { var observer = new global.MutationObserver(markPanel); observer.observe(listEl, { attributes: true, attributeFilter: ['class'] }); observer.observe(panelEl, { attributes: true, attributeFilter: ['style'] }); observer.observe(textEl, { childList: true, subtree: true }); }; try { overlay.widget.layout(SUGGEST_DETAILS_WIDTH, 0); } catch (e) { }; }; function referencedTopLevelTableNames(sql) { var names = {}; try { Tools.parse(sql, Schema.getIndex()).from.forEach(function (f) { if (f.kind === 'table' && !f.isFunction && f.name) names[f.name.replace(/^\[|\]$/g, '')] = true; }); } catch (e) { /* ignore */ } return Object.keys(names); }; function tablesToTypeBy(sql) { var found = { tables: [], unlisted: [] }; var parsed; try { parsed = Tools.parse(sql, Schema.getIndex()); } catch (e) { return found; }; var cteNames = {}; (parsed.ctes || []).forEach(function (c) { cteNames[String(c.name).toUpperCase()] = true; }); var seen = {}; var add = function (name, table) { var key = table ? Schema.tableKey(table).toLowerCase() : 'name:' + name.toLowerCase(); if (seen[key]) return; seen[key] = true; if (!table) { found.unlisted.push(name); } else if (table.kind === 'de' && !table.fields) { found.tables.push(table); }; }; (parsed.from || []).forEach(function (f) { if (f.kind !== 'table' || f.isFunction || !f.name) return; var name = f.name.replace(/^\[|\]$/g, ''); if (f.nameParts === 1 && cteNames[name.toUpperCase()]) return; add(name, Tools.lookupTable(Schema.byName, name)); }); var read = []; try { read = Tools.inferColumnTypeTables(sql, Schema.getIndex()); } catch (e) { read = []; }; read.forEach(function (t) { if (!t.isTopLevel) add(t.name, t.table); }); return found; }; function offsetToLineCol(sql, offset) { var line = 1, col = 1; var n = Math.min(offset, sql.length); for (var i = 0; i < n; i++) { if (sql.charAt(i) === '\n') { line++; col = 1; } else col++; }; return { line: line, col: col }; }; function shiftEdits(edits, baseOffset) { return (edits || []).map(function (e) { return { start: e.start + baseOffset, end: e.end + baseOffset, text: e.text }; }); }; function isRunInFlight(s) { return s === 'validating' || s === 'preparing' || s === 'running' || s === 'fetching'; }; function setRunScope(runner, sql, offset) { var scope = Tools.runScope(sql, Editor.getValue(), offset); runner.currentRunIsFragment = scope !== false; runner.currentRunContext = (scope && typeof scope === 'object') ? scope : null; runner.hasStaleShownStatus = false; }; function savedRunContext(context) { if (!context || typeof context.before !== 'string' || typeof context.after !== 'string') return null; return { before: context.before, after: context.after }; }; function isRunStale(tab) { var runner = tab.runner; if (!Editor.mode || tab.state.runnerState !== 'done' || !tab.state.activeRun || !runner.currentRunStartedAt) return false; if (!runner.currentRunSql || !runner.currentRunSql.trim()) return false; var scope = runner.currentRunIsFragment ? (runner.currentRunContext || true) : false; return !Tools.sameQuery(runner.currentRunSql, Editor.textFor(tab), scope); }; function lineCommentTexts(sql) { try { return Tools.tokenize(String(sql || '')).filter(function (t) { return t.type === 'comment' && t.raw.indexOf('--') === 0; }).map(function (t) { return t.raw.replace(/\s+$/, ''); }); } catch (e) { return []; }; }; function endsInLineComment(sql) { try { var text = String(sql || ''); var tokens = Tools.tokenize(text); var last = tokens[tokens.length - 1]; return !!last && last.type === 'comment' && last.raw.indexOf('--') === 0 && text.slice(last.end).indexOf('\n') === -1; } catch (e) { return false; }; }; function isErrorStale(tab) { var about = tab.state.statusError && tab.state.statusError.about; if (!Editor.mode || !about || typeof about.sql !== 'string' || !about.sql.trim()) return false; var scope = about.isFragment ? (about.context || true) : false; var text = Editor.textFor(tab); if (!Tools.sameQuery(about.sql, text, scope)) return true; if (about.isRun !== true) return false; var remaining = lineCommentTexts(text); var ran = lineCommentTexts(about.sql); return ran.some(function (comment, i) { var at = remaining.indexOf(comment); if (at === -1 && i === ran.length - 1 && endsInLineComment(about.sql)) { for (var k = 0; k < remaining.length && at === -1; k++) { if (remaining[k].indexOf(comment) === 0) at = k; }; }; if (at === -1) return true; remaining.splice(at, 1); return false; }); }; function isFailedRunStale(tab) { var about = tab.state.statusError && tab.state.statusError.about; return tab.state.runnerState === 'failed' && !!about && about.isRun === true && isErrorStale(tab); }; function errorAbout(sql, isFragment, context, isRun) { return { sql: String(sql || ''), isFragment: !!isFragment, context: context || null, isRun: !!isRun }; }; function isPageReadRefusal(err) { return !!err && (err.code === 'RATE_LIMITED' || err.code === 'NETWORK'); }; function unreadPageOf(tabState) { var unread = tabState.unreadPage; if (!unread || tabState.runnerState !== 'done' || tabState.results || tabState.activeRun !== unread.run) return null; return unread; }; function knownRowCount(run) { return (run && typeof run.lastRowCount === 'number' && run.lastRowCount > 0) ? run.lastRowCount : null; }; function runBlockingFindings(findings, sql) { var dialogRanges = null; if (typeof sql === 'string') { dialogRanges = {}; try { Tools.extractColumns(sql, Schema.getIndex()).issues.forEach(function (issue) { if (issue.code === 'UNNAMED' || issue.code === 'DUPLICATE') dialogRanges[issue.start + ':' + issue.end] = true; }); } catch (e) { }; }; return (findings || []).filter(function (f) { if (f.severity !== 'error' || f.id === 'MCE050') return false; if (f.id !== 'MCE010' && f.id !== 'MCE011') return true; return !!dialogRanges && !dialogRanges[f.start + ':' + f.end]; }); }; function lintGateMessage(count) { return count + ' error' + (count > 1 ? 's' : '') + ' must be fixed before running.'; }; function saveBlockingFindings(findings) { return (findings || []).filter(function (f) { return f.severity === 'error'; }); }; function saveLintErrors(sql, target) { var context = Editor.lintContext(); if (target) { context.targetKey = target.key || null; context.targetName = target.name || null; context.targetAction = target.action || null; }; var findings = []; try { findings = Editor.withServerVerdict(sql, Tools.lint(sql, context)); } catch (e) { }; return saveBlockingFindings(findings); }; function saveGateMessage(count) { return count + ' error' + (count > 1 ? 's' : '') + ' must be fixed before saving.'; }; function clearSaveGate(tab) { if (!tab.state.statusError || tab.state.statusError.gate !== 'save') return; if (tab === QueryTabs.active()) { UI.clearStatusError(); } else { tab.state.statusError = null; }; }; function lintErrorsBox() { var box = h('div', { class: 'ss-message-item ss-message-item--error ss-save__gate', role: 'alert', hidden: true, 'data-save': 'lint-gate' }, null); var shownKey = null; return { el: box, show: function (errors) { var key = JSON.stringify(errors.map(function (f) { return [f.id, f.message]; })); if (!box.hidden && key === shownKey) return; shownKey = key; clearNode(box); box.appendChild(h('div', { class: 'ss-save__gate-title', 'data-save': 'lint-gate-title' }, saveGateMessage(errors.length))); box.appendChild(h('ul', { class: 'ss-save__gate-list' }, errors.map(function (f) { return h('li', { 'data-save': 'lint-gate-error' }, [h('strong', { class: 'ss-save__gate-id' }, f.id), ' ', f.message]); }))); box.hidden = false; }, hide: function () { shownKey = null; clearNode(box); box.hidden = true; } }; }; function severalQueriesMessage(count) { return 'This text holds ' + count + ' queries, and the query check reads one. Select the query to validate, then click Validate.'; }; var RUN_PHASE_LABELS = { validating: 'Checking the query', preparing: 'Starting the query in Marketing Cloud Engagement', running: 'Query running in Marketing Cloud Engagement', fetching: 'Loading results' }; function runResultsWhen(phase, where) { if (phase === 'fetching') return 'The results open ' + where + ' once they load.'; return 'The results open ' + where + ' when Marketing Cloud Engagement finishes the query.'; }; function hintTable(rows) { return h('table', { class: 'ss-hint__table' }, h('tbody', null, rows.map(function (row) { return h('tr', null, [h('td', null, row[0]), h('td', null, row[1])]); }))); }; function runStepsHint(startedAt, phaseAt) { var fieldWait = (phaseAt.typing && phaseAt.typed && phaseAt.typed > phaseAt.typing) ? phaseAt.typed - phaseAt.typing : 0; var steps = [ ['Checking the query', startedAt, phaseAt.preparing, 0], ['Loading the fields of its Data Extensions', phaseAt.typing, phaseAt.typed, 0], ['Starting it in Marketing Cloud Engagement', phaseAt.preparing, phaseAt.running, fieldWait], ['Queued and running in Marketing Cloud Engagement', phaseAt.running, phaseAt.fetching, 0], ['Loading the results', phaseAt.fetching, phaseAt.done, 0] ]; if (!fieldWait) steps.splice(1, 1); var rows = steps.filter(function (step) { return step[1] && step[2] && step[2] >= step[1]; }).map(function (step) { return [step[0], fmtDuration(step[2] - step[1] - step[3])]; }); if (rows.length < 2) return null; return h('div', null, [h('p', { class: 'ss-hint__title' }, 'Where the time went'), hintTable(rows)]); }; function runDetailsHint(lines) { if (!lines || !lines.length) return null; return h('div', null, [h('p', { class: 'ss-hint__title' }, 'About this run')].concat(lines.map(function (line) { return h('p', { class: 'ss-hint__line' }, line); }))); }; function joinHints(parts) { var present = parts.filter(Boolean); if (!present.length) return null; if (present.length === 1) return present[0]; var joined = h('div', null, null); present.forEach(function (part) { while (part.firstChild) joined.appendChild(part.firstChild); }); return joined; }; function appliedColumnTypes(types, count) { var applied = []; for (var i = 0; i < count; i++) { var entry = Array.isArray(types) ? types[i] : null; applied.push(entry && typeof entry === 'object' && typeof entry.type === 'string' ? entry : null); }; return applied; }; var RERUN_AS_TEXT_MESSAGE = 'The run failed with typed columns, so SQL Studio ran it again with every column as Text.'; function typedColumnsSummary(columns, columnTypes) { var parts = []; columnTypes.forEach(function (t, i) { if (!t) return; parts.push(columns[i] + ' ' + t.type + (t.type === 'Decimal' ? '(' + t.precision + ',' + t.scale + ')' : '')); }); return parts.join(', ') || 'none'; }; function couldBeTypeFailure(status, errorMsg) { var said = String(status || '') + ' ' + String(errorMsg || ''); if (/time ?out|timed ?out|cancel|abort|stopped/i.test(said)) return false; if (!String(errorMsg || '').trim()) return true; return /conver|overflow|arithmetic|out of range|cast|data type/i.test(errorMsg); }; function hasTypedColumns(columnTypes) { return Array.isArray(columnTypes) && columnTypes.some(function (t) { return !!t; }); }; var DISPLAY_TYPES = ['Date', 'Decimal', 'Boolean']; function withDisplayDates(res, columnTypes) { if (!res || !Array.isArray(res.rows) || !Array.isArray(columnTypes)) return res; var rewritten = []; columnTypes.forEach(function (t, i) { if (t && DISPLAY_TYPES.indexOf(t.type) !== -1) rewritten.push(i); }); if (!rewritten.length) return res; res.rows = res.rows.map(function (row) { var shown = row.slice(); rewritten.forEach(function (i) { shown[i] = Tools.formatTypedValue(shown[i], columnTypes[i]); }); return shown; }); return res; }; function dateSampleNote(res, columnTypes) { if (!res || !Array.isArray(res.rows) || !Array.isArray(columnTypes) || !Array.isArray(res.columns)) return ''; var note = ''; DISPLAY_TYPES.forEach(function (typeName) { for (var c = 0; c < columnTypes.length; c++) { if (!columnTypes[c] || columnTypes[c].type !== typeName) continue; for (var r = 0; r < res.rows.length; r++) { var raw = res.rows[r][c]; if (raw === null || raw === undefined || raw === '') continue; note += ' ' + typeName + ' column ' + res.columns[c] + ' came back as "' + raw + '" (' + typeof raw + '), shown as "' + Tools.formatTypedValue(raw, columnTypes[c]) + '".'; return; }; }; }); return note; }; function logDatePage(res, columnTypes) { var note = dateSampleNote(res, columnTypes); if (!note || typeof console === 'undefined') return; console.info('[SQL Studio] Page ' + res.page + ' read ' + (res.readPath === 'free' ? 'without an API call.' : 'through the REST API.') + note); }; function isBadFreeRead(res, knownCount) { var rows = (res && res.rows) || []; return typeof knownCount === 'number' && !!res && res.readPath !== 'rest' && ((typeof res.count === 'number' && res.count !== knownCount) || (rows.length === 0 && knownCount > 0)); }; function isTrustedFreeExport(res, knownCount) { return !!res && res.readPath === 'free' && !isBadFreeRead(res, knownCount) && res.count === knownCount && Array.isArray(res.rows) && res.rows.length === knownCount; }; function readCheckedPage(call, payload, knownCount, label, readAgain) { return call(payload).then(function (res) { if (!isBadFreeRead(res, knownCount)) return res; if (typeof console !== 'undefined') { console.info('[SQL Studio] ' + label + ' counted ' + res.count + ' rows where ' + knownCount + ' were counted before, so it is read again through the REST API.'); }; return readAgain(Object.assign({}, payload, { restOnly: true })); }); }; function compareCells(a, b, columnType) { if (columnType && (columnType.type === 'Number' || columnType.type === 'Decimal')) { var an = parseFloat(a); var bn = parseFloat(b); if (!isNaN(an) && !isNaN(bn)) return an === bn ? 0 : (an > bn ? 1 : -1); }; return a > b ? 1 : (a < b ? -1 : 0); }; var CALL_LABELS = { validateQuery: 'Validating the SQL', getWorkspace: 'Finding the SQL Studio folders', runQuery: 'Creating the results Data Extension and starting the query', getRunStatus: 'Query status checks', getRunOutcome: 'Asking Marketing Cloud Engagement how a 0-row run ended', getResults: 'Results pages', deleteRun: 'Deleting the previous temp DE', whoami: 'Session check' }; var WSPROXY_CALL_LABELS = { getRunStatus: 'Query status checks', runQuery: 'Checking the query activity', deleteRun: 'Deleting the previous temp DE', exportRows: 'Export batches' }; function tallyRunCalls(entries) { var tally = { api: [], wsproxy: [], freeCounts: 0, freePages: 0 }; var add = function (pairs, action, calls) { for (var i = 0; i < pairs.length; i++) { if (pairs[i][0] === action) { pairs[i][1] += calls; return; }; }; pairs.push([action, calls]); }; entries.forEach(function (entry) { if (entry.wsproxyCalls) add(tally.wsproxy, entry.action, entry.wsproxyCalls); if (entry.isFree) { if (entry.action === 'getRunStatus') tally.freeCounts += entry.times || 1; if (entry.action === 'getResults') tally.freePages += entry.times || 1; return; }; if (entry.apiCalls) add(tally.api, entry.action, entry.apiCalls); }); return tally; }; var SAVED_CALL_ACTIONS_MAX = 40; function restoredRunCalls(saved) { if (!saved || typeof saved !== 'object' || !Array.isArray(saved.api) || !Array.isArray(saved.wsproxy)) return null; var calls = []; var callEntry = function (action, apiCalls, wsproxyCalls, isFree, times) { var entry = { action: action, apiCalls: apiCalls, wsproxyCalls: wsproxyCalls, isFree: isFree }; if (times) entry.times = times; return entry; }; var countOf = function (n) { return (typeof n === 'number' && isFinite(n) && n >= 1) ? Math.floor(n) : 0; }; var addPairs = function (pairs, isWsproxy) { pairs.slice(0, SAVED_CALL_ACTIONS_MAX).forEach(function (pair) { var n = Array.isArray(pair) ? countOf(pair[1]) : 0; if (!n || typeof pair[0] !== 'string' || !pair[0] || pair[0].length > 60) return; calls.push(callEntry(pair[0], isWsproxy ? 0 : n, isWsproxy ? n : 0, false)); }); }; addPairs(saved.api, false); addPairs(saved.wsproxy, true); if (countOf(saved.freeCounts)) calls.push(callEntry('getRunStatus', 0, 0, true, countOf(saved.freeCounts))); if (countOf(saved.freePages)) calls.push(callEntry('getResults', 0, 0, true, countOf(saved.freePages))); return calls; }; function runCallsHint(entries) { var tally = tallyRunCalls(entries); var parts = []; var sumOf = function (pairs) { return pairs.reduce(function (sum, pair) { return sum + pair[1]; }, 0); }; var paidTotal = sumOf(tally.api); var proxiedTotal = sumOf(tally.wsproxy); if (proxiedTotal) { parts.push(h('p', { class: 'ss-hint__split' }, apiCallsText(paidTotal) + ' through the API, and ' + wsproxyCallsText(proxiedTotal) + ' through WSProxy inside Marketing Cloud Engagement, which Salesforce is said not to count against the API limit')); }; if (tally.api.length) { parts.push(h('p', { class: 'ss-hint__title' }, 'Through the API')); parts.push(hintTable(tally.api.map(function (pair) { return [CALL_LABELS[pair[0]] || pair[0], apiCallsText(pair[1])]; }))); }; if (tally.wsproxy.length) { parts.push(h('p', { class: 'ss-hint__title' }, 'Through WSProxy')); parts.push(hintTable(tally.wsproxy.map(function (pair) { return [WSPROXY_CALL_LABELS[pair[0]] || CALL_LABELS[pair[0]] || pair[0], wsproxyCallsText(pair[1])]; }))); }; var freeRows = []; if (tally.freeCounts) freeRows.push(['Row count checks', fmtNumber(tally.freeCounts)]); if (tally.freePages) freeRows.push(['Results pages', fmtNumber(tally.freePages)]); if (freeRows.length) { parts.push(h('p', { class: 'ss-hint__title' }, 'Done without any call')); parts.push(hintTable(freeRows)); }; return parts.length ? h('div', null, parts) : null; }; function hintTrigger(text, content, href) { if (!content) return document.createTextNode(text); var trigger = href ? h('a', { class: 'ss-run-summary__hint ss-run-summary__link', href: href, target: '_blank', rel: 'noopener noreferrer', 'aria-expanded': 'false' }, text) : h('span', { class: 'ss-run-summary__hint', tabindex: '0', role: 'button', 'aria-expanded': 'false' }, text); var showTimer = null; trigger.addEventListener('mouseenter', function () { clearTimeout(showTimer); showTimer = setTimeout(function () { UI.showHint(trigger, content, false); }, 150); }); trigger.addEventListener('mouseleave', function () { clearTimeout(showTimer); UI.hideHint(trigger, false); }); trigger.addEventListener('focus', function () { UI.showHint(trigger, content, false); }); trigger.addEventListener('blur', function () { UI.hideHint(trigger, false); }); if (!href) trigger.addEventListener('click', function (ev) { ev.stopPropagation(); UI.showHint(trigger, content, true); }); return trigger; }; function dataExtensionUrl(stackKey, deId) { if (typeof stackKey !== 'string' || !STACK_KEY_RE.test(stackKey)) return null; if (typeof deId !== 'string' || !DE_ID_RE.test(deId)) return null; return 'https://mc.' + stackKey.toLowerCase() + '.marketingcloudapps.com/contactsmeta/admin.html#admin/data-extension/' + deId + '/properties/'; }; function tempDeHint(deKey, isLink) { var parts = [h('p', { class: 'ss-hint__title' }, 'Temporary Data Extension'), h('p', { class: 'ss-hint__line' }, deKey)]; if (isLink) parts.push(h('p', { class: 'ss-hint__line' }, 'Click to open it in Contact Builder.')); return h('div', null, parts); }; function spinner() { var el = h('span', { class: 'ss-spinner', 'aria-hidden': 'true' }, null); el.style.animationDelay = '-' + (Date.now() % SPINNER_TURN_MS) + 'ms'; return el; }; function pollInterval(elapsed) { return Math.min(POLL_SLOW_MS, Math.max(POLL_FAST_MS, Math.round(elapsed * POLL_WAIT_SHARE))); }; function nextPollWait(run, elapsed) { return run.isCountUnreadable ? pollInterval(elapsed) : (elapsed < POLL_FREE_FAST_WINDOW_MS ? POLL_FREE_FAST_MS : POLL_FREE_SLOW_MS); }; var loggedUnknownRunStatuses = {}; function logUnknownRunStatus(status) { var text = String(status === null || status === undefined ? '' : status); if (loggedUnknownRunStatuses[text] || typeof console === 'undefined') return; loggedUnknownRunStatuses[text] = true; console.info('[SQL Studio] Unknown run status "' + text + '", checking the run the old way.'); }; var MCE_UTC_OFFSET_HOURS = -6; function parseMceDateTime(text) { var value = String(text === null || text === undefined ? '' : text).replace(/^\s+|\s+$/g, ''); var parts = /^(\d{1,2})\/(\d{1,2})\/(\d{4}) (\d{1,2}):(\d{2}):(\d{2}) ?(AM|PM)$/i.exec(value); if (parts) { var hours = Number(parts[4]) % 12 + (parts[7].toUpperCase() === 'PM' ? 12 : 0); return Date.UTC(Number(parts[3]), Number(parts[1]) - 1, Number(parts[2]), hours - MCE_UTC_OFFSET_HOURS, Number(parts[5]), Number(parts[6])); }; if (/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(\.\d+)?(Z|[+-]\d{2}:\d{2})$/.test(value)) { var ms = Date.parse(value); return isFinite(ms) ? ms : null; }; return null; }; function logRunCompletion(taskStatus, startedAt, checkCount, previousCheckAt) { try { if (typeof console === 'undefined' || !startedAt) return; var now = Date.now(); var raw = String(taskStatus.completedDate === null || taskStatus.completedDate === undefined ? '' : taskStatus.completedDate); var completedAt = parseMceDateTime(raw); if (completedAt !== null && (completedAt < startedAt - 60000 || completedAt > now + 60000)) completedAt = null; var check = 'on status check ' + checkCount + (previousCheckAt ? ', ' + fmtSeconds(now - previousCheckAt) + ' after the check before it' : ''); var text = completedAt !== null ? 'MCE completed the query ' + fmtSeconds(Math.max(0, completedAt - startedAt)) + ' after the start, by its status row\'s CompletedDate (' + raw + '). SQL Studio read that ' + fmtSeconds(Math.max(0, now - completedAt)) + ' later, ' + check + '.' : 'MCE\'s status row says the query is complete' + (raw ? ', with the CompletedDate ' + raw : ', with no CompletedDate') + '. SQL Studio read that ' + fmtSeconds(now - startedAt) + ' after the start, ' + check + '.'; console.info('[SQL Studio] ' + text); } catch (e) { }; }; function isRunStatusDue(run, elapsed) { var isFreeTaskStatus = run.taskStatusVia !== 'soap' && run.taskStatusVia !== 'none'; if (run.taskId && run.taskSig && !run.isTaskStatusUnknown && isFreeTaskStatus && Session.isBackendBu()) return true; if (run.isConfirmDue) return true; if (!run.statusCheckedAt) return elapsed >= POLL_FIRST_STATUS_MS; var gap = Math.min(POLL_STATUS_MAX_MS, Math.max(POLL_STATUS_MIN_MS, elapsed * POLL_STATUS_SHARE)); if (run.statusCheckCount === 1 || run.statusCheckCount === 2) gap = POLL_EARLY_STATUS_GAP_MS; if (run.notRunningCount > 0 && !run.seenRunning && !run.outcomePending) gap = POLL_STATUS_RECHECK_MS; return Date.now() - run.statusCheckedAt >= gap; }; function isTaskRowAwaited(run, elapsed) { return !!run.taskId && !run.isTaskStatusUnknown && !run.hasTaskRow && run.taskStatusVia !== 'none' && !run.isConfirmDue && elapsed < TASK_ROW_WAIT_MS; }; function isRowProbeDue(run, elapsed) { if (!run.rowProbedAt) return elapsed >= POLL_FIRST_STATUS_MS; var gap = Math.min(POLL_STATUS_MAX_MS, Math.max(POLL_STATUS_MIN_MS, elapsed * POLL_STATUS_SHARE)); if (run.rowProbeCount === 1 || run.rowProbeCount === 2) gap = POLL_EARLY_STATUS_GAP_MS; return Date.now() - run.rowProbedAt >= gap; }; var runStartQueue = Promise.resolve(); function isFolderRefusal(err) { return !!err && err.code === 'MCE_ERROR' && /categor|folder/i.test(String(err.message) + ' ' + String(err.details || '')); }; function QueryRunner(tab) { this.tab = tab; this.attempt = 0; this.hasRunStarted = false; this.currentRunOffset = 0; this.messages = []; this.currentRunSql = ''; this.currentRunIsFragment = false; this.currentRunContext = null; this.hasStaleShownStatus = false; this.currentRunStartedAt = null; this.currentRunFinishedAt = null; this.phaseAt = {}; this._calls = null; this.hasRerunAsText = false; this._isRenewingBeforeRun = false; this.nextPollAt = 0; }; QueryRunner.prototype.setState = function (s) { var tab = this.tab; var wasInFlight = isRunInFlight(tab.state.runnerState); tab.state.runnerState = s; if (this.phaseAt && s !== 'idle' && !this.phaseAt[s]) this.phaseAt[s] = Date.now(); if (tab === QueryTabs.active()) { UI.renderToolbarState(s); UI.renderRunSummary(); if (wasInFlight || isRunInFlight(s)) UI.renderResults(); UI._refreshStatusEmptyState(); if (s === 'done') UI.setActiveTab('results'); else if (s === 'failed' || s === 'cancelled') UI.setActiveTab('status'); } else if (s === 'done') { tab.state.bottomPanel = 'results'; } else if (s === 'failed' || s === 'cancelled') { tab.state.bottomPanel = 'status'; }; QueryTabs.onTabChanged(tab, wasInFlight && (s === 'done' || s === 'failed')); }; QueryRunner.prototype.addMessage = function (kind, text, details) { this.messages.push({ kind: kind, text: text, details: details || null }); }; QueryRunner.prototype._recordHistory = function (status) { var run = this.hasRunStarted ? this.tab.state.activeRun : null; var startedAt = this.currentRunStartedAt || Date.now(); var entry = { id: uid(), sql: this.currentRunSql, startedAt: new Date(startedAt).toISOString(), durationMs: (this.currentRunFinishedAt || Date.now()) - startedAt, rowCount: status === 'done' ? (this.tab.state.results ? this.tab.state.results.count : (knownRowCount(run) || 0)) : 0, status: status, deKey: run ? run.deKey : null }; History.add(entry); if (Session.data.historyEnabled) { Api.call('addHistory', { sql: entry.sql, rowCount: entry.rowCount, durationMs: entry.durationMs, status: status, deKey: entry.deKey || undefined }).catch(function () { /* fire and forget */ }); }; }; QueryRunner.prototype._failFinalize = function (hasRunAnywayLink) { this.currentRunFinishedAt = Date.now(); this.setState('failed'); this._recordHistory('failed'); UI.reportRunOutcome(this, hasRunAnywayLink); }; QueryRunner.prototype._fail = function (kind, text, details, err) { this.addMessage(kind || 'error', text, details); if (err && err.isConnection) this.addMessage('hint', CONNECTION_HINT); this._failFinalize(); }; QueryRunner.prototype.run = function (options) { options = options || {}; var tab = this.tab; if (isRunInFlight(tab.state.runnerState)) return; if (tab.activeExport) { Toast.show('This tab is exporting its results. Run again when the export has ended.', 'info'); return; }; if (tab.activeSave) { Toast.show('This tab is saving its results to a Data Extension. Run again when the save has ended.', 'info'); return; }; this.messages = []; Editor.clearServerMarkers(); var target = Editor.getRunTarget(); var sql = target.sql; if (!sql || !sql.trim()) { Toast.show('Nothing to run: the editor is empty.', 'warning'); return; }; var sessionSeconds = Session.secondsLeft(); if (sessionSeconds !== null && sessionSeconds < SESSION_MIN_SECONDS_TO_RUN) { if (options.hasRenewed) { Session.expire('SESSION_ENDING'); return; }; if (this._isRenewingBeforeRun) return; var self = this; self._isRenewingBeforeRun = true; Toast.show('Renewing your session first. The query runs right after.', 'info'); Session.renewSilently().then(function () { self._isRenewingBeforeRun = false; if (self.tab === QueryTabs.active()) self.run({ skipLintGate: options.skipLintGate, hasRenewed: true }); }, function () { self._isRenewingBeforeRun = false; Session.expire('SESSION_ENDING'); }); return; }; UI.clearRunOutcome(); this.attempt++; tab._restoringEntry = null; this.hasRunStarted = false; this.hasRerunAsText = false; this.currentRunSql = sql; this.currentRunOffset = target.offset; setRunScope(this, sql, target.offset); this.currentRunStartedAt = Date.now(); this.currentRunFinishedAt = null; this.phaseAt = {}; this._calls = []; tab.state.runDetails = []; var previousRun = tab.state.activeRun; if (previousRun) { tab.state.activeRun = null; tab.state.results = null; Grid.setData(tab, null); this._deleteRunDe(previousRun); QueryTabs.markChanged(tab); }; this.setState('validating'); var findings = []; try { findings = Editor.withServerVerdict(sql, Tools.lint(sql, Editor.lintContext(true))); } catch (e) { }; UI.renderProblems(Editor.toDocumentFindings(findings, target.offset)); var errors = runBlockingFindings(findings, sql); if (errors.length && !options.skipLintGate) { this.addMessage('error', lintGateMessage(errors.length)); this._failFinalize(true); return; }; this._prepareColumns(sql, false); }, QueryRunner.prototype._prepareColumns = function (sql, hasRetriedStar) { var self = this; var extraction; try { extraction = Tools.extractColumns(sql, Schema.getIndex()); } catch (e) { this._fail('error', 'Could not read the SELECT list: ' + e.message); return; }; var starIssues = extraction.issues.filter(function (i) { return i.code === 'STAR_UNRESOLVED'; }); if (starIssues.length && !hasRetriedStar) { var names = referencedTopLevelTableNames(sql); Promise.all(names.map(function (n) { return Schema.ensureFieldsFor(n).catch(function () { return null; }); })).then(function () { self._prepareColumns(sql, true); }); return; }; if (starIssues.length && hasRetriedStar) { this._fail('error', 'Cannot expand * for: ' + starIssues.map(function (i) { return i.message; }).join(' ')); return; }; var aliasNeededIssues = extraction.issues.filter(function (i) { return i.code === 'UNNAMED' || i.code === 'DUPLICATE'; }); if (aliasNeededIssues.length) { var proceedWithAliases = function () { var fixed = Tools.suggestAliases(sql, Schema.getIndex()); Editor.applyEdits(shiftEdits(fixed.edits, self.currentRunOffset)); self.currentRunSql = fixed.sql; self._prepareColumns(fixed.sql, hasRetriedStar); }; if (self.tab !== QueryTabs.active()) { var label = QueryTabs.labelFor(self.tab); self.addMessage('warning', 'Some columns need a name before this query can run. Run it again, and SQL Studio offers to add the names.'); self.setState('idle'); self.tab.hasUnseenFinish = true; UI.reportRunOutcome(self); QueryTabs.render(); Toast.show(label + ' needs column names. Open it and run it again.', 'warning'); return; }; Dialogs.openAliasNeeded(extraction, aliasNeededIssues, function (shouldApply) { if (!shouldApply) { self.setState('idle'); return; }; proceedWithAliases(); }); return; }; this._afterColumnsReady(sql, extraction.columns); }; QueryRunner.prototype._deleteRunDe = function (run) { if (!run || !run.deKey || run.isRestored || run.isDeleted) return; run.isDeleted = true; this._api('deleteRun', { deKey: run.deKey }).catch(function () { /* retention and the sweep take it */ }); }; QueryRunner.prototype._api = function (action, payload) { return Api.call(action, payload, this); }; QueryRunner.prototype._startFieldLoads = function (sql) { var needed = tablesToTypeBy(sql); var isListLoading = needed.unlisted.length > 0 && Schema.isDeListLoading(); if (!needed.tables.length && !isListLoading) return null; var typing = { tables: [], failures: [], unlisted: isListLoading ? needed.unlisted : [], isListIn: false, isDone: false }; var started = {}; function startLoads(tables) { return Promise.all(tables.filter(function (t) { var key = Schema.tableKey(t).toLowerCase(); if (started[key]) return false; started[key] = true; typing.tables.push(t); return true; }).map(function (t) { return Schema.autoFields(t).then(function () { return null; }, function (err) { typing.failures.push({ table: t, reason: err && err.message ? err.message : 'no reason given' }); }); })); }; var loads = [startLoads(needed.tables)]; if (isListLoading) { loads.push(Schema.loadDataExtensionList().then(function () { typing.isListIn = true; return startLoads(tablesToTypeBy(sql).tables); }, function () { typing.isListIn = true; })); }; var timer = null; typing.limit = new Promise(function (resolve) { timer = setTimeout(resolve, FIELD_WAIT_MS); }); typing.loads = Promise.all(loads).then(function () { typing.isDone = true; clearTimeout(timer); }); return typing; }; QueryRunner.prototype._columnTypes = function (typing, sql, columns) { var self = this; function build() { var columnTypes; try { columnTypes = Tools.inferColumnTypes(sql, Schema.getIndex()); } catch (e) { columnTypes = []; }; if (columnTypes.length !== columns.length) columnTypes = columns.map(function () { return null; }); return columnTypes; }; function report() { var untyped = []; var failedKeys = {}; typing.failures.forEach(function (f) { failedKeys[Schema.tableKey(f.table).toLowerCase()] = true; untyped.push(f.table.name + ' (its field load failed: ' + f.reason + ')'); }); typing.tables.forEach(function (t) { if (!t.fields && !failedKeys[Schema.tableKey(t).toLowerCase()]) untyped.push(t.name + ' (its fields did not load within ' + (FIELD_WAIT_MS / 1000) + ' s)'); }); if (!typing.isListIn) { typing.unlisted.forEach(function (name) { untyped.push(name + ' (the Data Extension list did not load within ' + (FIELD_WAIT_MS / 1000) + ' s)'); }); }; if (untyped.length && typeof console !== 'undefined') { console.info('[SQL Studio] The run goes ahead without types for the columns of ' + untyped.join(', ') + ', which are Text, as Marketing Cloud Engagement gives them.'); }; }; if (!typing) return Promise.resolve(build()); if (typing.isDone) { report(); return Promise.resolve(build()); }; if (self.phaseAt) self.phaseAt.typing = Date.now(); return Promise.race([typing.loads, typing.limit]).then(function () { if (self.phaseAt) self.phaseAt.typed = Date.now(); report(); return build(); }); }; QueryRunner.prototype._afterColumnsReady = function (sql, columns) { var self = this; var attempt = self.attempt; self.setState('preparing'); var typing = self._startFieldLoads(sql); self._api('validateQuery', { sql: sql }).then(function (res) { if (attempt !== self.attempt) return; /* cancelled while validating */ if (!res.valid) { (res.errors && res.errors.length ? res.errors : ['Marketing Cloud Engagement rejected this query.']).forEach(function (msg) { self.addMessage('error', msg); }); self._markServerErrors(sql, res.errors || [], self.currentRunOffset); var runHint = serverErrorHint(sql, res.errors || []); if (runHint) self.addMessage('hint', runHint); self._failFinalize(); return; }; if (self.tab === QueryTabs.active() && self.currentRunOffset === 0 && sql === Editor.getValue()) Editor.markServerAccepted(sql); return self._columnTypes(typing, sql, columns).then(function (columnTypes) { if (attempt !== self.attempt) return; /* cancelled while the fields loaded */ self._create(sql, columns, false, columnTypes); }); }).catch(function (err) { if (attempt !== self.attempt) return; self._fail('error', 'Could not validate the query: ' + err.message, err.details, err); }); }; QueryRunner.prototype._markServerErrors = function (sql, errors, baseOffset) { if (this.tab !== QueryTabs.active()) return; var base = baseOffset || 0; var tokens; try { tokens = Tools.tokenize(sql); } catch (e) { tokens = []; }; var markers = errors.map(function (msg) { var m = /near '([^']+)'/i.exec(msg) || /Invalid column name '([^']+)'/i.exec(msg) || /Invalid object name '([^']+)'/i.exec(msg); var tok = null; if (m) { for (var i = 0; i < tokens.length; i++) { var t = tokens[i]; if (t.type === 'string' || t.type === 'comment') continue; if (String(t.value).toLowerCase() === m[1].toLowerCase()) { tok = t; break; }; }; }; var start = tok ? tok.start : 0; var end = tok ? tok.end : Math.min(sql.length, 1) || 1; var a = Editor.lineColAt(base + start); var b = Editor.lineColAt(base + end); return { severity: 8, message: msg, startLineNumber: a.line, startColumn: a.col, endLineNumber: b.line, endColumn: b.col }; }); Editor.setServerMarkers(markers); }; QueryRunner.prototype._create = function (sql, columns, hasRetriedSession, columnTypes) { var self = this; var tab = self.tab; var attempt = self.attempt; self.setState('preparing'); Schema.workspace().catch(function () { self.addMessage('warning', 'SQL Studio could not reach its folders in Marketing Cloud Engagement, so this run files its Data Extension and query in the top-level folders.'); return {}; }).then(function (ws) { if (attempt !== self.attempt) return null; /* cancelled before runQuery: create nothing */ ws = ws || {}; var startNow = function () { if (attempt !== self.attempt) return null; /* cancelled while waiting its turn */ var requestedSlot = QueryTabs.pickSlot(tab); self._requestedSlot = requestedSlot; self.pendingSlot = requestedSlot; var cachedQueryId = safeGet(adhocQueryKey(requestedSlot)) || undefined; return self._api('runQuery', { sql: sql, columns: columns, columnTypes: columnTypes || columns.map(function () { return null; }), deFolderId: ws.deFolderId, queryFolderId: ws.queryFolderId, queryId: cachedQueryId, slot: requestedSlot }).then(function (res) { if (res && res.slot) self.pendingSlot = res.slot; return res; }, function (err) { if (!(err && err.timeout)) self.pendingSlot = null; throw err; }); }; var started = runStartQueue.then(function () { var recovery = Session._expired ? Session.recovery() : null; if (!recovery) return startNow(); return recovery.then(startNow, function () { throw Api.makeError('SESSION_EXPIRED', 'Your Marketing Cloud Engagement session has ended. Sign in again, then run the query.'); }); }); runStartQueue = started.then(function () { /* next */ }, function () { /* next */ }); return started; }).then(function (res) { self.pendingSlot = null; if (attempt !== self.attempt) { if (res && res.queryId) safeSet(adhocQueryKey(res.slot), res.queryId); if (res && res.deKey) self._api('deleteRun', { deKey: res.deKey }).catch(function () { /* best effort */ }); return; }; tab.state.activeRun = { runId: res.runId, deKey: res.deKey, deName: res.deName, deId: res.deId, queryId: res.queryId, slot: res.slot || 1, queryName: res.queryName, startedAt: res.startedAt || new Date().toISOString(), columns: res.columns || columns, retentionApplied: res.retentionApplied !== false, columnTypes: appliedColumnTypes(res.columnTypes, (res.columns || columns).length), pollStartedAt: Date.now(), finishedAt: null, seenRunning: false, queuedTimedOut: false, hasRowMark: res.hasRowMark === true, taskId: res.taskId || null, taskSig: (res.taskId && typeof res.taskSig === 'string') ? res.taskSig : null, outcomeCalls: 0, outcomeFinishedEmpty: false }; self.hasRunStarted = true; tab.state.results = null; Grid.setData(tab, null); safeSet(adhocQueryKey(res.slot), res.queryId); if (typeof console !== 'undefined') { console.info('[SQL Studio] Query started through ' + (res.startMethod === 'rest' ? 'REST' : 'SOAP') + ', task ID: ' + (res.taskId ? 'yes' : 'no') + '.' + (res.startResponse ? ' ' + res.startResponse : '')); }; var usedSlot = res.slot || 1; if (self._requestedSlot && usedSlot !== self._requestedSlot) { self.addMessage('detail', 'Ran on your SQL Studio query activity ' + usedSlot + ', since activity ' + self._requestedSlot + ' is busy in another window.'); } else if (res.queryReused) { self.addMessage('detail', 'Reused your SQL Studio query activity' + (usedSlot > 1 ? ' ' + usedSlot : '') + '.'); }; self.setState('running'); self._scheduleNext(POLL_FAST_MS); Poller.ensureRunning(); }).catch(function (err) { self.pendingSlot = null; if (attempt !== self.attempt) return; var isSessionError = !!err && (err.code === 'SESSION_EXPIRED' || err.code === 'SESSION_INVALID'); var recovery = (isSessionError && !hasRetriedSession) ? Session.recovery() : null; if (recovery) { recovery.then(function () { if (attempt === self.attempt) self._create(sql, columns, true, columnTypes); }, function () { if (attempt === self.attempt) self._fail('error', 'Could not start the query: ' + err.message, err.details, err); }); return; }; if (err && (err.code === 'RUN_IN_PROGRESS' || err.timeout)) { self._fail('error', err.message, null, err); return; }; if (isFolderRefusal(err) && Schema.forgetWorkspace()) { self._create(sql, columns, hasRetriedSession, columnTypes); return; }; var startHint = serverErrorHint(sql, [err.message, typeof err.details === 'string' ? err.details : JSON.stringify(err.details || '')], true); if (startHint) self.addMessage('hint', startHint); self._fail('error', 'Could not start the query: ' + err.message, err.details, err); }); }; QueryRunner.prototype._scheduleNext = function (waitMs) { this.nextPollAt = Date.now() + (waitMs || 0); }; QueryRunner.prototype._pollPayload = function () { var run = this.tab.state.activeRun; var elapsed = Date.now() - run.pollStartedAt; var payload = { queryId: run.queryId }; if (run.taskId && !run.isTaskStatusUnknown && run.taskStatusVia !== 'none') { payload.taskId = run.taskId; if (run.taskSig) payload.taskSig = run.taskSig; if (isTaskRowAwaited(run, elapsed)) payload.taskOnly = true; }; if (!run.isCountUnreadable) { payload.deKey = run.deKey; payload.checkRunning = isRunStatusDue(run, elapsed); }; return payload; }; QueryRunner.prototype._handlePollResult = function (res) { var self = this; var tab = self.tab; var run = tab.state.activeRun; if (!run || tab.state.runnerState !== 'running') return; if (!res) { self._scheduleNext(POLL_FAST_MS); return; }; var elapsed = Date.now() - run.pollStartedAt; if (elapsed > POLL_MAX_MS) { run.finishedAt = Date.now(); self._fail('warning', 'Timed out after 31 minutes. Marketing Cloud Engagement may still finish this query. SQL Studio deletes its temporary Data Extension on its own.'); return; }; run.pollFailures = 0; var hasCount = res.rowCount !== null && res.rowCount !== undefined; if (!hasCount) run.isCountUnreadable = true; run.lastRowCount = hasCount ? Number(res.rowCount) : null; var hasStatus = res.isRunning === true || res.isRunning === false; if (hasStatus) { run.statusCheckedAt = Date.now(); run.statusCheckCount = (run.statusCheckCount || 0) + 1; if (res.isRunning) { run.seenRunning = true; } else { run.notRunningCount = (run.notRunningCount || 0) + 1; }; }; if (res.pending === true && !hasStatus && !res.taskStatus) { run.statusCheckedAt = Date.now(); run.statusCheckCount = (run.statusCheckCount || 0) + 1; run.taskStatusVia = res.pendingVia === 'soap' ? 'soap' : 'wsproxy'; }; if (hasStatus && run.taskId && !run.isTaskStatusUnknown && !res.taskStatus) run.taskStatusVia = 'none'; var taskKind = null; if (res.taskStatus) { run.hasTaskRow = true; taskKind = classifyRunOutcome(res.taskStatus); if (taskKind === 'unknown') { logUnknownRunStatus(res.taskStatus.status); run.isTaskStatusUnknown = true; taskKind = null; }; }; if (taskKind !== null) { var previousCheckAt = run.statusCheckedAt || null; run.statusCheckedAt = Date.now(); run.statusCheckCount = (run.statusCheckCount || 0) + 1; run.taskStatusVia = res.taskStatus.via || null; run.isConfirmDue = false; if (taskKind === 'failed') { self._failFromOutcome(run, res.taskStatus.status || '', res.taskStatus.errorMsg || ''); return; }; if (taskKind === 'complete') { if (!(hasCount && Number(res.rowCount) > 0)) run.outcomeFinishedEmpty = true; logRunCompletion(res.taskStatus, self.currentRunStartedAt, run.statusCheckCount, previousCheckAt); self._finishPolling(); return; }; if (/running|progress|processing|in ?process|start/i.test(String(res.taskStatus.status || ''))) { run.seenRunning = true; }; self._scheduleNext(nextPollWait(run, elapsed)); return; }; if (hasCount && Number(res.rowCount) > 0) { if (res.isRunning === false) { self._finishPolling(); return; }; run.isConfirmDue = !hasStatus; if (run.isConfirmDue) { self._scheduleNext(0); return; }; } else if (hasCount) { if (res.isRunning === true) { run.emptySince = null; run.lookupsSinceRunning = 0; run.nextOutcomeAt = 0; } else if (res.isRunning === false && !run.emptySince) { run.emptySince = Date.now(); }; if (run.emptySince) { if (run.taskId && !run.outcomeCalls) { self._checkRunOutcome(); return; }; var now = Date.now(); var emptyFor = now - run.emptySince; var calls = run.outcomeCalls || 0; var isAskDue = !!run.taskId && run.outcomeOpen && calls < OUTCOME_MAX_CALLS && now >= (run.nextOutcomeAt || 0); if (run.seenRunning) { if (emptyFor >= POLL_FREE_FAST_MS) { var canAskAfterRun = !!run.taskId && run.outcomeOpen && (run.lookupsSinceRunning || 0) < 2 && calls < OUTCOME_MAX_CALLS; if (!canAskAfterRun) { self._finishPolling(); return; }; if (isAskDue) { self._checkRunOutcome(); return; }; }; } else if (run.outcomePending) { if (isAskDue) { self._checkRunOutcome(); return; }; } else if (emptyFor >= POLL_QUEUE_WAIT_MS) { if (isAskDue && calls < 2) { self._checkRunOutcome(); return; }; run.queuedTimedOut = true; self._finishPolling(); return; }; }; } else if (run.hasProbedRows && hasStatus) { run.isConfirmDue = false; if (res.isRunning === false) { self._finishPolling(); return; }; } else if (res.isRunning === false) { if (!run.seenRunning && elapsed < POLL_QUEUED_GRACE_MS) { self._pollQueuedProbe(elapsed); return; }; if (!run.seenRunning) run.queuedTimedOut = true; self._finishPolling(); return; } else if (res.pending === true && isRowProbeDue(run, elapsed)) { run.rowProbedAt = Date.now(); run.rowProbeCount = (run.rowProbeCount || 0) + 1; self._pollQueuedProbe(elapsed, true); return; }; self._scheduleNext(nextPollWait(run, elapsed)); }; QueryRunner.prototype._handlePollError = function (err) { var self = this; var run = self.tab.state.activeRun; if (!run || self.tab.state.runnerState !== 'running') return; var elapsed = Date.now() - run.pollStartedAt; var failures = (run.pollFailures || 0) + 1; if (err && (err.code === 'NETWORK' || err.code === 'RATE_LIMITED') && failures < POLL_MAX_NETWORK_FAILURES) { run.pollFailures = failures; self._scheduleNext(pollInterval(elapsed)); return; }; if (self._isWaitingForRenewal(err)) return; run.finishedAt = Date.now(); self._fail('error', 'Could not check run status: ' + err.message, err.details, err); }; QueryRunner.prototype._finishPolling = function () { this.tab.state.activeRun.finishedAt = Date.now(); this.currentRunFinishedAt = this.tab.state.activeRun.finishedAt; this._fetch(); }; QueryRunner.prototype._pollQueuedProbe = function (elapsed, isAfterPending) { var self = this; var tab = self.tab; var attempt = self.attempt; self.nextPollAt = Infinity; var pageSize = Settings.data.pageSize || 100; var run = tab.state.activeRun; self._api('getResults', { deKey: run.deKey, page: 1, pageSize: pageSize, columns: run.columns }).then(function (res) { if (attempt !== self.attempt || !tab.state.activeRun) return; if (res.rows && res.rows.length > 0 && isAfterPending) { tab.state.activeRun.hasProbedRows = true; tab.state.activeRun.isConfirmDue = true; self._scheduleNext(0); return; }; if (res.rows && res.rows.length > 0) { self._applyResults(res); return; }; self._scheduleNext(pollInterval(elapsed)); }).catch(function (err) { if (attempt !== self.attempt) return; if (self._isWaitingForRenewal(err)) return; if (tab.state.activeRun) tab.state.activeRun.finishedAt = Date.now(); self._fail('error', 'Could not fetch results: ' + err.message, err.details, err); }); }; function classifyRunOutcome(res) { var status = String((res && res.status) || '').replace(/^\s+|\s+$/g, '').toLowerCase(); var errorMsg = String((res && res.errorMsg) || '').replace(/^\s+|\s+$/g, ''); if (/error|fail|unsuccess|cancel|abort|stopped|timeout|timed ?out/.test(status)) return 'failed'; if (/incomplete|not ?complete|pending|queue|wait|schedul|progress|running|start|processing|in ?process/.test(status)) return 'pending'; if (/^(complete|completed|success|successful|succeeded|finished|done|processed)$/.test(status)) return errorMsg ? 'unknown' : 'complete'; return 'unknown'; }; QueryRunner.prototype._checkRunOutcome = function () { var self = this; var tab = self.tab; var attempt = self.attempt; var run = tab.state.activeRun; run.outcomeCalls = (run.outcomeCalls || 0) + 1; run.lookupsSinceRunning = (run.lookupsSinceRunning || 0) + 1; var leaveOpen = function (isPending) { run.outcomeOpen = true; run.outcomePending = isPending; var retryIndex = run.seenRunning ? 0 : Math.min(run.outcomeCalls, OUTCOME_RETRY_MS.length) - 1; run.nextOutcomeAt = Date.now() + OUTCOME_RETRY_MS[retryIndex]; self._scheduleNext(POLL_FREE_FAST_MS); }; self.nextPollAt = Infinity; self._api('getRunOutcome', { taskId: run.taskId }).then(function (res) { if (attempt !== self.attempt || tab.state.activeRun !== run) return; var status = (res && res.status) || ''; var statusMessage = (res && res.statusMessage) || ''; var errorMsg = (res && res.errorMsg) || ''; var kind = classifyRunOutcome(res); if (typeof console !== 'undefined') { console.info('[SQL Studio] Run outcome ' + run.outcomeCalls + ': ' + (res && res.found ? 'found' : 'not found') + ', status: ' + (status || '(none)') + ', message: ' + (statusMessage || '(none)') + ', error: ' + (errorMsg || '(none)') + ', read as ' + kind + '.' + (res && res.raw ? ' Raw: ' + res.raw : '')); }; if (kind === 'failed') { self._failFromOutcome(run, status, errorMsg); return; }; if (kind === 'complete') { run.outcomeFinishedEmpty = true; self._finishPolling(); return; }; leaveOpen(kind === 'pending'); }).catch(function (err) { if (attempt !== self.attempt || tab.state.activeRun !== run) return; if (self._isWaitingForRenewal(err)) return; leaveOpen(run.outcomePending === true); }); }; QueryRunner.prototype._failFromOutcome = function (run, status, errorMsg) { var self = this; if (self._rerunAsText(run, status, errorMsg)) return; var hint = serverErrorHint(self.currentRunSql, [errorMsg || 'Automation failed due to system error'], true); if (hint) self.addMessage('hint', hint); self._fail('error', errorMsg ? 'The query failed while it ran in Marketing Cloud Engagement: ' + errorMsg : 'The query failed while it ran in Marketing Cloud Engagement, which gives no reason (status ' + (status || 'unknown') + ').'); }; QueryRunner.prototype._rerunAsText = function (run, status, errorMsg) { var self = this; if (self.hasRerunAsText || !hasTypedColumns(run.columnTypes)) return false; if (!couldBeTypeFailure(status, errorMsg)) return false; if (Date.now() - run.pollStartedAt >= RERUN_MAX_ELAPSED_MS) return false; self.hasRerunAsText = true; self.addMessage('info', RERUN_AS_TEXT_MESSAGE); if (typeof console !== 'undefined') { console.info('[SQL Studio] The run failed while it ran with typed columns (' + typedColumnsSummary(run.columns, run.columnTypes) + '). Running it again with every column as Text.'); }; self._deleteRunDe(run); self.hasRunStarted = false; self._create(self.currentRunSql, run.columns, false, null); return true; }; QueryRunner.prototype._isWaitingForRenewal = function (err) { var self = this; var attempt = self.attempt; var isSessionError = !!err && (err.code === 'SESSION_EXPIRED' || err.code === 'SESSION_INVALID'); var recovery = isSessionError ? Session.recovery() : null; if (!recovery) return isSessionError && Session._expired; recovery.then(function () { if (attempt !== self.attempt || !self.tab.state.activeRun) return; if (self.tab.state.runnerState === 'fetching') { self._fetch(); return; }; self._scheduleNext(0); Poller.ensureRunning(); }, function () { /* the dialog has taken over */ }); return true; }; QueryRunner.prototype._fetch = function () { var self = this; var attempt = self.attempt; self.setState('fetching'); var first = self._readFirstPage(); first.read.then(function (res) { if (attempt !== self.attempt) return; self._applyResults(res, first.isEmptyEnd); }).catch(function (err) { if (attempt !== self.attempt) return; if (self._isWaitingForRenewal(err)) return; if (isPageReadRefusal(err)) { self._keepRunWithoutPage(err); return; }; self._fail('error', 'Could not fetch results: ' + err.message, err.details, err); }); }; QueryRunner.prototype._readFirstPage = function () { var self = this; var run = self.tab.state.activeRun; var pageSize = Settings.data.pageSize || 100; var isEmptyEnd = !(run.lastRowCount > 0); var request = { deKey: run.deKey, page: 1, pageSize: pageSize, columns: run.columns }; if (isEmptyEnd) request.restOnly = true; var sendFirstPage = function (payload) { return self._api('getResults', payload); }; return { read: isEmptyEnd ? sendFirstPage(request) : readCheckedPage(sendFirstPage, request, run.lastRowCount, 'Page 1', sendFirstPage), isEmptyEnd: isEmptyEnd }; }; QueryRunner.prototype._keepRunWithoutPage = function (err) { var tab = this.tab; var run = tab.state.activeRun; run.finishedAt = Date.now(); this.currentRunFinishedAt = run.finishedAt; if (typeof console !== 'undefined') { console.info('[SQL Studio] The first page of the results could not be read (' + (err.code || 'no code') + '), so the run stays done and its rows stay in ' + run.deKey + '. The results area offers to read the page again.'); }; tab.state.results = null; Grid.setData(tab, null); tab.state.unreadPage = { run: run, message: err.message || 'No message', details: err.details || null, triedAt: Date.now(), isReading: false }; this.setState('done'); this._finish(); }; QueryRunner.prototype.readResultsAgain = function () { var self = this; var tab = self.tab; var unread = unreadPageOf(tab.state); if (!unread || unread.isReading) return; var attempt = self.attempt; var focused = document.activeElement; var hadFocus = !!focused && !!focused.classList && focused.classList.contains('ss-unread-page__button'); var restoreFocus = function () { var again = hadFocus ? document.querySelector('.ss-unread-page__button') : null; if (again && !again.disabled) again.focus(); }; unread.isReading = true; if (tab === QueryTabs.active()) UI.renderResults(); var isCurrent = function () { return attempt === self.attempt && unreadPageOf(tab.state) === unread; }; var first = self._readFirstPage(); first.read.then(function (res) { if (!isCurrent()) return; self._setFirstPage(res, first.isEmptyEnd); tab.state.unreadPage = null; var messageCount = self.messages.length; self._addEmptyNotice(tab.state.activeRun); if (self.messages.length > messageCount) UI.reportRunOutcome(self); if (tab === QueryTabs.active()) { UI.renderRunSummary(); UI._syncExportCost(); }; QueryTabs.markChanged(tab); QueryTabs.render(); }).catch(function (err) { if (!isCurrent()) return; unread.isReading = false; var isSessionError = !!err && (err.code === 'SESSION_EXPIRED' || err.code === 'SESSION_INVALID'); var recovery = isSessionError ? Session.recovery() : null; if (recovery) { recovery.then(function () { if (isCurrent()) self.readResultsAgain(); }, function () { /* the dialog has taken over */ }); if (tab === QueryTabs.active()) UI.renderResults(); return; }; if (isSessionError && Session._expired) { if (tab === QueryTabs.active()) UI.renderResults(); return; }; if (isDataExtensionGoneError(err)) { QueryTabs._dropResults(tab); if (tab === QueryTabs.active()) { UI.addStatusNotice(RESULTS_GONE_TEXT); UI.setActiveTab('status'); } else { tab.state.statusNotices.push(RESULTS_GONE_TEXT); tab.state.bottomPanel = 'status'; }; QueryTabs.markChanged(tab); return; }; unread.message = (err && err.message) || 'No message'; unread.details = (err && err.details) || null; unread.triedAt = Date.now(); if (tab === QueryTabs.active()) { UI.renderResults(); restoreFocus(); UI.renderRunSummary(); Toast.show('Could not read the results again: ' + unread.message, 'error'); }; QueryTabs.render(); }); }; QueryRunner.prototype._applyResults = function (res, isEmptyEnd) { var tab = this.tab; tab.state.activeRun.finishedAt = Date.now(); this.currentRunFinishedAt = tab.state.activeRun.finishedAt; this._setFirstPage(res, isEmptyEnd); this.setState('done'); this._finish(); }; QueryRunner.prototype._setFirstPage = function (res, isEmptyEnd) { var run = this.tab.state.activeRun; if (res && res.readPath && typeof console !== 'undefined') { var reason = ''; if (isEmptyEnd && res.readPath === 'rest') { reason = run.lastRowCount === 0 ? ' The run ended with 0 rows counted, so REST confirms the count.' : ' The free row count could not be read.'; }; console.info('[SQL Studio] Results read ' + (res.readPath === 'free' ? 'without an API call.' : 'through the REST API.') + reason + dateSampleNote(res, run.columnTypes)); }; withDisplayDates(res, run.columnTypes); var tab = this.tab; tab.state.results = { columns: res.columns, rows: res.rows, count: res.count, page: res.page, pageSize: res.pageSize, orderBy: null, deKey: tab.state.activeRun.deKey, columnTypes: run.columnTypes || null, freeReadFailed: !!(res && res.readPath === 'rest' && run.hasRowMark && Tools.isFreeReadPage(1, res.pageSize)) }; Grid.setData(tab, tab.state.results); }; QueryRunner.prototype._finish = function () { var self = this; var run = self.tab.state.activeRun; if (!run) return; self._recordHistory('done'); self._addEmptyNotice(run); if (!run.retentionApplied) { self.addMessage('detail', 'This org does not allow retention on API-created Data Extensions. SQL Studio sweeps up leftovers automatically after a few hours.'); }; UI.reportRunOutcome(self); }; QueryRunner.prototype._addEmptyNotice = function (run) { var self = this; if (self.tab.state.results && self.tab.state.results.count === 0) { var activityName = run.queryName ? '"' + run.queryName + '"' : 'your SQL Studio query activity'; var zeroRowText; if (run.outcomeFinishedEmpty) { zeroRowText = '0 rows. The query finished in Marketing Cloud Engagement without returning any rows.'; } else if (run.queuedTimedOut) { zeroRowText = '0 rows. If you expected rows, the query may have been queued. Run it again, or check Automation Studio.'; } else { zeroRowText = '0 rows. If you expected rows, the query may have failed while it ran, which Marketing Cloud Engagement does not report to SQL Studio. Automation Studio shows it in the Action Log of ' + activityName + '.'; }; self.addMessage('info', zeroRowText); }; }; QueryRunner.prototype.resumeRun = function (restoreEntry) { this.tab.state.activeRun = restoreEntry.activeRun; this.hasRunStarted = true; this.currentRunSql = restoreEntry.runSql || ''; this.currentRunIsFragment = restoreEntry.runIsFragment === true; this.currentRunContext = savedRunContext(restoreEntry.runContext); this.hasStaleShownStatus = false; this.currentRunStartedAt = restoreEntry.runStartedAt || restoreEntry.activeRun.pollStartedAt; this.currentRunFinishedAt = null; this._calls = restoredRunCalls(restoreEntry.runCalls); this.setState('running'); this._scheduleNext(0); Poller.ensureRunning(); }; QueryRunner.prototype.restoreResults = function (restoreEntry, hasRetriedSession) { var self = this; var tab = self.tab; var attempt = ++self.attempt; var saved = restoreEntry.results; var run = restoreEntry.resultsRun; run.isRestored = true; tab.state.activeRun = run; tab._restoringEntry = restoreEntry; self._calls = null; self.currentRunSql = restoreEntry.runSql || ''; self.currentRunIsFragment = restoreEntry.runIsFragment === true; self.currentRunContext = savedRunContext(restoreEntry.runContext); self.hasStaleShownStatus = false; self.currentRunStartedAt = restoreEntry.runStartedAt || run.pollStartedAt; self.currentRunFinishedAt = restoreEntry.runFinishedAt || run.finishedAt; self.phaseAt = null; var request = { deKey: saved.deKey, page: saved.page || 1, pageSize: saved.pageSize || Settings.data.pageSize || 100, orderBy: saved.orderBy || undefined, columns: run.columns }; if (saved.restOnly === true) request.restOnly = true; var savedCount = typeof saved.count === 'number' ? saved.count : null; var isReadAgain = false; var sendRestore = function (payload) { return self._api('getResults', payload); }; var readRestoreAgain = function (restPayload) { isReadAgain = true; return sendRestore(restPayload); }; return readCheckedPage(sendRestore, request, savedCount, 'The restored page', readRestoreAgain).then(function (res) { if (attempt !== self.attempt) return; /* a new run started meanwhile */ tab._restoringEntry = null; tab.state.unreadPage = null; withDisplayDates(res, run.columnTypes); tab.state.results = { columns: res.columns, rows: res.rows, count: res.count, page: res.page, pageSize: res.pageSize, orderBy: saved.orderBy || null, deKey: saved.deKey, columnTypes: run.columnTypes || null, freeReadFailed: isReadAgain || run.isCountUnreadable === true || !!(res && res.readPath === 'rest' && run.hasRowMark && Tools.isFreeReadPage(request.page, request.pageSize)) }; Grid.setData(tab, tab.state.results); Grid.showSortOf(tab, saved.orderBy); self._calls = restoredRunCalls(restoreEntry.runCalls); self.setState('done'); scheduleSaveTabs(); }).catch(function (err) { if (attempt !== self.attempt) return; var isSessionError = !!err && (err.code === 'SESSION_EXPIRED' || err.code === 'SESSION_INVALID'); var recovery = (isSessionError && !hasRetriedSession) ? Session.recovery() : null; if (recovery) { return recovery.then(function () { if (attempt === self.attempt) return self.restoreResults(restoreEntry, true); }, function () { /* the dialog has taken over */ }); }; if (isSessionError && Session._expired) return; tab.state.activeRun = null; self.currentRunStartedAt = null; self.currentRunFinishedAt = null; if (isDataExtensionGoneError(err)) { tab._restoringEntry = null; self.currentRunSql = ''; if (tab === QueryTabs.active()) { UI.addStatusNotice(RESULTS_GONE_TEXT); } else if (tab.state.statusNotices.indexOf(RESULTS_GONE_TEXT) === -1) { tab.state.statusNotices.push(RESULTS_GONE_TEXT); }; scheduleSaveTabs(); if (tab === QueryTabs.active()) UI.renderRunSummary(); return; }; var restoreError = { message: 'Could not load this tab\'s last results: ' + (err && err.message ? err.message : 'no message'), details: (err && err.details) || null, gate: null, hint: 'They stay saved, so the next page load asks for them again.' }; tab.state.statusError = restoreError; if (tab === QueryTabs.active()) { UI.showStatusError(restoreError.message, restoreError.details, false, restoreError.hint); UI.renderRunSummary(); }; }); }; var RESULTS_GONE_TEXT = 'This tab\'s last results are gone, as their temporary Data Extension was deleted. Run the query again to see them.'; function isDataExtensionGoneError(err) { if (!err || err.code !== 'MCE_ERROR') return false; var details = typeof err.details === 'string' ? err.details : ''; return /cannot be retrieved/i.test(String(err.message || '') + ' ' + details); }; var Poller = { _timer: null, ensureRunning: function () { if (this._timer) return; var self = this; this._timer = setInterval(function () { self._tick(); }, POLL_FAST_MS); }, _stopIfIdle: function () { var tabs = QueryTabs.all(); for (var i = 0; i < tabs.length; i++) { if (tabs[i].state.runnerState === 'running') return; }; if (this._timer) { clearInterval(this._timer); this._timer = null; }; }, _tick: function () { if (Session._expired) return; var tabs = QueryTabs.all(); var due = []; for (var i = 0; i < tabs.length; i++) { var runner = tabs[i].runner; if (tabs[i].state.runnerState === 'running' && tabs[i].state.activeRun && Date.now() >= (runner.nextPollAt || 0)) due.push(runner); }; this._stopIfIdle(); if (!due.length) return; var payload = { runs: due.map(function (r) { return r._pollPayload(); }) }; var attempts = due.map(function (r) { return r.attempt; }); due.forEach(function (r) { r.nextPollAt = Infinity; }); var handleEach = function (handle) { for (var j = 0; j < due.length; j++) { if (due[j].attempt !== attempts[j]) continue; try { handle(due[j], j); } catch (e) { if (typeof console !== 'undefined') console.error('[SQL Studio] Handling a run status failed:', e); due[j]._scheduleNext(POLL_FAST_MS); }; }; }; Api.call('getRunStatus', payload).then(function (res) { var results = (res && res.runs) || []; handleEach(function (runner, j) { var answer = results[j] || {}; var asked = payload.runs[j]; var hasAskedStatus = !asked.deKey || !!asked.checkRunning; var isStatusCall = answer.isRunning === true || answer.isRunning === false || (!!answer.error && hasAskedStatus); var runApiCalls = (typeof answer.apiCalls === 'number') ? answer.apiCalls : (isStatusCall ? 1 : 0); var runWsproxyCalls = (typeof answer.wsproxyCalls === 'number') ? answer.wsproxyCalls : 0; if (runner._calls) runner._calls.push({ action: 'getRunStatus', apiCalls: runApiCalls, wsproxyCalls: runWsproxyCalls, isFree: runApiCalls === 0 && !answer.error && typeof answer.rowCount === 'number' }); if (answer.error) { runner._handlePollError({ code: answer.error.code, message: answer.error.message, details: answer.error.details }); return; }; runner._handlePollResult(results[j]); }); }, function (err) { handleEach(function (runner) { runner._handlePollError(err); }); }); } }; var Grid = { get data() { return state.grid.data; }, set data(v) { state.grid.data = v; }, get clientFilter() { return state.grid.clientFilter; }, set clientFilter(v) { state.grid.clientFilter = v; }, get sortColumn() { return state.grid.sortColumn; }, set sortColumn(v) { state.grid.sortColumn = v; }, get sortDir() { return state.grid.sortDir; }, set sortDir(v) { state.grid.sortDir = v; }, get clientSortActive() { return state.grid.clientSortActive; }, set clientSortActive(v) { state.grid.clientSortActive = v; }, get colWidths() { return state.grid.colWidths; }, set colWidths(v) { state.grid.colWidths = v; }, setData: function (tab, data) { var grid = tab.state.grid; grid.data = data; grid.chunkCache = null; grid.sortColumn = null; grid.sortDir = null; grid.clientSortActive = false; grid.clientFilter = ''; grid.colWidths = {}; if (tab === QueryTabs.active()) UI.renderResults(); }, hasData: function () { return !!this.data; }, outputColumns: function () { return this.outputColumnsFor(QueryTabs.active()); }, outputColumnsFor: function (tab) { return tab.state.activeRun ? tab.state.activeRun.columns : (tab.state.grid.data ? tab.state.grid.data.columns : []); }, visibleRows: function () { if (!this.data) return []; var rows = this.data.rows; if (this.clientFilter) { var q = this.clientFilter.toLowerCase(); rows = rows.filter(function (r) { return r.some(function (v) { return v !== null && v !== undefined && String(v).toLowerCase().indexOf(q) !== -1; }); }); }; if (this.clientSortActive && this.sortColumn !== null) { var idx = this.sortColumn, dir = this.sortDir === 'desc' ? -1 : 1; var columnType = (this.data.columnTypes || [])[idx] || null; rows = rows.slice().sort(function (a, b) { var av = a[idx], bv = b[idx]; if (av === bv) return 0; if (av === null || av === undefined) return 1; if (bv === null || bv === undefined) return -1; return compareCells(av, bv, columnType) * dir; }); }; return rows; }, totalPages: function () { if (!this.data || !this.data.pageSize) return 1; return Math.max(1, Math.ceil(this.data.count / this.data.pageSize)); }, sortBy: function (colIndex) { var tab = QueryTabs.active(); var grid = tab.state.grid; if (grid.sortColumn === colIndex) { grid.sortDir = grid.sortDir === 'asc' ? 'desc' : (grid.sortDir === 'desc' ? null : 'asc'); } else { grid.sortColumn = colIndex; grid.sortDir = 'asc'; }; if (grid.sortDir === null) { grid.sortColumn = null; grid.clientSortActive = false; this._reload(tab, null); return; }; var colName = grid.data.columns[colIndex]; this._reload(tab, colName + ' ' + grid.sortDir.toUpperCase()); }, _chunkCacheFor: function (grid, data, orderBy) { var chunkSize = Tools.resultChunkSize(data.columns.length); if (!Tools.chunkCacheMatches(grid.chunkCache, data.deKey, orderBy, chunkSize)) { grid.chunkCache = Tools.createChunkCache(data.deKey, orderBy, chunkSize); UI._syncExportCost(); }; return grid.chunkCache; }, _loadChunk: function (cache, chunk, request) { var held = Tools.chunkCacheGet(cache, chunk); if (held) { return Promise.resolve({ rows: held, count: null }); }; return Api.call('getResults', { deKey: cache.deKey, page: chunk, pageSize: cache.chunkSize, orderBy: cache.orderBy || undefined, columns: request.columns, restOnly: true }).then(function (res) { var note = dateSampleNote(res, request.columnTypes); if (note && typeof console !== 'undefined') { console.info('[SQL Studio] Rows ' + fmtNumber((chunk - 1) * cache.chunkSize + 1) + ' to ' + fmtNumber(chunk * cache.chunkSize) + ' read through the REST API.' + note); }; withDisplayDates(res, request.columnTypes); var rows = res.rows || []; var expected = Tools.resultChunkLength(chunk, request.count, cache.chunkSize); if (expected > 0 && rows.length >= expected) { Tools.chunkCachePut(cache, chunk, rows); UI._syncExportCost(); }; return { rows: rows, count: typeof res.count === 'number' ? res.count : null }; }); }, _readPage: function (tab, data, page, orderBy) { var self = this; var columns = self.outputColumnsFor(tab); var cache = self._chunkCacheFor(tab.state.grid, data, orderBy); function readRest() { if (data.pageSize > cache.chunkSize) { return Api.call('getResults', { deKey: data.deKey, page: page, pageSize: data.pageSize, orderBy: orderBy || undefined, columns: columns, restOnly: true }).then(function (res) { logDatePage(res, data.columnTypes); withDisplayDates(res, data.columnTypes); return res; }); }; var parts = Tools.resultChunksForPage(page, data.pageSize, cache.chunkSize, data.count); var request = { columns: columns, columnTypes: data.columnTypes, count: data.count }; return Promise.all(parts.map(function (part) { return self._loadChunk(cache, part.chunk, request); })).then(function (answers) { var rows = []; var count = data.count; answers.forEach(function (answer, i) { rows = rows.concat(answer.rows.slice(parts[i].from, parts[i].to)); if (answer.count !== null) count = answer.count; }); return { count: count, page: page, pageSize: data.pageSize, columns: columns, rows: rows, readPath: 'rest' }; }); }; var hasFreeRead = !!runForDeKey(data.deKey); if (!(hasFreeRead && !cache.freeFailed && !data.freeReadFailed && Tools.isFreeReadPage(page, data.pageSize))) { return readRest(); }; var sendPage = function (payload) { return Api.call('getResults', payload).then(function (res) { if (res.readPath === 'rest') cache.freeFailed = true; logDatePage(res, data.columnTypes); withDisplayDates(res, data.columnTypes); return res; }); }; var freePayload = { deKey: data.deKey, page: page, pageSize: data.pageSize, orderBy: orderBy || undefined, columns: columns }; var knownCount = typeof data.count === 'number' ? data.count : null; var readAgainFromChunks = function () { cache.freeFailed = true; data.freeReadFailed = true; return readRest(); }; return readCheckedPage(sendPage, freePayload, knownCount, 'Page ' + page, readAgainFromChunks); }, _reload: function (tab, orderBy) { var grid = tab.state.grid; var data = grid.data; if (!data) return; var readSeq = grid.readSeq = (grid.readSeq || 0) + 1; grid.hasPendingOrder = true; grid.pendingOrderBy = orderBy || null; this._readPage(tab, data, 1, orderBy || null).then(function (res) { if (grid.data !== data || grid.readSeq !== readSeq) return; data.columns = res.columns; data.rows = res.rows; data.count = res.count; data.page = res.page; data.orderBy = orderBy || null; grid.hasPendingOrder = false; grid.clientSortActive = false; QueryTabs.markChanged(tab); if (tab === QueryTabs.active()) UI.renderResults(); }).catch(function (err) { if (grid.data !== data || grid.readSeq !== readSeq) return; grid.hasPendingOrder = false; if (orderBy) { grid.clientSortActive = true; if (tab === QueryTabs.active()) { UI.addStatusNotice('Server rejected sorting; sorted on this page only.'); Toast.show('The server rejected sorting by "' + orderBy + '"; sorting the current page instead.', 'warning'); UI.renderResults(); }; } else { Toast.show('Could not reload results: ' + err.message, 'error'); }; }); }, goToPage: function (page) { var tab = QueryTabs.active(); var grid = tab.state.grid; var data = grid.data; if (!data) return; page = clamp(page, 1, this.totalPages()); var readSeq = grid.readSeq = (grid.readSeq || 0) + 1; var orderBy = grid.hasPendingOrder ? grid.pendingOrderBy : (data.orderBy || null); var isPendingOrder = !!grid.hasPendingOrder; this._readPage(tab, data, page, orderBy).then(function (res) { if (grid.data !== data || grid.readSeq !== readSeq) return; data.rows = res.rows; data.page = res.page; data.count = res.count; if (isPendingOrder) { data.orderBy = orderBy; grid.hasPendingOrder = false; grid.clientSortActive = false; }; QueryTabs.markChanged(tab); if (tab === QueryTabs.active()) UI.renderResults(); }).catch(function (err) { if (grid.data !== data || grid.readSeq !== readSeq) return; if (isPendingOrder && orderBy) { grid.hasPendingOrder = false; grid.clientSortActive = true; if (tab === QueryTabs.active()) UI.renderResults(); }; Toast.show('Could not load page ' + page + ': ' + err.message, 'error'); }); }, setPageSize: function (size) { if (!this.data) return; this.data.pageSize = size; Settings.set('pageSize', size); this.goToPage(1); }, setClientFilter: function (text) { this.clientFilter = text || ''; UI.renderResults(); }, showSortOf: function (tab, orderBy) { var grid = tab.state.grid; var match = /^(.*) (ASC|DESC)$/.exec(orderBy || ''); var colIndex = match && grid.data ? grid.data.columns.indexOf(match[1]) : -1; if (colIndex === -1) return; grid.sortColumn = colIndex; grid.sortDir = match[2].toLowerCase(); if (tab === QueryTabs.active()) UI.renderResults(); }, setColWidth: function (colIndex, px) { this.colWidths[colIndex] = clamp(px, 80, 2000); }, exportPlan: function () { var tab = QueryTabs.active(); var data = this.data; var plan = { tab: tab, isSinglePage: this.totalPages() <= 1, apiCalls: 0, wsproxyCalls: 0 }; if (!data || plan.isSinglePage) return plan; var totalCount = data.count; var columns = data.columns; var deKey = data.deKey; var orderBy = data.orderBy || undefined; var pageSize = Tools.resultChunkSize(columns.length); var totalChunks = Tools.resultChunkCount(totalCount, pageSize); var held = {}; var heldCount = 0; if (Tools.chunkCacheMatches(tab.state.grid.chunkCache, deKey, orderBy, pageSize)) { for (var chunk = 1; chunk <= totalChunks; chunk++) { var heldRows = Tools.chunkCacheGet(tab.state.grid.chunkCache, chunk); if (heldRows && heldRows.length === Tools.resultChunkLength(chunk, totalCount, pageSize)) { held[chunk] = heldRows; heldCount++; }; }; }; var exportCalls = totalChunks - heldCount; var fullPageSize = Tools.exportPageSize(columns.length); var fullPageCalls = Math.ceil(totalCount / fullPageSize); if (fullPageSize > pageSize && fullPageCalls < exportCalls) { pageSize = fullPageSize; totalChunks = fullPageCalls; exportCalls = fullPageCalls; held = {}; heldCount = 0; }; var isWsproxy = exportCalls > 0 && !orderBy && !!deKey && Session.isBackendBu() && columns.length <= Tools.WSPROXY_EXPORT_MAX_COLUMNS; var wsproxyBatches = Math.max(1, Math.ceil(totalCount / Tools.WSPROXY_BATCH_ROWS)); var isFreeFailed = !!data.freeReadFailed || (Tools.chunkCacheMatches(tab.state.grid.chunkCache, deKey, orderBy, Tools.resultChunkSize(columns.length)) && tab.state.grid.chunkCache.freeFailed); var isFreeExport = exportCalls > 0 && !!deKey && !!runForDeKey(deKey) && Session.isBackendBu() && !isFreeFailed && typeof totalCount === 'number' && Tools.isFreeReadPage(1, totalCount + 1) && totalCount <= Tools.exportPageSize(columns.length); plan.totalCount = totalCount; plan.columns = columns; plan.outputColumns = this.outputColumns(); plan.columnTypes = data.columnTypes; plan.deKey = deKey; plan.orderBy = orderBy; plan.gridData = data; plan.pageSize = pageSize; plan.totalChunks = totalChunks; plan.held = held; plan.exportCalls = exportCalls; plan.fullPageSize = fullPageSize; plan.isWsproxy = isWsproxy; plan.wsproxyBatches = wsproxyBatches; plan.isFreeExport = isFreeExport; if (!isFreeExport && exportCalls > 0) { if (isWsproxy) { plan.wsproxyCalls = wsproxyBatches; } else { plan.apiCalls = exportCalls; }; }; return plan; }, exportCsv: function () { var self = this; if (!this.data) return; if (QueryTabs.active().activeExport || QueryTabs.active().activeSave) return; var plan = this.exportPlan(); var tab = plan.tab; var deName = (tab.state.activeRun && tab.state.activeRun.deName) || 'sqlstudio-results'; if (plan.isSinglePage) { this._downloadCsv(this.data.rows, this.data.columns, deName); return; }; var totalCount = plan.totalCount; var columns = plan.columns; var outputColumns = plan.outputColumns; var columnTypes = plan.columnTypes; var deKey = plan.deKey; var orderBy = plan.orderBy; var pageSize = plan.pageSize; var totalChunks = plan.totalChunks; var held = plan.held; var exportCalls = plan.exportCalls; var fullPageSize = plan.fullPageSize; var isWsproxy = plan.isWsproxy; var wsproxyBatches = plan.wsproxyBatches; var isFreeExport = plan.isFreeExport; var gridData = plan.gridData; var runCalls = tab.runner._calls; var spent = { apiCalls: 0, wsproxyCalls: 0 }; var callOwner = { _calls: { push: function (entry) { spent.apiCalls += entry.apiCalls || 0; spent.wsproxyCalls += entry.wsproxyCalls || 0; if (runCalls) runCalls.push(entry); } } }; var failedReads = []; var refreshSummary = function () { if (tab === QueryTabs.active() && tab.runner._calls === runCalls) UI.renderRunSummary(); UI._syncExportCost(); }; var proceed = function () { var allRows = []; var handle = exportCalls > 0 ? UI.openExportProgress(tab, totalCount) : silentProgress(); var callsAllowed = Infinity; var usualCalls = 0; function spentText() { var parts = []; if (spent.wsproxyCalls) parts.push(wsproxyCallsText(spent.wsproxyCalls)); if (spent.apiCalls) parts.push(apiCallsText(spent.apiCalls)); return parts.join(' and '); }; function cancelExport() { handle.close(); var spentSoFar = spentText(); Toast.show('Export cancelled. Nothing was downloaded' + (spentSoFar ? ', after ' + spentSoFar : '') + '.', 'info'); refreshSummary(); }; function isCallAllowed(next, title, cost) { if (usualCalls < callsAllowed) { usualCalls++; return true; }; Dialogs.confirm(title, cost, function () { callsAllowed = Infinity; next(); }, cancelExport, true); return false; }; function moreCallsText(n, kind) { return fmtNumber(n) + ' more ' + kind + (n === 1 ? ' call' : ' calls'); }; function restCallsLeft(rowsFetched, currentPageSize) { var left = 0; for (var at = rowsFetched; at < totalCount; at += currentPageSize) { var isHeld = at % pageSize === 0 && pageSize % currentPageSize === 0 && !!held[at / pageSize + 1]; if (!isHeld) left++; }; return Math.max(1, left); }; function fallBack(learnedCount) { failedReads.push('the read without an API call'); if (typeof learnedCount === 'number' && learnedCount > totalCount) { totalCount = learnedCount; var chunkSize = Tools.resultChunkSize(columns.length); pageSize = Math.ceil(totalCount / fullPageSize) < Math.ceil(totalCount / chunkSize) ? fullPageSize : chunkSize; totalChunks = Math.ceil(totalCount / pageSize); exportCalls = totalChunks; held = {}; wsproxyBatches = Math.max(1, Math.ceil(totalCount / Tools.WSPROXY_BATCH_ROWS)); handle.update(allRows.length, totalCount); }; var planCalls = isWsproxy ? wsproxyBatches : exportCalls; if (planCalls > 1) { Dialogs.confirm( 'Export ' + fmtRows(totalCount) + '?', 'It takes ' + (isWsproxy ? wsproxyCallsText(planCalls) : apiCallsText(planCalls)) + '.', readUsualWay, cancelExport, true ); return; }; callsAllowed = 1; readUsualWay(); }; var isRestCountRead = false; var countNote = ''; function finish() { handle.close(); self._downloadCsv(allRows, columns, deName, countNote, doneText(allRows.length)); refreshSummary(); }; function doneText(rowCount) { if (failedReads.length) { var spentParts = []; if (spent.wsproxyCalls) spentParts.push(wsproxyCallsText(spent.wsproxyCalls)); if (spent.apiCalls) spentParts.push(apiCallsText(spent.apiCalls)); return 'Exported ' + fmtRows(rowCount) + ' with ' + (spentParts.length ? spentParts.join(' and ') : 'no API call') + ', as ' + failedReads.join(' and ') + ' failed.'; }; if (isFreeExport) return 'Exported ' + fmtRows(rowCount) + ' without an API call.'; return null; }; function takeRestCount(res) { if (isRestCountRead || typeof res.count !== 'number') return; isRestCountRead = true; if (res.count === totalCount) return; if (typeof console !== 'undefined') { console.info('[SQL Studio] The REST API counts ' + res.count + ' rows where the results had ' + totalCount + ', so the export reads ' + res.count + '.'); }; countNote = ' The REST API counted ' + fmtRows(res.count) + ', not the ' + fmtNumber(totalCount) + ' this export first stated.'; totalCount = res.count; }; function fail(message) { handle.close(); Toast.show(message, 'error'); refreshSummary(); }; function readUsualWay() { if (isWsproxy) { readBatch(null, null, 1); } else { step(0, pageSize, false); }; }; function readFree() { var request = { deKey: deKey, page: 1, pageSize: totalCount, orderBy: orderBy, columns: outputColumns, freeOnly: true }; Api.call('getResults', request, callOwner).then(function (res) { if (isTrustedFreeExport(res, totalCount)) { if (typeof console !== 'undefined') { console.info('[SQL Studio] Export read ' + res.rows.length + ' rows without an API call.' + dateSampleNote(res, columnTypes)); }; withDisplayDates(res, columnTypes); Array.prototype.push.apply(allRows, res.rows); handle.update(allRows.length, totalCount); finish(); return; }; gridData.freeReadFailed = true; if (typeof console !== 'undefined') { var rowsGiven = (res && res.rows) ? res.rows.length : 0; var gave = (res && res.readPath === 'free') ? 'a count of ' + res.count + ' and ' + rowsGiven + ' rows' : 'no free answer'; console.info('[SQL Studio] The free read for the export gave ' + gave + ' where ' + totalCount + ' rows were counted before. Reading the rows the usual way instead.'); }; fallBack(res && typeof res.count === 'number' ? res.count : null); }, function (err) { if (err && (err.code === 'SESSION_EXPIRED' || err.code === 'SESSION_INVALID')) { fail('Export failed: ' + err.message); return; }; if (typeof console !== 'undefined') { console.info('[SQL Studio] The free read for the export failed (' + (err && err.message ? err.message : 'no message') + '). Reading the rows the usual way instead.'); }; fallBack(null); }); }; function readBatch(requestId, requestSig, batchNumber) { var next = function () { readBatch(requestId, requestSig, batchNumber); }; if (!isCallAllowed(next, 'Export more than ' + fmtRows(allRows.length) + '?', 'It takes 1 WSProxy call for every ' + fmtNumber(Tools.WSPROXY_BATCH_ROWS) + ' rows past them.')) return; var request = { deKey: deKey, columns: outputColumns }; if (requestId) { request.requestId = requestId; request.requestSig = requestSig; }; Api.call('exportRows', request, callOwner).then(function (res) { withDisplayDates(res, columnTypes); var batchRows = res.rows || []; Array.prototype.push.apply(allRows, batchRows); handle.update(allRows.length, totalCount); if (res.hasMore) { if (!res.requestId || !res.requestSig || !batchRows.length) { readAllThroughRest('WSProxy gave more rows and nothing to continue them with'); return; }; readBatch(res.requestId, res.requestSig, batchNumber + 1); } else if (allRows.length >= totalCount) { finish(); } else { readAllThroughRest('WSProxy gave ' + fmtNumber(allRows.length) + ' rows, not the ' + fmtNumber(totalCount) + ' the run has'); }; }, function (err) { if (err && (err.code === 'SESSION_EXPIRED' || err.code === 'SESSION_INVALID')) { fail('Export failed: ' + err.message); return; }; readAllThroughRest(err && err.message ? err.message : 'WSProxy failed'); }); }; function readAllThroughRest(reason) { allRows.length = 0; handle.update(0, totalCount); if (typeof console !== 'undefined') { console.info('[SQL Studio] The export through WSProxy stopped (' + reason + '). Reading every row through the REST API instead.'); }; failedReads.push('the WSProxy read'); step(0, pageSize, false); }; function step(rowsFetched, currentPageSize, isRetry) { if (rowsFetched >= totalCount) { finish(); return; }; var page = rowsFetched / currentPageSize + 1; var heldChunk = (rowsFetched % pageSize === 0 && pageSize % currentPageSize === 0) ? held[rowsFetched / pageSize + 1] : null; if (heldChunk) { Array.prototype.push.apply(allRows, heldChunk); handle.update(allRows.length, totalCount); step(rowsFetched + heldChunk.length, currentPageSize, false); return; }; var next = function () { step(rowsFetched, currentPageSize, isRetry); }; var callsLeft = restCallsLeft(rowsFetched, currentPageSize); if (!isCallAllowed(next, 'Export ' + fmtRows(totalCount) + '?', 'It takes ' + (usualCalls ? moreCallsText(callsLeft, 'API') : apiCallsText(callsLeft)) + '.')) return; Api.call('getResults', { deKey: deKey, page: page, pageSize: currentPageSize, orderBy: orderBy, columns: outputColumns, restOnly: true }, callOwner).then(function (res) { takeRestCount(res); withDisplayDates(res, columnTypes); var pageRows = res.rows || []; Array.prototype.push.apply(allRows, pageRows); var newRowsFetched = rowsFetched + pageRows.length; handle.update(allRows.length, totalCount); if (newRowsFetched >= totalCount || !pageRows.length) { finish(); } else if (pageRows.length < currentPageSize) { if (rowsFetched === 0) { step(newRowsFetched, pageRows.length, false); return; }; fail('Export stopped: Marketing Cloud Engagement returned ' + fmtNumber(pageRows.length) + ' rows for page ' + fmtNumber(page) + ' instead of ' + fmtNumber(currentPageSize) + '. Nothing was downloaded.'); } else { step(newRowsFetched, currentPageSize, false); }; }).catch(function (err) { var smaller = exportRetryPageSize(currentPageSize); if (!isRetry && smaller) { step(rowsFetched, smaller, true); return; }; fail('Export failed: ' + err.message); }); }; if (isFreeExport) { readFree(); } else { readUsualWay(); }; }; proceed(); }, _downloadCsv: function (rows, columns, deName, note, doneText) { var lines = [columns.map(csvEscape).join(',')]; rows.forEach(function (r) { lines.push(r.map(csvEscape).join(',')); }); var csv = '' + lines.join('\r\n'); var name = (deName || 'sqlstudio-results') + '.csv'; downloadText(name, csv, 'text/csv;charset=utf-8'); Toast.show(doneText ? doneText + (note || '') : 'Exported ' + fmtRows(rows.length) + ' to ' + name + (note ? '.' + note : ''), 'success'); } }; var TAB_LABEL_MAX_CHARS = 40; var TAB_PHASE_LABELS = { validating: 'Checking', preparing: 'Starting', running: 'Running', fetching: 'Loading' }; var TAB_AUTO_NAME_MAX_CHARS = 36; function sourceTableNames(sql) { var tokens; try { tokens = Tools.tokenize(sql); } catch (e) { return []; }; var sig = tokens.filter(function (t) { return t.type !== 'whitespace' && t.type !== 'comment'; }); var names = []; var seen = {}; var isCallAt = []; for (var i = 0; i < sig.length; i++) { var t = sig[i]; if (t.type === 'punct' && t.value === '(') { isCallAt.push(i > 0 && sig[i - 1].type === 'identifier'); continue; }; if (t.type === 'punct' && t.value === ')') { isCallAt.pop(); continue; }; var isSource = t.type === 'keyword' && (t.upper === 'FROM' || t.upper === 'JOIN') && !t.isDistinctFrom; if (!isSource || isCallAt[isCallAt.length - 1] === true) continue; var j = i + 1; if (!sig[j] || sig[j].type !== 'identifier') continue; var name = sig[j].value; var isDotted = sig[j + 1] && sig[j + 1].type === 'punct' && sig[j + 1].value === '.' && sig[j + 2] && sig[j + 2].type === 'identifier'; if (isDotted) { name = sig[j + 2].value; j += 2; }; if (sig[j + 1] && sig[j + 1].type === 'punct' && sig[j + 1].value === '(') continue; /* a table function */ var key = String(name).toLowerCase(); if (!seen[key]) { seen[key] = true; names.push(String(name)); }; }; return names; }; function tableListLabel(names) { if (!names.length) return ''; var label = names[0]; var shown = 1; while (shown < names.length && (label + ', ' + names[shown]).length <= TAB_AUTO_NAME_MAX_CHARS) { label += ', ' + names[shown]; shown++; }; return shown < names.length ? label + ' +' + (names.length - shown) : label; }; function QueryTab(index) { this.index = index; this.id = 'tab' + (index + 1); this.state = makeTabState(); this.runner = new QueryRunner(this); this.monacoModel = null; this.monacoViewState = null; this.textareaText = ''; this.pendingText = null; this.hasUnseenFinish = false; this._restoringEntry = null; this.activeExport = null; this.activeSave = null; this.slotHolds = []; this.saveModel = null; this.existingModel = null; this.saveChoice = 'new'; this.changedAt = 0; this.loadedChangedAt = 0; }; var QueryTabs = { _tabs: [], _activeIndex: 0, init: function () { this._tabs = []; for (var i = 0; i < QUERY_SLOT_COUNT; i++) this._tabs.push(new QueryTab(i)); this._activeIndex = 0; state = this._tabs[0].state; Runner = this._tabs[0].runner; }, all: function () { return this._tabs; }, get: function (index) { return this._tabs[index]; }, active: function () { return this._tabs[this._activeIndex]; }, activeIndex: function () { return this._activeIndex; }, findByOpenedQueryId: function (id) { if (!id) return null; for (var i = 0; i < this._tabs.length; i++) { var oq = this._tabs[i].state.openedQuery; if (oq && oq.id === id) return this._tabs[i]; }; return null; }, pickSlot: function (forTab) { var used = {}; var heldUntil = {}; var now = Date.now(); for (var i = 0; i < this._tabs.length; i++) { var t = this._tabs[i]; (t.slotHolds || []).forEach(function (hold) { if (hold.until > now && !(heldUntil[hold.slot] >= hold.until)) heldUntil[hold.slot] = hold.until; }); if (t === forTab) continue; if (t.activeSave && t.activeSave.slot) used[t.activeSave.slot] = true; if (t.runner.pendingSlot) { used[t.runner.pendingSlot] = true; continue; }; var run = t.state.activeRun; if (isRunInFlight(t.state.runnerState) && t.runner.hasRunStarted && run && run.slot) used[run.slot] = true; }; for (var slot = 1; slot <= QUERY_SLOT_COUNT; slot++) { if (!used[slot] && !heldUntil[slot]) return slot; }; var soonest = null; for (var held = 1; held <= QUERY_SLOT_COUNT; held++) { if (!used[held] && (soonest === null || heldUntil[held] < heldUntil[soonest])) soonest = held; }; return soonest !== null ? soonest : 1; /* every slot used: unreachable with QUERY_SLOT_COUNT tabs sharing a QUERY_SLOT_COUNT-slot pool */ }, holdSlot: function (tab, slot, until) { tab.slotHolds = this._liveHolds(tab.slotHolds).filter(function (hold) { return hold.slot !== slot; }); tab.slotHolds.push({ slot: slot, until: until }); }, _liveHolds: function (holds) { var now = Date.now(); if (!Array.isArray(holds)) return []; return holds.filter(function (hold) { return !!hold && typeof hold.slot === 'number' && hold.slot >= 1 && hold.slot <= QUERY_SLOT_COUNT && typeof hold.until === 'number' && hold.until > now; }).map(function (hold) { return { slot: hold.slot, until: hold.until }; }); }, labelFor: function (tab, isFull) { var oq = tab.state.openedQuery; var name = (oq && oq.name) ? oq.name : (this.autoNameFor(tab) || ('Query ' + (tab.index + 1))); if (isFull) return name; return name.length > TAB_LABEL_MAX_CHARS ? name.slice(0, TAB_LABEL_MAX_CHARS - 1) + '…' : name; }, autoNameFor: function (tab) { var text = Editor.textFor(tab) || ''; if (tab._autoName && tab._autoName.text === text) return tab._autoName.name; var name = tableListLabel(sourceTableNames(text)); tab._autoName = { text: text, name: name }; return name; }, switchTo: function (index) { if (index === this._activeIndex || !this._tabs[index]) return; if (!this._isEditorReady) return; var outgoing = this._tabs[this._activeIndex]; var incoming = this._tabs[index]; Editor._saveTab(outgoing); var savedError = incoming.state.statusError; var savedNotices = (incoming.state.statusNotices || []).slice(); this._activeIndex = index; incoming.hasUnseenFinish = false; state = incoming.state; Runner = incoming.runner; Editor._loadTab(incoming); /* re-lints incoming, which also repaints the Problems list */ UI._scheduleJoinRefresh(); UI.renderOpenedQueryBadge(); UI.renderToolbarState(incoming.state.runnerState); UI.renderRunSummary(); UI.renderResults(); UI.clearRunOutcome(); if (savedError) UI.showStatusError(savedError.message, savedError.details, savedError.gate, savedError.hint, savedError.about); for (var i = 0; i < savedNotices.length; i++) UI.addStatusNotice(savedNotices[i]); UI.syncFormatNotice(); UI.syncValidateNotice(); UI._refreshStatusEmptyState(); UI.setActiveTab(incoming.state.bottomPanel || (incoming.state.results ? 'results' : 'status')); this.render(); scheduleSaveTabs(); }, isEditedSinceLoad: function (tab) { return tab.changedAt !== tab.loadedChangedAt; }, markChanged: function (tab) { tab.changedAt = Date.now(); scheduleSaveTabs(); }, _dropResults: function (tab) { tab.runner.attempt++; tab._restoringEntry = null; tab.state.activeRun = null; tab.state.results = null; tab.state.statusError = null; tab.state.statusNotices = []; tab.runner.messages = []; tab.runner.currentRunSql = ''; tab.runner.currentRunIsFragment = false; tab.runner.currentRunContext = null; tab.runner.hasStaleShownStatus = false; tab.runner.currentRunStartedAt = null; tab.runner.currentRunFinishedAt = null; Grid.setData(tab, null); if (tab.state.runnerState !== 'idle') tab.runner.setState('idle'); if (tab === this.active()) { UI.clearRunOutcome(); UI.renderRunSummary(); }; }, clearResults: function (tab) { if (isRunInFlight(tab.state.runnerState) || tab.activeExport || tab.activeSave) return; if (!tab.state.activeRun && !tab.state.results && tab.state.runnerState === 'idle') return; tab.runner._deleteRunDe(tab.state.activeRun); this._dropResults(tab); this.markChanged(tab); }, onTabChanged: function (tab, justFinished) { if (justFinished) this.markChanged(tab); if (justFinished && tab !== this.active()) { tab.hasUnseenFinish = true; var label = this.labelFor(tab); if (tab.state.runnerState === 'done' && unreadPageOf(tab.state)) { Toast.show(label + ' finished, but its rows did not load', 'warning'); } else if (tab.state.runnerState === 'done') { Toast.show(label + ' finished: ' + fmtRows(tab.state.results ? tab.state.results.count : 0), 'success'); } else { Toast.show(label + ' failed', 'error'); }; }; this.render(); }, render: function () { if (UI.renderQueryTabStrip) UI.renderQueryTabStrip(); }, hasRunInFlight: function () { for (var i = 0; i < this._tabs.length; i++) { if (isRunInFlight(this._tabs[i].state.runnerState)) return true; }; return false; }, hasUnsavedOrRunningWork: function () { for (var i = 0; i < this._tabs.length; i++) { var st = this._tabs[i].state; if (st.dirty || isRunInFlight(st.runnerState) || this._tabs[i].activeSave) return true; }; return false; }, snapshot: function (includeActiveRun) { var tabs = []; for (var i = 0; i < this._tabs.length; i++) { var tab = this._tabs[i]; var st = tab.state; var save = SaveToDe.snapshotOf(tab); var holds = this._liveHolds(tab.slotHolds); var unanswered = SaveToDe.unansweredHoldOf(tab); if (unanswered && !holds.some(function (hold) { return hold.slot === unanswered.slot && hold.until >= unanswered.until; })) { holds = holds.filter(function (hold) { return hold.slot !== unanswered.slot; }).concat([unanswered]); }; var isPolling = st.runnerState === 'running' || st.runnerState === 'fetching'; var hasResults = !isPolling && !!st.results && !!st.activeRun && st.activeRun.deKey === st.results.deKey; var unread = hasResults ? null : unreadPageOf(st); var unreadCount = unread ? knownRowCount(unread.run) : null; var loading = tab._restoringEntry || tab._pendingRestoreEntry; if (!hasResults && !isPolling && loading && loading.results && loading.resultsRun && (!st.activeRun || st.activeRun === loading.resultsRun)) { tabs.push({ changedAt: tab.changedAt || 0, text: Editor.textFor(tab), lastLoadedText: st.lastLoadedText, openedQuery: st.openedQuery, activeRun: null, runSql: loading.runSql, runIsFragment: loading.runIsFragment, runContext: loading.runContext, runStartedAt: loading.runStartedAt, runFinishedAt: loading.runFinishedAt, runCalls: loading.runCalls, results: loading.results, resultsRun: loading.resultsRun, save: save, slotHolds: holds.length ? holds : undefined }); continue; }; tabs.push({ changedAt: tab.changedAt || 0, text: Editor.textFor(tab), lastLoadedText: st.lastLoadedText, openedQuery: st.openedQuery, activeRun: (includeActiveRun && isPolling) ? st.activeRun : null, runSql: tab.runner.currentRunSql, runIsFragment: tab.runner.currentRunIsFragment || undefined, runContext: tab.runner.currentRunContext || undefined, runStartedAt: (st.runnerState === 'done' || (includeActiveRun && isPolling)) ? tab.runner.currentRunStartedAt : null, runFinishedAt: st.runnerState === 'done' ? tab.runner.currentRunFinishedAt : null, runCalls: ((hasResults || !!unread || (includeActiveRun && isPolling)) && tab.runner._calls) ? tallyRunCalls(tab.runner._calls) : null, results: hasResults ? { deKey: st.results.deKey, page: st.results.page, pageSize: st.results.pageSize, orderBy: st.results.orderBy, columns: st.activeRun.columns, count: st.results.count } : (unread ? { deKey: unread.run.deKey, page: 1, orderBy: null, columns: unread.run.columns, count: unreadCount, restOnly: unreadCount === null ? true : undefined } : null), resultsRun: (hasResults || unread) ? st.activeRun : null, save: save, slotHolds: holds.length ? holds : undefined }); }; var payload = { activeIndex: this._activeIndex, savedAt: Date.now(), tabs: tabs }; if (includeActiveRun) payload.sidebarCollapsed = Shared.sidebarCollapsed; return payload; }, persist: function () { var key = tabsKey(); if (!key) return; var payload = this.snapshot(false); var stored = readJSON(key, null); if (stored && stored.tabs) { for (var i = 0; i < payload.tabs.length; i++) { var theirs = stored.tabs[i]; if (theirs && (theirs.changedAt || 0) > (payload.tabs[i].changedAt || 0)) payload.tabs[i] = theirs; }; }; writeJSON(key, payload); }, applySaved: function (saved) { if (!saved || !saved.tabs) return; for (var i = 0; i < this._tabs.length && i < saved.tabs.length; i++) { var entry = saved.tabs[i]; if (!entry) continue; this._tabs[i].slotHolds = this._liveHolds(entry.slotHolds); if (isRunInFlight(this._tabs[i].state.runnerState)) continue; if (this._tabs[i].activeSave) { this._tabs[i].activeSave = null; this._tabs[i].saveModel = null; this._tabs[i].existingModel = null; }; if (this._tabs[i].state.activeRun || this._tabs[i]._restoringEntry) this._dropResults(this._tabs[i]); this._tabs[i]._pendingRestoreEntry = entry; if (!this.isEditedSinceLoad(this._tabs[i])) this._tabs[i].state.openedQuery = entry.openedQuery || null; }; if (typeof saved.activeIndex === 'number' && saved.activeIndex >= 0 && saved.activeIndex < this._tabs.length) { this._pendingActiveIndex = saved.activeIndex; }; }, applyPendingContent: function () { for (var i = 0; i < this._tabs.length; i++) { var tab = this._tabs[i]; var entry = tab._pendingRestoreEntry; if (!entry || this.isEditedSinceLoad(tab)) continue; tab.changedAt = entry.changedAt || 0; var text = entry.text || ''; if (tab === this.active() && Editor.mode) { if (Editor.getValue() !== text) Editor.setValue(text, true); tab.changedAt = entry.changedAt || 0; this._setLoadedText(tab, entry, text); if (Editor.mode === 'textarea') tab.textareaText = text; UI.renderOpenedQueryBadge(); } else if (tab.monacoModel) { if (tab.monacoModel.getValue() !== text) tab.monacoModel.setValue(text); this._setLoadedText(tab, entry, text); } else { tab.pendingText = text; tab.textareaText = text; this._setLoadedText(tab, entry, text); }; tab.loadedChangedAt = tab.changedAt; }; }, _setLoadedText: function (tab, entry, text) { tab.state.lastLoadedText = (tab.state.openedQuery && entry.lastLoadedText) ? entry.lastLoadedText : text; tab.state.dirty = tab.state.lastLoadedText !== text; }, adoptOtherWindowChanges: function (stored) { if (!stored || !stored.tabs || !this._isEditorReady) return; var hasAdopted = false; for (var i = 0; i < this._tabs.length; i++) { var tab = this._tabs[i]; var entry = stored.tabs[i]; if (!entry || tab === this.active()) continue; if ((entry.changedAt || 0) <= (tab.changedAt || 0) || tab.changedAt !== tab.loadedChangedAt) continue; if (isRunInFlight(tab.state.runnerState) || tab.activeExport || tab.activeSave || tab._restoringEntry || tab._pendingRestoreEntry) continue; var text = entry.text || ''; if (tab.monacoModel) { if (tab.monacoModel.getValue() !== text) tab.monacoModel.setValue(text); } else { tab.pendingText = text; tab.textareaText = text; }; tab.state.openedQuery = entry.openedQuery || null; this._setLoadedText(tab, entry, text); tab.changedAt = entry.changedAt; tab.loadedChangedAt = entry.changedAt; var shownDeKey = tab.state.results ? tab.state.results.deKey : null; if (entry.results && entry.resultsRun && entry.results.deKey !== shownDeKey) { tab.state.statusError = null; tab.state.statusNotices = []; tab.runner.restoreResults(entry); } else if (!entry.results && tab.state.activeRun) { this._dropResults(tab); }; hasAdopted = true; }; if (hasAdopted) this.render(); }, finishRestore: function () { var reads = []; for (var i = 0; i < this._tabs.length; i++) { var tab = this._tabs[i]; var entry = tab._pendingRestoreEntry; tab._pendingRestoreEntry = null; if (!entry || isRunInFlight(tab.state.runnerState)) continue; if (entry.save) SaveToDe.resume(tab, entry.save); if (entry.activeRun) { tab.runner.resumeRun(entry); } else if (entry.results && entry.resultsRun) { reads.push({ tab: tab, entry: entry }); }; }; this._restoreResultsInTurn(reads); }, _restoreResultsInTurn: function (reads) { if (!reads.length) return; var onScreen = typeof this._pendingActiveIndex === 'number' ? this._pendingActiveIndex : this._activeIndex; reads.sort(function (a, b) { var rankA = a.tab.index === onScreen ? -1 : a.tab.index; var rankB = b.tab.index === onScreen ? -1 : b.tab.index; return rankA - rankB; }); reads.forEach(function (read) { read.tab._restoringEntry = read.entry; }); var readFrom = function (position) { if (position >= reads.length) return; var read = reads[position]; var next = function () { readFrom(position + 1); }; if (read.tab._restoringEntry !== read.entry || isRunInFlight(read.tab.state.runnerState)) { if (read.tab._restoringEntry === read.entry) read.tab._restoringEntry = null; next(); return; }; read.tab.runner.restoreResults(read.entry).then(next, next); }; readFrom(0); }, _activatePending: function () { if (typeof this._pendingActiveIndex === 'number' && this._pendingActiveIndex !== this._activeIndex) { this.switchTo(this._pendingActiveIndex); }; this._pendingActiveIndex = null; }, protectedDeKeys: function (restore) { var keys = []; var add = function (k) { if (k && keys.indexOf(k) === -1) keys.push(k); }; if (restore && restore.tabs) { for (var i = 0; i < restore.tabs.length; i++) { var entry = restore.tabs[i]; if (!entry) continue; if (entry.activeRun) add(entry.activeRun.deKey); if (entry.results) add(entry.results.deKey); }; }; for (var j = 0; j < this._tabs.length; j++) { var st = this._tabs[j].state; if (st.activeRun) add(st.activeRun.deKey); if (st.results) add(st.results.deKey); }; return keys; } }; var scheduleSaveTabs = debounce(function () { QueryTabs.persist(); }, SQL_SAVE_DEBOUNCE_MS); var History = { items: [], _unsaved: [], key: function () { return Session.data.userKey ? userKeyFor(LS.history) : null; }, load: function () { var key = this.key(); var stored = key ? (readJSON(key, []) || []) : []; this.items = this._unsaved.concat(stored).slice(0, 100); if (key && this._unsaved.length) { this._unsaved = []; this.save(); }; }, save: function () { var key = this.key(); if (key) writeJSON(key, this.items); }, add: function (entry) { this.items.unshift(entry); if (this.items.length > 100) this.items.length = 100; if (!this.key()) this._unsaved.unshift(entry); this.save(); UI.refreshHistoryDialogIfOpen(); }, clear: function () { this.items = []; this._unsaved = []; this.save(); }, search: function (query) { var q = String(query || '').toLowerCase(); if (!q) return this.items; return this.items.filter(function (i) { return i.sql.toLowerCase().indexOf(q) !== -1; }); } }; var SNIPPET_NAME_MAX = 100; var SNIPPET_SQL_MAX = 20000; var HOVER_SQL_LINES = 15; var HOVER_SQL_CHARS = 800; function snippetDay(value) { var text = String(value || ''); var pad = function (n) { return (n < 10 ? '0' : '') + n; }; var zoned = /^(\d{4})-(\d{2})-(\d{2})T(\d{2}):(\d{2}):(\d{2})(?:\.\d+)?(Z|([+-])(\d{2}):(\d{2}))$/.exec(text); if (zoned) { var offsetMinutes = zoned[7] === 'Z' ? 0 : (Number(zoned[9]) * 60 + Number(zoned[10])) * (zoned[8] === '-' ? -1 : 1); var at = new Date(Date.UTC(Number(zoned[1]), Number(zoned[2]) - 1, Number(zoned[3]), Number(zoned[4]), Number(zoned[5]), Number(zoned[6])) - offsetMinutes * 60000); return at.getFullYear() + '-' + pad(at.getMonth() + 1) + '-' + pad(at.getDate()); }; var match = /^(\d{4}-\d{2}-\d{2})/.exec(text); return match ? match[1] : ''; }; function snippetHoverText(item) { var created = snippetDay(item.createdDate); var changed = snippetDay(item.modifiedDate); var saved = 'Saved by ' + (item.author || 'an unknown author') + (created ? ' on ' + created : '') + (changed && changed !== created ? ', changed on ' + changed : '') + '.'; return String(item.name || '') + '\n\n' + hoverSql(item.sql) + '\n\n' + saved; }; function hoverSql(text) { var lines = String(text || '').replace(/\r\n/g, '\n').split('\n'); var isCut = lines.length > HOVER_SQL_LINES; var sql = lines.slice(0, HOVER_SQL_LINES).join('\n'); if (sql.length > HOVER_SQL_CHARS) { sql = sql.slice(0, HOVER_SQL_CHARS); isCut = true; }; return sql + (isCut ? '\n...' : ''); }; function tidySnippetSql(text) { return String(text || '').replace(/\r\n/g, '\n').replace(/^(?:[ \t]*\n)+/, '').replace(/\s+$/, ''); }; var Snippets = { items: null, status: 'idle', error: null, savedAt: 0, _promise: null, _loadNumber: 0, _changedDuringRead: null, isEnabled: function () { var data = Session.data || {}; var hasMid = data.mid !== null && data.mid !== undefined && String(data.mid) !== ''; return !!data.snippetsEnabled && hasMid && typeof data.midSig === 'string' && data.midSig !== ''; }, payload: function (fields) { var data = Session.data || {}; var payload = { mid: String(data.mid), midSig: data.midSig || null }; Object.keys(fields || {}).forEach(function (key) { payload[key] = fields[key]; }); return payload; }, load: function (isForced) { var self = this; if (!self.isEnabled()) return Promise.resolve([]); if (self._promise) return self._promise; if (self.status === 'loaded' && !isForced) return Promise.resolve(self.items); var loadNumber = ++self._loadNumber; self.status = 'loading'; self.error = null; self._changedDuringRead = {}; UI.renderSidebarTree(); self._promise = Api.call('listSnippets', self.payload()).then(function (res) { if (loadNumber !== self._loadNumber) return self.items; self._promise = null; self.items = self._withChanges((res && res.items) || []); self.status = 'loaded'; self.savedAt = Date.now(); UI.renderSidebarTree(); return self.items; }, function (err) { if (loadNumber !== self._loadNumber) return self.items || []; self._promise = null; self._changedDuringRead = null; self.status = 'failed'; self.error = err || new Error('The snippets did not load.'); UI.renderSidebarTree(); return self.items || []; }); return self._promise; }, _noteChange: function (id, item) { if (this._promise && this._changedDuringRead) this._changedDuringRead[id] = item; }, _withChanges: function (list) { var changed = this._changedDuringRead || {}; this._changedDuringRead = null; var kept = list.filter(function (item) { return !Object.prototype.hasOwnProperty.call(changed, item.id); }); Object.keys(changed).forEach(function (id) { if (changed[id]) kept.push(changed[id]); }); return kept.sort(Snippets.compareNames); }, compareNames: function (a, b) { var an = String(a.name).toLowerCase(); var bn = String(b.name).toLowerCase(); return an < bn ? -1 : an > bn ? 1 : 0; }, find: function (id) { var list = this.items || []; for (var i = 0; i < list.length; i++) if (list[i].id === id) return list[i]; return null; }, checkName: function (name, exceptId) { if (!name) return 'Give the snippet a name.'; if (name.length > SNIPPET_NAME_MAX) return 'A snippet\'s name can have at most ' + SNIPPET_NAME_MAX + ' characters. This one has ' + name.length + '.'; var list = this.items || []; for (var i = 0; i < list.length; i++) { if (list[i].id !== exceptId && list[i].name.toLowerCase() === name.toLowerCase()) { return 'This Business Unit has a snippet named "' + list[i].name + '" already. Names are not case sensitive.'; }; }; return ''; }, save: function (name, sql, id) { var self = this; var fields = { name: name, sql: sql }; if (id) fields.id = id; return Api.call('saveSnippet', self.payload(fields)).then(function (res) { var item = res.item; self._noteChange(item.id, item); if (self.items) { var list = self.items.filter(function (held) { return held.id !== item.id; }); list.push(item); list.sort(Snippets.compareNames); self.items = list; } else if (!self._promise) { self.load(true); }; return item; }, function (err) { if (err && id && (err.code === 'NOT_FOUND' || err.code === 'BAD_REQUEST')) self.load(true); throw err; }); }, remove: function (id) { var self = this; var dropHeld = function () { self._noteChange(id, null); if (self.items) self.items = self.items.filter(function (held) { return held.id !== id; }); }; return Api.call('deleteSnippet', self.payload({ id: id })).then(dropHeld, function (err) { if (err && err.code === 'NOT_FOUND') { dropHeld(); self.load(true); return; }; if (err && err.code === 'BAD_REQUEST') self.load(true); throw err; }); }, openSave: function () { var self = this; var source = Editor.getSnippetSource(); var sql = tidySnippetSql(source.text); if (!sql) { Toast.show('Write or select some SQL first, then save it as a snippet.', 'info'); return; }; if (sql.length > SNIPPET_SQL_MAX) { Toast.show('A snippet can hold at most ' + fmtNumber(SNIPPET_SQL_MAX) + ' characters of SQL, and ' + (source.isSelection ? 'the selection has ' : 'the query has ') + fmtNumber(sql.length) + '. Select a shorter part of it.', 'warning'); return; }; var lineCount = sql.split('\n').length; function saveNew(name) { return self.save(name, sql, null); }; function savedNew(item) { Toast.show('Saved the snippet "' + item.name + '".', 'success'); UI._nextSidebarFocusKey = 'snippets:save'; UI.renderSidebarTree(); }; this._openNameDialog({ title: 'Save as a snippet', confirmLabel: 'Save snippet', value: '', exceptId: null, note: (source.isSelection ? 'Saves the selected SQL' : 'Saves the whole query') + ' (' + fmtNumber(lineCount) + (lineCount === 1 ? ' line' : ' lines') + '). Everyone who uses SQL Studio in this Business Unit can insert it.', preview: sql, submit: saveNew, done: savedNew }); }, openRename: function (item) { var self = this; function rename(name) { return name === item.name ? Promise.resolve(item) : self.save(name, item.sql, item.id); }; function renamed(saved) { if (saved !== item) Toast.show('Renamed the snippet to "' + saved.name + '".', 'success'); UI._nextSidebarFocusKey = 'snippet:' + saved.id + ':rename'; UI.renderSidebarTree(); }; this._openNameDialog({ title: 'Rename snippet', confirmLabel: 'Rename', value: item.name, exceptId: item.id, note: 'Everyone who uses SQL Studio in this Business Unit sees the new name.', preview: null, submit: rename, done: renamed }); }, confirmDelete: function (item) { var self = this; Dialogs.confirm('Delete the snippet "' + item.name + '"?', 'Everyone who uses SQL Studio in this Business Unit loses it. This cannot be undone.', function () { self.remove(item.id).then(function () { Toast.show('Deleted the snippet "' + item.name + '".', 'success'); UI._nextSidebarFocusKey = 'snippets:save'; UI.renderSidebarTree(); }, function (err) { Toast.show('Could not delete the snippet: ' + (err && err.message ? err.message : 'unknown error'), 'error'); UI.renderSidebarTree(); }); }, null, true, 'Delete'); }, _openNameDialog: function (o) { var inputId = 'ss-field-' + uid(); var errorId = 'ss-field-' + uid(); var isBusy = false; var input = h('input', { class: 'ss-input', type: 'text', id: inputId, maxlength: String(SNIPPET_NAME_MAX), required: true, 'aria-describedby': errorId, autocomplete: 'off' }, null); input.value = o.value; var error = h('div', { class: 'ss-message-item ss-message-item--error', id: errorId, role: 'alert', hidden: true }, null); var saveBtn = h('button', { class: 'ss-btn ss-btn--primary', type: 'button', onclick: function () { submit(); } }, o.confirmLabel); var modal = null; function showError(text) { error.textContent = text; error.hidden = false; input.focus(); }; function submit() { if (isBusy) return; var name = input.value.trim(); var problem = Snippets.checkName(name, o.exceptId); if (problem) { showError(problem); return; }; error.hidden = true; isBusy = true; saveBtn.disabled = true; o.submit(name).then(function (item) { modal.close(); o.done(item); }, function (err) { isBusy = false; saveBtn.disabled = false; showError(err && err.message ? err.message : 'The snippet was not saved.'); }); }; input.addEventListener('keydown', function (ev) { if (isPlainEnter(ev)) { ev.preventDefault(); submit(); }; }); input.addEventListener('input', function () { error.hidden = true; }); modal = UI.openModal({ title: o.title, body: [ h('div', { class: 'ss-field' }, [h('label', { class: 'ss-label', for: inputId }, 'Name'), input, error]), h('p', { class: 'ss-snippet-note' }, o.note), o.preview ? h('pre', { class: 'ss-snippet-preview', 'aria-label': 'The SQL to save' }, o.preview) : null ], footer: [ h('button', { class: 'ss-btn', type: 'button', onclick: function () { modal.close(); } }, 'Cancel'), saveBtn ] }); setTimeout(function () { input.focus(); input.select(); }, 0); } }; var Settings = { data: {}, load: function () { var stored = readJSON(LS.settings, {}) || {}; var merged = {}; for (var k in DEFAULT_SETTINGS) { if (!Object.prototype.hasOwnProperty.call(DEFAULT_SETTINGS, k)) continue; merged[k] = stored[k] !== undefined ? stored[k] : DEFAULT_SETTINGS[k]; }; if (merged.splitRatio === OLD_DEFAULT_SPLIT_RATIO) merged.splitRatio = DEFAULT_SETTINGS.splitRatio; if (stored.colorTheme === undefined && stored.theme === 'light') merged.colorTheme = 'light'; this.data = merged; }, save: function () { writeJSON(LS.settings, this.data); }, set: function (key, value) { this.data[key] = value; this.save(); this.apply(); }, systemTheme: function () { var isLight = !!(global.matchMedia && global.matchMedia('(prefers-color-scheme: light)').matches); return isLight ? 'light' : 'dark'; }, themeChoice: function () { var choice = this.data.colorTheme; return (choice === 'light' || choice === 'dark') ? choice : 'system'; }, effectiveTheme: function () { var choice = this.data.colorTheme; return (choice === 'light' || choice === 'dark') ? choice : this.systemTheme(); }, followSystem: function () { var self = this; if (!global.matchMedia) return; var query = global.matchMedia('(prefers-color-scheme: light)'); function onSystemChange() { if (self.data.colorTheme === 'system') self.apply(); } if (query.addEventListener) query.addEventListener('change', onSystemChange); else if (query.addListener) query.addListener(onSystemChange); }, apply: function () { var root = document.querySelector('.ss'); if (root) { root.setAttribute('data-theme', this.effectiveTheme()); root.setAttribute('data-theme-choice', this.themeChoice()); }; var themeBtn = UI.el && UI.el.themeBtn; if (themeBtn) { var label = THEME_LABELS[this.themeChoice()]; themeBtn.title = label; themeBtn.setAttribute('aria-label', label); }; if (Editor.mode === 'monaco' && Editor.instance) { Editor.monaco.editor.setTheme(this.effectiveTheme() === 'dark' ? 'ss-dark' : 'ss-light'); }; } }; var UI = { el: {}, activeTab: 'status', modalStack: [], buildShell: function (root) { root.className = 'ss'; root.setAttribute('data-theme', Settings.effectiveTheme()); root.setAttribute('data-theme-choice', Settings.themeChoice()); clearNode(root); injectIconSprite(); var toolbar = this._buildToolbar(); var sidebar = this._buildSidebar(); var main = this._buildMain(); var overlayBg = h('div', { class: 'ss-sidebar-overlay-bg', onclick: function () { UI.setSidebarCollapsed(true); } }, null); var body = h('div', { class: 'ss-body' }, [main, overlayBg, sidebar]); /* sidebar on the right, like Diagramforce's stencil */ var toasts = h('div', { class: 'ss-toasts', 'aria-live': 'polite' }, null); var modalLayer = h('div', { class: 'ss-modal-layer' }, null); root.appendChild(toolbar); root.appendChild(body); root.appendChild(toasts); root.appendChild(modalLayer); this.el.root = root; this.el.body = body; this.el.overlayBg = overlayBg; this.el.toasts = toasts; this.el.modalLayer = modalLayer; Toast.init(toasts); this._wireSidebarResizer(); this._wireSplitResizer(); this._wireKeyboard(); this._wireTips(); this._applyResponsive(); global.addEventListener('resize', debounce(function () { UI._applyResponsive(); Editor.layout(); }, 150)); }, openModal: function (opts) { var self = this; var overlay = h('div', { class: 'ss-modal-overlay', role: 'presentation' }, null); var titleId = 'ss-modal-title-' + uid(); var closeBtn = opts.dismissable === false ? null : h('button', { class: 'ss-modal__close', type: 'button', 'aria-label': 'Close dialog', onclick: function () { close(); } }, '×'); var header = h('div', { class: 'ss-modal__header' }, [ h('h2', { class: 'ss-modal__title', id: titleId }, opts.title || ''), closeBtn ]); var body = h('div', { class: 'ss-modal__body' }, opts.body || null); var footer = opts.footer ? h('div', { class: 'ss-modal__footer' }, opts.footer) : null; var dialog = h('div', { class: 'ss-modal' + (opts.wide ? ' ss-modal--wide' : '') + (opts.xwide ? ' ss-modal--xwide' : ''), role: 'dialog', 'aria-modal': 'true', 'aria-labelledby': titleId }, [header, body, footer]); overlay.appendChild(dialog); var previousFocus = document.activeElement; function trapKeydown(ev) { if (ev.key === 'Escape') { if (opts.dismissable !== false) { ev.preventDefault(); close(); }; return; }; if (ev.key === 'Tab') { var focusables = dialog.querySelectorAll('button, [href], input, select, textarea, [tabindex]:not([tabindex="-1"])'); if (!focusables.length) return; var list = Array.prototype.slice.call(focusables).filter(function (n) { return !n.disabled && n.offsetParent !== null; }); if (!list.length) return; var first = list[0], last = list[list.length - 1]; if (ev.shiftKey && document.activeElement === first) { ev.preventDefault(); last.focus(); } else if (!ev.shiftKey && document.activeElement === last) { ev.preventDefault(); first.focus(); }; }; }; function close(result) { overlay.removeEventListener('keydown', trapKeydown); if (overlay.parentNode) overlay.parentNode.removeChild(overlay); self.modalStack = self.modalStack.filter(function (m) { return m.close !== close; }); if (previousFocus && previousFocus.focus) { try { previousFocus.focus(); } catch (e) { /* ignore */ }; }; if (opts.onClose) opts.onClose(result); }; overlay.addEventListener('keydown', trapKeydown); if (opts.dismissable !== false) { overlay.addEventListener('mousedown', function (ev) { if (ev.target === overlay) close(); }); }; this.el.modalLayer.appendChild(overlay); this.modalStack.push({ close: close, overlay: overlay }); setTimeout(function () { var toFocus = dialog.querySelector('[data-autofocus]') || dialog.querySelector('input, button, select, textarea'); if (toFocus) toFocus.focus(); }, 0); return { close: close, dialog: dialog, body: body }; }, closeTopModal: function () { if (this.modalStack.length) this.modalStack[this.modalStack.length - 1].close(); }, openDropdown: function (anchorEl, key, opts) { var self = this; if (this._openDropdown) { var isSameKey = this._openDropdown.key === key; this._openDropdown.close(); if (isSameKey) return null; }; anchorEl.classList.add('is-active'); anchorEl.setAttribute('aria-expanded', 'true'); var titleId = 'ss-dropdown-title-' + uid(); var closeBtn = h('button', { class: 'ss-modal__close', type: 'button', 'aria-label': 'Close', onclick: function () { close(); } }, '×'); var header = opts.title ? h('div', { class: 'ss-modal__header' }, [h('h2', { class: 'ss-modal__title', id: titleId }, opts.title), closeBtn]) : null; var body = h('div', { class: 'ss-modal__body' }, opts.body || null); var footer = opts.footer ? h('div', { class: 'ss-modal__footer' }, opts.footer) : null; var panel = h('div', { class: 'ss-dropdown-panel' + (opts.xwide ? ' ss-dropdown-panel--xwide' : (opts.wide ? ' ss-dropdown-panel--wide' : '')), role: 'dialog', 'aria-modal': 'false', 'aria-labelledby': header ? titleId : null }, [header, body, footer]); var scrim = h('div', { class: 'ss-dropdown-scrim' }, null); var mount = h('div', { class: 'ss-dropdown-mount' }, panel); this.el.modalLayer.appendChild(scrim); this.el.modalLayer.appendChild(mount); var rect = anchorEl.getBoundingClientRect(); var rootRect = this.el.root.getBoundingClientRect(); mount.style.top = (rect.bottom - rootRect.top + 4) + 'px'; var left = rect.left - rootRect.left; var maxLeft = rootRect.width - Math.min(opts.xwide ? 640 : (opts.wide ? 420 : 300), rootRect.width - 16) - 8; mount.style.left = Math.max(8, Math.min(left, maxLeft)) + 'px'; panel.style.maxWidth = (rootRect.width - 16) + 'px'; var previousFocus = document.activeElement; var isClosed = false; function trapKeydown(ev) { if (self.modalStack.length) return; if (ev.key === 'Escape') { ev.preventDefault(); close(); return; }; if (ev.key === 'Tab') { var focusables = Array.prototype.slice.call(panel.querySelectorAll('button, [href], input, select, textarea, [tabindex]:not([tabindex="-1"])')) .filter(function (n) { return !n.disabled && n.offsetParent !== null; }); if (!focusables.length) return; var first = focusables[0], last = focusables[focusables.length - 1]; if (ev.shiftKey && document.activeElement === first) { ev.preventDefault(); last.focus(); } else if (!ev.shiftKey && document.activeElement === last) { ev.preventDefault(); first.focus(); }; }; }; function onScrimDown() { close(); } function close() { if (isClosed) return; isClosed = true; document.removeEventListener('keydown', trapKeydown, true); scrim.removeEventListener('mousedown', onScrimDown); if (mount.parentNode) mount.parentNode.removeChild(mount); if (scrim.parentNode) scrim.parentNode.removeChild(scrim); anchorEl.classList.remove('is-active'); anchorEl.setAttribute('aria-expanded', 'false'); if (self._openDropdown && self._openDropdown.close === close) self._openDropdown = null; if (opts.onClose) opts.onClose(); if (previousFocus && previousFocus.focus) { try { previousFocus.focus(); } catch (e) { /* ignore */ }; }; }; document.addEventListener('keydown', trapKeydown, true); scrim.addEventListener('mousedown', onScrimDown); this._openDropdown = { key: key, close: close }; setTimeout(function () { var toFocus = panel.querySelector('[data-autofocus]') || panel.querySelector('input, button, select, textarea'); if (toFocus) toFocus.focus(); }, 0); return { close: close, panel: panel, body: body }; }, toggleDropdown: function (anchorEl, key) { if (key === 'history') Dialogs.openHistory(anchorEl); if (key === 'open') Dialogs.openOpenQuery(anchorEl); }, showBanner: function (message, kind) { var bar = h('div', { class: 'ss-banner' + (kind === 'error' ? ' ss-banner--error' : ''), role: 'alert' }, [ h('span', null, message), h('button', { class: 'ss-banner__close', type: 'button', 'aria-label': 'Dismiss', onclick: function () { bar.parentNode && bar.parentNode.removeChild(bar); } }, '×') ]); this.el.main.insertBefore(bar, this.el.main.firstChild); }, showConfigOutdatedBanner: function (message) { if (this._isConfigOutdatedShown || !this.el.main) return; this._isConfigOutdatedShown = true; this.showBanner('SQL Studio cannot run until the admin who installed it updates SQL Studio Backend. Send them this message: ' + message, 'error'); } }; Editor.insertTextAtCursor = function (text) { if (this.mode === 'monaco' && this.instance) { var sel = this.instance.getSelection(); this.instance.executeEdits('ss-insert', [{ range: sel, text: text, forceMoveMarkers: true }]); this.instance.focus(); } else if (this.textarea) { var ta = this.textarea; var start = ta.selectionStart, end = ta.selectionEnd; ta.value = ta.value.slice(0, start) + text + ta.value.slice(end); ta.selectionStart = ta.selectionEnd = start + text.length; ta.focus(); this._onContentChanged(); }; }; Editor.getCursorOffset = function () { if (this.mode === 'monaco' && this.instance && this.model) { var position = this.instance.getPosition(); return position ? this.model.getOffsetAt(position) : 0; }; return this.textarea ? this.textarea.selectionStart : 0; }; Editor.applyEdits = function (edits, cursor, source) { var self = this; if (!edits || !edits.length) return; var hasCursor = typeof cursor === 'number'; if (this.mode === 'monaco' && this.instance && this.model) { var model = this.model; var operations = edits.map(function (edit) { var from = model.getPositionAt(edit.start); var to = model.getPositionAt(edit.end); return { range: new self.monaco.Range(from.lineNumber, from.column, to.lineNumber, to.column), text: edit.text, forceMoveMarkers: hasCursor }; }); this.instance.pushUndoStop(); this.instance.executeEdits(source || (hasCursor ? 'ss-join-to-query' : 'ss'), operations); if (hasCursor) { var position = model.getPositionAt(cursor); this.instance.setPosition(position); this.instance.revealPositionInCenterIfOutsideViewport(position); }; this.instance.pushUndoStop(); if (hasCursor) this.instance.focus(); } else if (this.textarea) { var ta = this.textarea; var shifted = function (offset) { return edits.reduce(function (moved, edit) { return edit.end <= offset ? moved + edit.text.length - (edit.end - edit.start) : moved; }, offset); }; var selectionStart = shifted(ta.selectionStart); var selectionEnd = shifted(ta.selectionEnd); ta.value = Tools.applyEdits(ta.value, edits); ta.selectionStart = hasCursor ? cursor : selectionStart; ta.selectionEnd = hasCursor ? cursor : selectionEnd; if (hasCursor) ta.focus(); this._onContentChanged(); }; if (!hasCursor) this._runLint(); }; Editor.getSelectionOffsets = function () { if (this.mode === 'monaco' && this.instance && this.model) { var sel = this.instance.getSelection(); if (sel) return { start: this.model.getOffsetAt(sel.getStartPosition()), end: this.model.getOffsetAt(sel.getEndPosition()) }; } else if (this.textarea) { return { start: this.textarea.selectionStart, end: this.textarea.selectionEnd }; }; return { start: 0, end: 0 }; }; Editor.getSnippetSource = function () { var full = this.getValue(); var selection = this.getSelectionOffsets(); if (selection.end > selection.start) { var part = full.slice(selection.start, selection.end); if (part.trim()) return { text: part, isSelection: true }; }; return { text: full, isSelection: false }; }; Editor.insertSnippet = function (text, how) { var full = this.getValue(); var selection = how === 'replace' ? { start: 0, end: full.length } : this.getSelectionOffsets(); var written = how === 'cursor' ? wrapFullQueryInsert(full, selection.start, selection.end, text) : text; var lead = written.length > text.length && written.slice(0, text.length + 1) === '\n' + text ? 1 : 0; var cursor = selection.start + lead + text.length; this.applyEdits([{ start: selection.start, end: selection.end, text: written }], cursor, 'ss-insert-snippet'); }; Editor.insertFullQuery = function (text) { if (!this.getValue().trim()) { this.setValue(text, false); this.focus(); return; }; if (this.mode === 'monaco' && this.instance && this.model) { var sel = this.instance.getSelection(); var full = this.getValue(); var startOffset = this.model.getOffsetAt(sel.getStartPosition()); var endOffset = this.model.getOffsetAt(sel.getEndPosition()); var wrapped = wrapFullQueryInsert(full, startOffset, endOffset, text); this.instance.executeEdits('ss-insert-full', [{ range: sel, text: wrapped, forceMoveMarkers: true }]); this.instance.focus(); } else if (this.textarea) { var ta = this.textarea; var start = ta.selectionStart, end = ta.selectionEnd; var wrapped2 = wrapFullQueryInsert(ta.value, start, end, text); ta.value = ta.value.slice(0, start) + wrapped2 + ta.value.slice(end); ta.selectionStart = ta.selectionEnd = start + wrapped2.length; ta.focus(); this._onContentChanged(); }; }; function wrapFullQueryInsert(full, startOffset, endOffset, text) { var before = full.slice(0, startOffset); var after = full.slice(endOffset); var lineStart = before.lastIndexOf('\n') + 1; var isAtLineStart = before.slice(lineStart) === ''; var hasTextAfter = after.trim() !== ''; return (isAtLineStart ? '' : '\n') + text + (hasTextAfter ? '\n' : ''); }; function typeBadge(text, title, isCompact, isQuickTip) { var attrs = { class: 'ss-type-badge' + (isCompact ? ' ss-type-badge--compact' : '') }; attrs[isQuickTip ? 'data-tip' : 'title'] = title || text; return h('span', attrs, text); }; function sharedKindBadge(t, isCompact, isQuickTip) { if (t.sharedKind === 'synchronized') return typeBadge('SYNCED', 'Synchronized Data Extension of the parent Business Unit', isCompact, isQuickTip); return typeBadge('SHARED', 'Shared Data Extension of the parent Business Unit', isCompact, isQuickTip); }; function iconButton(label, iconId, onClick, extraClass) { return h('button', { class: 'ss-btn ss-btn--icon' + (extraClass ? ' ' + extraClass : ''), type: 'button', 'aria-label': label, title: label, onclick: onClick }, icon(iconId)); }; function toolbarButton(iconId, label, onClick, opts) { opts = opts || {}; var cls = 'ss-toolbar__button' + (opts.primary ? ' ss-toolbar__button--primary' : '') + (opts.secondary ? ' ss-toolbar__button--secondary' : '') + (label ? '' : ' ss-toolbar__button--icon-only'); var attrs = { class: cls, type: 'button', onclick: onClick }; if (opts.ariaLabel || !label) attrs['aria-label'] = opts.ariaLabel || label; if (opts.title || label) attrs.title = opts.title || label; /* the label doubles as the tooltip in icon-only mode */ if (opts.keys) attrs['aria-keyshortcuts'] = opts.keys; var children = []; if (iconId) children.push(icon(iconId)); if (label) children.push(h('span', null, label)); var btn = h('button', attrs, children); if (opts.ref) opts.ref(btn); return btn; }; var IS_MAC = /Mac|iPhone|iPad/.test((global.navigator && (global.navigator.platform || global.navigator.userAgent)) || ''); function shortcutLabel(key, withShift) { if (IS_MAC) return (withShift ? '⇧' : '') + '⌘' + key; return 'Ctrl+' + (withShift ? 'Shift+' : '') + key; }; function shortcutKeys(key, withShift) { return (IS_MAC ? 'Meta+' : 'Control+') + (withShift ? 'Shift+' : '') + key; }; function toolbarSeparator() { return h('div', { class: 'ss-toolbar__divider', 'aria-hidden': 'true' }, null); }; UI._buildHeader = function () { var self = this; var sidebarToggle = toolbarButton('side_list', null, function () { UI.setSidebarCollapsed(!Shared.sidebarCollapsed); }, { title: 'Toggle sidebar (' + shortcutLabel('B') + ')', keys: shortcutKeys('B'), ariaLabel: 'Toggle sidebar', ref: function (btn) { self.el.sidebarToggleBtn = btn; } }); var brand = h('a', { class: 'ss-toolbar__brand', href: 'https://mateuszdabrowski.pl', target: '_blank', rel: 'noopener noreferrer', 'aria-label': 'SQL Studio - mateuszdabrowski.pl' }, [ h('span', { class: 'ss-toolbar__brand-logo', 'aria-hidden': 'true' }, null), h('span', { class: 'ss-toolbar__brand-name' }, 'SQL Studio') ]); var themeBtn = h('button', { class: 'ss-toolbar__button ss-toolbar__button--icon-only', type: 'button', title: THEME_LABELS[Settings.themeChoice()], 'aria-label': THEME_LABELS[Settings.themeChoice()], onclick: function () { UI.toggleTheme(); } }, themeToggleIconSvg()); var helpBtn = h('button', { class: 'ss-toolbar__button ss-toolbar__button--icon-only', type: 'button', title: 'Help', 'aria-label': 'Help', 'aria-haspopup': 'true' }, helpIconSvg()); var helpDropdown = UI._buildHelpMenu(helpBtn); var updateBadge = h('button', { class: 'ss-update-badge', type: 'button', style: { display: 'none' }, onclick: function () { UI.showUpdateDialog(); } }, 'Update available'); var actions = h('div', { class: 'ss-header__actions' }, [updateBadge, themeBtn, helpDropdown, sidebarToggle]); /* toggle at the far right, next to the sidebar it controls */ this.el.themeBtn = themeBtn; this.el.helpBtn = helpBtn; this.el.updateBadge = updateBadge; return { sidebarToggle: sidebarToggle, brand: brand, actions: actions }; }; UI._buildHelpMenu = function (btn) { function menuButton(iconId, label, onClick) { return h('button', { class: 'ss-toolbar__menu-item ss-toolbar__menu-item--icon', type: 'button', onclick: function () { close(); btn.focus(); onClick(); } }, [iconId ? icon(iconId, 'ss-toolbar__menu-icon') : null, h('span', null, label)]); }; function menuLink(label, href) { return h('a', { class: 'ss-toolbar__menu-item ss-toolbar__menu-item--icon', href: href, target: '_blank', rel: 'noopener noreferrer', onclick: function () { close(); } }, [icon('new_window', 'ss-toolbar__menu-icon'), h('span', null, label)]); }; var menu = h('div', { class: 'ss-toolbar__menu' }, [ menuLink('MD SQL documentation', Config.data.docsBase), menuLink('SF MCE SQL reference', 'https://help.salesforce.com/s/articleView?id=mktg.mc_as_using_the_query_activity.htm&type=5'), menuLink('MS T-SQL documentation', 'https://learn.microsoft.com/en-us/sql/t-sql/language-reference?view=sql-server-ver15'), h('div', { class: 'ss-toolbar__menu-separator', role: 'separator' }, null), menuButton('magicwand', 'Write SQL with Claude', function () { Dialogs.openSkill(); }), menuButton('info', 'About SQL Studio', function () { Dialogs.openAbout(); }) ]); var wrap = h('div', { class: 'ss-toolbar__dropdown' }, [btn, menu]); var isOpen = false; function onDocDown(ev) { if (!wrap.contains(ev.target)) close(); } function onDocKey(ev) { if (ev.key === 'Escape') { close(); btn.focus(); } } function close() { if (!isOpen) return; isOpen = false; wrap.classList.remove('ss-toolbar__dropdown--open'); btn.setAttribute('aria-expanded', 'false'); document.removeEventListener('mousedown', onDocDown, true); document.removeEventListener('keydown', onDocKey, true); }; btn.setAttribute('aria-expanded', 'false'); btn.addEventListener('click', function () { if (isOpen) { close(); return; }; isOpen = true; wrap.classList.add('ss-toolbar__dropdown--open'); btn.setAttribute('aria-expanded', 'true'); document.addEventListener('mousedown', onDocDown, true); document.addEventListener('keydown', onDocKey, true); }); return wrap; }; var DOCUMENT_ORIGIN = 'https://diagramforce.com'; var DOCUMENT_URL = DOCUMENT_ORIGIN + '/#import=postmessage'; var DOCUMENT_READY_MS = 15000; var DOCUMENT_FIELDS_MS = 30000; var DOCUMENT_GIVE_UP_MS = 60000; var DOCUMENT_TRUST_LINE = 'Opens Diagramforce, my free and open source diagramming app, in a new tab. The diagram holds table and field names and the query\'s expressions, never rows. Diagramforce keeps it in your browser, or in your own Google Drive if you connected one.'; var DOCUMENT_BLOCKED = 'Your browser blocked the new tab. Allow pop-ups for this page and try again.'; var DOCUMENT_SILENT = 'Diagramforce did not answer within 15 seconds. Check that the new tab shows Diagramforce, then try again.'; var DOCUMENT_TOO_SLOW = 'The diagram was not ready within a minute. Try again.'; var DOCUMENT_TAB_CLOSED = 'The Diagramforce tab was closed before the diagram reached it. Try again.'; var DOCUMENT_KINDS = [ { kind: 'model', label: 'Data model: fields in the query', icon: 'df-datamodel' }, { kind: 'modelAll', label: 'Data model: all fields', icon: 'df-datamodel' }, { kind: 'lineage', label: 'Lineage', icon: 'df-datamapping' } ]; var DOCUMENT_BUTTON_TITLE = 'Document this query in Diagramforce'; var documentJudged = { sql: null, reason: '' }; function documentUnavailableReason() { var sql = ''; try { sql = Editor.getRunTarget().sql || ''; } catch (e) { return ''; }; if (sql === documentJudged.sql) return documentJudged.reason; var reason = ''; if (!sql.trim()) { reason = 'Nothing to document.'; } else { try { reason = Tools.queryDiagramTables(sql, Schema.getIndex()).error || ''; } catch (e) { reason = ''; }; }; documentJudged = { sql: sql, reason: reason }; return reason; }; UI.syncDocumentAvailability = function () { var btn = this.el && this.el.documentBtn; if (!btn) return; var reason = documentUnavailableReason(); if (reason) { btn.setAttribute('aria-disabled', 'true'); btn.classList.add('is-unavailable'); btn.title = reason; } else { btn.removeAttribute('aria-disabled'); btn.classList.remove('is-unavailable'); btn.title = DOCUMENT_BUTTON_TITLE; }; }; function documentTargetTable(oq) { var lower = function (text) { return String(text).toLowerCase(); }; var tables = Schema.getIndex().tables || []; var byKey = oq.targetKey ? tables.filter(function (t) { return !!t && t.kind !== 'dataview' && !!t.key && lower(t.key) === lower(oq.targetKey); }) : []; byKey.sort(function (a, b) { return (a.isShared ? 1 : 0) - (b.isShared ? 1 : 0); }); if (byKey[0]) return byKey[0]; var named = Schema.byName[lower(oq.targetName || oq.targetKey)]; return named && named.kind !== 'dataview' ? named : null; }; var Documenter = { _run: null, open: function (kind) { var self = this; if (this._run) { Toast.show('Diagramforce is still being prepared. Try again in a moment.', 'info'); return; }; var sql = Editor.getRunTarget().sql; if (!sql.trim()) { Toast.show('Nothing to document.', 'warning'); return; }; var check = Tools.queryDiagramTables(sql, Schema.getIndex()); if (check.error) { Toast.show(check.error, 'warning'); return; }; var win = global.open(DOCUMENT_URL, '_blank'); if (!win) { Toast.show(DOCUMENT_BLOCKED, 'error'); return; }; var run = { win: win, isReady: false, isDone: false, json: null, timer: null, fieldTimer: null, giveUpTimer: null, dismissProgress: null, onMessage: null }; run.onMessage = function (ev) { if (run.isDone || ev.origin !== DOCUMENT_ORIGIN || ev.source !== run.win) return; var data = ev.data; if (!data || data.source !== 'diagramforce' || data.type !== 'ready' || run.isReady) return; run.isReady = true; clearTimeout(run.timer); self._send(run); }; this._run = run; global.addEventListener('message', run.onMessage); run.timer = setTimeout(function () { self._fail(run, DOCUMENT_SILENT, false); }, DOCUMENT_READY_MS); run.giveUpTimer = setTimeout(function () { self._fail(run, DOCUMENT_TOO_SLOW, true); }, DOCUMENT_GIVE_UP_MS); this._prepare(run, kind, sql, (state.openedQuery && state.openedQuery.name) ? state.openedQuery.name : 'Query', state.openedQuery); }, _prepare: function (run, kind, sql, title, oq) { var self = this; var listWait = Schema.isDeListLoading() ? Schema.loadDataExtensionList().catch(function () { /* the sidebar says it did not load */ }) : Promise.resolve(); listWait.then(function () { if (run.isDone) return null; var read = Tools.queryDiagramTables(sql, Schema.getIndex()); var toLoad = []; (read.tables || []).forEach(function (t) { if (t.table && t.kind === 'de' && !t.table.fields && toLoad.indexOf(t.table) === -1) toLoad.push(t.table); }); var target = null; if (kind === 'lineage' && oq && (oq.targetKey || oq.targetName)) { var targetTable = documentTargetTable(oq); target = { name: oq.targetName || (targetTable && targetTable.name) || oq.targetKey, action: oq.updateType || '', table: targetTable }; if (targetTable && targetTable.kind === 'de' && !targetTable.fields && toLoad.indexOf(targetTable) === -1) toLoad.push(targetTable); }; return self._loadFields(run, toLoad).then(function () { return target || false; }); }).then(function (target) { if (run.isDone || target === null) return; var options = { kind: kind, title: title }; if (target) options.target = { name: target.name, action: target.action, fields: target.table && target.table.fields ? target.table.fields : [] }; var diagram = Tools.buildQueryDiagram(sql, Schema.getIndex(), options); if (diagram.error) { self._fail(run, diagram.error, true); return; }; run.json = JSON.stringify(diagram); self._send(run); }).catch(function (err) { self._fail(run, 'Could not make the diagram: ' + (err && err.message ? err.message : err), true); }); }, _loadFields: function (run, tables) { if (!tables.length) return Promise.resolve(); var done = 0; var noun = tables.length === 1 ? '1 Data Extension' : tables.length + ' Data Extensions'; var show = function () { if (run.dismissProgress) run.dismissProgress(); run.dismissProgress = run.isDone ? null : Toast.show('Loading the fields of ' + noun + ': ' + done + ' done.', 'info', { sticky: true }); }; show(); var loads = Promise.all(tables.map(function (t) { return Schema.fields(t).then(null, function () { /* named below, with the ones that did not finish */ }).then(function () { done++; show(); }); })); var limit = new Promise(function (resolve) { run.fieldTimer = setTimeout(resolve, DOCUMENT_FIELDS_MS); }); return Promise.race([loads, limit]).then(function () { clearTimeout(run.fieldTimer); if (run.dismissProgress) run.dismissProgress(); run.dismissProgress = null; var missing = tables.filter(function (t) { return !t.fields; }).map(function (t) { return t.name; }); if (missing.length && !run.isDone) { Toast.show('The fields of ' + missing.join(', ') + ' did not load, so the diagram lists the fields the query uses.', 'warning'); }; }); }, _send: function (run) { if (run.isDone || !run.isReady || run.json === null) return; if (run.win.closed) { this._fail(run, DOCUMENT_TAB_CLOSED, false); return; }; try { run.win.postMessage({ source: 'diagramforce', type: 'import', v: 1, json: run.json }, DOCUMENT_ORIGIN); } catch (err) { this._fail(run, 'Could not send the diagram to Diagramforce: ' + err.message, false); return; }; this._finish(run); Toast.show('Sent to Diagramforce.', 'success'); }, _finish: function (run) { run.isDone = true; global.removeEventListener('message', run.onMessage); clearTimeout(run.timer); clearTimeout(run.fieldTimer); clearTimeout(run.giveUpTimer); if (run.dismissProgress) run.dismissProgress(); run.dismissProgress = null; if (this._run === run) this._run = null; }, _fail: function (run, message, isTabEmpty) { if (run.isDone) return; this._finish(run); Toast.show(message, 'error'); if (isTabEmpty) { try { if (typeof run.win.close === 'function') run.win.close(); } catch (err) { /* the tab is Diagramforce's now */ } }; } }; UI._buildDocumentMenu = function () { var btn = toolbarButton('table', 'Document', function (ev) { var reason = documentUnavailableReason(); if (reason) { Toast.show(reason, 'warning'); return; }; toggle(ev.detail === 0); }, { secondary: true, title: DOCUMENT_BUTTON_TITLE }); this.el.documentBtn = btn; btn.setAttribute('aria-haspopup', 'menu'); btn.setAttribute('aria-expanded', 'false'); btn.setAttribute('aria-controls', 'ss-document-menu'); var items = DOCUMENT_KINDS.map(function (entry) { return h('button', { class: 'ss-toolbar__menu-item ss-toolbar__menu-item--icon', type: 'button', role: 'menuitem', dataset: { documentKind: entry.kind }, onclick: function () { close(); btn.focus(); Documenter.open(entry.kind); } }, [icon(entry.icon, 'ss-toolbar__menu-icon'), h('span', null, entry.label)]); }); var note = h('p', { class: 'ss-toolbar__menu-note', id: 'ss-document-note' }, DOCUMENT_TRUST_LINE); var list = h('div', { id: 'ss-document-menu', role: 'menu', 'aria-label': 'Document this query', 'aria-describedby': 'ss-document-note' }, items); var menu = h('div', { class: 'ss-toolbar__menu ss-toolbar__menu--start ss-toolbar__menu--document' }, [list, h('div', { class: 'ss-toolbar__menu-separator', role: 'separator' }, null), note]); var wrap = h('div', { class: 'ss-toolbar__dropdown' }, [btn, menu]); var isOpen = false; function onDocDown(ev) { if (!wrap.contains(ev.target)) close(); } function onDocKey(ev) { if (ev.key === 'Escape') { close(); btn.focus(); return; }; var at = items.indexOf(document.activeElement); var next = -1; if (ev.key === 'ArrowDown') next = at === -1 ? 0 : (at + 1) % items.length; else if (ev.key === 'ArrowUp') next = at === -1 ? items.length - 1 : (at - 1 + items.length) % items.length; else if (ev.key === 'Home') next = 0; else if (ev.key === 'End') next = items.length - 1; if (next === -1) return; ev.preventDefault(); items[next].focus(); }; function close() { if (!isOpen) return; isOpen = false; wrap.classList.remove('ss-toolbar__dropdown--open'); btn.setAttribute('aria-expanded', 'false'); document.removeEventListener('mousedown', onDocDown, true); document.removeEventListener('keydown', onDocKey, true); }; function toggle(byKeyboard) { if (isOpen) { close(); return; }; isOpen = true; wrap.classList.add('ss-toolbar__dropdown--open'); btn.setAttribute('aria-expanded', 'true'); document.addEventListener('mousedown', onDocDown, true); document.addEventListener('keydown', onDocKey, true); if (byKeyboard) items[0].focus(); }; wrap.addEventListener('focusout', function (ev) { if (isOpen && ev.relatedTarget && !wrap.contains(ev.relatedTarget)) close(); }); return wrap; }; UI.toggleTheme = function () { var next = { system: 'light', light: 'dark', dark: 'system' }; Settings.set('colorTheme', next[Settings.themeChoice()]); }; UI._tableExpanded = {}; UI._searchClosed = {}; UI._dvGroupCollapsed = { Journey: true, Mobile: true, Automation: true, Other: true }; UI._sectionCollapsed = { dataViews: false, dataExtensions: false, snippets: false }; UI._deFolderOpen = {}; UI._deFolderShown = {}; UI._nextSidebarFocusKey = null; UI._lostSidebarFocusKey = null; UI._joinRows = []; UI._joinPlan = null; UI._joinBusy = {}; UI.sidebarQuery = ''; UI._buildSidebar = function () { var self = this; var savedSections = readJSON(sidebarKey(), null); this._sectionCollapsed = { dataViews: !!(savedSections && savedSections.dataViews), dataExtensions: !!(savedSections && savedSections.dataExtensions), snippets: !!(savedSections && savedSections.snippets) }; var searchInput = h('input', { class: 'ss-input', type: 'search', placeholder: 'Search Data Extensions...', 'aria-label': 'Search Data Extensions', oninput: debounce(function (ev) { self._onSidebarSearch(ev.target.value); }, 120) }, null); var searchClear = h('button', { class: 'ss-sidebar__search-clear', type: 'button', 'aria-label': 'Clear search', style: { display: 'none' }, onclick: function () { searchInput.value = ''; self._onSidebarSearch(''); searchInput.focus(); } }, icon('close')); var search = h('div', { class: 'ss-sidebar__search' }, [searchInput, searchClear]); var tree = h('div', { class: 'ss-sidebar__tree' }, null); this.el.searchInput = searchInput; this.el.searchClear = searchClear; var resizer = h('div', { class: 'ss-sidebar__resizer', 'aria-hidden': 'true' }, null); var sidebar = h('div', { class: 'ss-sidebar' }, [search, tree, resizer]); sidebar.style.width = (Settings.data.sidebarWidth || 280) + 'px'; this.el.sidebar = sidebar; this.el.sidebarTree = tree; this.el.sidebarResizer = resizer; return sidebar; }; UI._onSidebarSearch = function (text) { this.sidebarQuery = text; this.el.searchClear.style.display = text ? '' : 'none'; if (!String(text || '').trim()) this._searchClosed = {}; if (String(text || '').trim().length >= 2) Schema.loadDataExtensionList().catch(function () { /* handled by the section re-render */ }); this.renderSidebarTree(); }; function matchLocalTables(list, query) { var matches = []; var raw = String(query || '').trim(); var q = isUnderscoreSearch(raw) ? raw : normalizeForSearch(raw); var prepare = isUnderscoreSearch(raw) ? function (s) { return String(s || '').toLowerCase(); } : normalizeForSearch; list.forEach(function (t) { var isNameMatch = prepare(t.name).indexOf(q) !== -1; var isKeyMatch = t.key ? prepare(t.key).indexOf(q) !== -1 : false; var matchedFields = null; if (t.fields) { var fieldMatches = t.fields.filter(function (f) { return prepare(f.name).indexOf(q) !== -1; }); if (fieldMatches.length) matchedFields = fieldMatches; }; if (isNameMatch || isKeyMatch || matchedFields) matches.push({ table: t, matchedFields: matchedFields }); }); return matches; }; function matchTargetTables(list, query) { var raw = String(query || '').trim(); if (!raw) return []; var q = raw.toLowerCase(); var starts = []; var holds = []; var keys = []; list.forEach(function (t) { var name = String(t.name || '').toLowerCase(); if (name.indexOf(q) === 0) starts.push(t); else if (name.indexOf(q) !== -1) holds.push(t); else if (raw.length >= 3 && t.key && String(t.key).toLowerCase().indexOf(q) === 0) keys.push(t); }); var byName = function (a, b) { return compareTableNames({ table: a }, { table: b }); }; return starts.sort(byName).concat(holds.sort(byName), keys.sort(byName)); }; function targetMetaText(t, folderPaths) { var folderText = t.isShared ? (t.folderPath || '') : (folderPaths[String(t.categoryId)] || ''); return [folderText ? 'Folder: ' + folderText : '', t.key ? 'Key: ' + t.key : ''].filter(Boolean).join(' · '); }; function chosenTargetSummary(t, folderPaths, query) { var text = String(query || '').trim(); var caption = 'Chosen Data Extension' + (text ? '. ArrowDown shows the matches for "' + firstLine(text, 40) + '".' : ''); var metaText = targetMetaText(t, folderPaths); return h('div', { class: 'ss-list__summary', 'data-save': 'target-summary' }, [ h('div', { class: 'ss-list__caption' }, caption), h('div', { class: 'ss-list__summary-name' }, [t.name, ' ', t.isShared ? sharedKindBadge(t) : typeBadge('DE')]), metaText ? h('div', { class: 'ss-list__summary-meta' }, metaText) : null ]); }; function compareTableNames(a, b) { var an = a.table.name.toLowerCase(); var bn = b.table.name.toLowerCase(); return an < bn ? -1 : an > bn ? 1 : 0; }; var DE_SECTION_ROW_LIMIT = 50; function deListEmptyPrompt(searchable) { if (Schema.deListSavedAt()) { var parentCount = searchable.filter(function (t) { return t.isShared; }).length; if (parentCount) return 'Type above to search ' + fmtNumber(searchable.length - parentCount) + ' Data Extensions and ' + fmtNumber(parentCount) + ' from the parent Business Unit.'; return 'Type above to search ' + fmtNumber(searchable.length) + ' Data Extensions.'; }; return "Type above to search this Business Unit's Data Extensions."; }; function minutesAgoLabel(savedAt) { var minutes = Math.floor((Date.now() - savedAt) / 60000); if (minutes < 1) return 'Loaded just now'; if (minutes < 60) return 'Loaded ' + minutes + ' min ago'; return 'Loaded ' + Math.floor(minutes / 60) + ' h ago'; }; UI.setSidebarCollapsed = function (isCollapsed) { Shared.sidebarCollapsed = isCollapsed; this.el.sidebar.classList.toggle('is-collapsed', isCollapsed); this._syncSidebarToggleButton(); this._syncOverlay(); Editor.layout(); }; UI._syncSidebarToggleButton = function () { if (!this.el.sidebarToggleBtn) return; this.el.sidebarToggleBtn.classList.toggle('is-active', !Shared.sidebarCollapsed); this.el.sidebarToggleBtn.setAttribute('aria-pressed', String(!Shared.sidebarCollapsed)); }; UI.renderSidebarTree = function () { var self = this; var container = this.el.sidebarTree; var active = document.activeElement; var focusKey = (active && container.contains(active) && active.getAttribute) ? active.getAttribute('data-focus-key') : null; if (!focusKey && (!active || active === document.body)) focusKey = this._lostSidebarFocusKey; this._lostSidebarFocusKey = null; if (this._nextSidebarFocusKey) focusKey = this._nextSidebarFocusKey; this._nextSidebarFocusKey = null; this._joinRows = []; this._joinPlan = null; clearNode(container); var raw = (this.sidebarQuery || '').trim(); var dvMatches = raw ? matchLocalTables(Schema.dataViews, raw) : null; var isDvCollapsed = !!this._sectionCollapsed.dataViews; container.appendChild(self._renderSectionTitle('dataViews', 'Data Views', dvMatches ? dvMatches.length : null)); if (!isDvCollapsed) { var dvBody = h('div', { class: 'ss-tree__section-body' }, null); container.appendChild(dvBody); if (!raw) { DATA_VIEW_GROUP_ORDER.forEach(function (group) { var items = Schema.dataViews.filter(function (t) { return t.group === group; }); if (!items.length) return; dvBody.appendChild(self._renderDataViewGroup(group, items)); }); } else if (!dvMatches.length) { dvBody.appendChild(h('div', { class: 'ss-tree__empty' }, 'No Data Views match.')); } else { dvMatches.forEach(function (m) { dvBody.appendChild(self._renderTreeRow(m.table, m.matchedFields)); }); }; }; self._renderDataExtensionsSection(container, raw); self._renderSnippetsSection(container, raw); self._dockSectionTitles(); if (focusKey) { var keyed = container.querySelectorAll('[data-focus-key]'); var isFound = false; for (var i = 0; i < keyed.length && !isFound; i++) { if (keyed[i].getAttribute('data-focus-key') === focusKey) { keyed[i].focus(); isFound = true; }; }; if (!isFound) this._lostSidebarFocusKey = focusKey; }; }; UI._renderSectionTitle = function (id, label, matchCount) { var self = this; var isCollapsed = !!this._sectionCollapsed[id]; var text = label + ((isCollapsed && matchCount !== null) ? ' · ' + fmtNumber(matchCount) : ''); return h('button', { class: 'ss-tree__section-title ss-tree__section-toggle', type: 'button', 'aria-expanded': String(!isCollapsed), 'data-focus-key': 'section:' + id, onclick: function (ev) { self._onSectionTitle(id, ev.currentTarget); } }, [h('span', { class: 'ss-tree__section-caret', 'aria-hidden': 'true' }, isCollapsed ? '▸' : '▾'), text]); }; UI._sectionDockOf = function (title) { return (title.parentNode && title.parentNode.classList && title.parentNode.classList.contains('ss-tree__section-head')) ? title.parentNode : title; }; UI._dockSectionTitles = function () { var docks = Array.prototype.filter.call(this.el.sidebarTree.children, function (el) { return el.classList.contains('ss-tree__section-title') || el.classList.contains('ss-tree__section-head'); }); docks.forEach(function (el, i) { el.classList.add('ss-tree__dock'); el.style.setProperty('--ss-dock-above', String(i)); el.style.setProperty('--ss-dock-below', String(docks.length - 1 - i)); }); }; UI._onSectionTitle = function (id, title) { var dock = this._sectionDockOf(title); var before = dock.previousElementSibling; var isDockedBelow = !!before && before.getBoundingClientRect().bottom > dock.getBoundingClientRect().top + 1; if (!isDockedBelow) { this._toggleSection(id); return; }; if (this._sectionCollapsed[id]) { this._sectionCollapsed[id] = false; writeJSON(sidebarKey(), this._sectionCollapsed); this.renderSidebarTree(); }; this._scrollToSection(id); }; UI._scrollToSection = function (id) { var container = this.el.sidebarTree; var title = container.querySelector('[data-focus-key="section:' + id + '"]'); if (!title) return; var dock = this._sectionDockOf(title); var before = dock.previousElementSibling; if (!before) { container.scrollTop = 0; return; }; var place = before.getBoundingClientRect().bottom - container.getBoundingClientRect().top; container.scrollTop += place - (parseFloat(getComputedStyle(dock).top) || 0); }; UI._renderReloadButton = function (focusKey, what, savedAt, isLoading, loadingText, onReload) { var label = isLoading ? loadingText : 'Reload ' + what + '.' + (savedAt ? ' ' + minutesAgoLabel(savedAt) + '.' : ''); return h('button', { class: 'ss-tree__action ss-tree__section-action' + (isLoading ? ' is-busy' : ''), type: 'button', 'data-tip': label, 'aria-label': label, 'aria-busy': isLoading ? 'true' : 'false', 'aria-disabled': isLoading ? 'true' : 'false', 'data-focus-key': focusKey, onclick: function () { if (!isLoading) onReload(); } }, icon('refresh')); }; UI._toggleSection = function (id) { this._sectionCollapsed[id] = !this._sectionCollapsed[id]; writeJSON(sidebarKey(), this._sectionCollapsed); this.renderSidebarTree(); }; UI._renderDataViewGroup = function (group, tables) { var self = this; var isCollapsed = !!this._dvGroupCollapsed[group]; var caret = h('button', { class: 'ss-tree__caret', type: 'button', 'aria-label': (isCollapsed ? 'Expand' : 'Collapse') + ' ' + group, 'aria-expanded': String(!isCollapsed), 'data-focus-key': 'group:' + group, onclick: function () { self._dvGroupCollapsed[group] = !isCollapsed; self.renderSidebarTree(); } }, isCollapsed ? '▸' : '▾'); var row = h('div', { class: 'ss-tree__row ss-tree__folder', 'data-tip': group, onclick: function () { self._dvGroupCollapsed[group] = !isCollapsed; self.renderSidebarTree(); } }, [ caret, h('span', { class: 'ss-tree__name' }, group) ]); var wrap = h('div', null, row); if (!isCollapsed) { wrap.appendChild(h('div', { class: 'ss-tree__children' }, tables.map(function (t) { return self._renderTreeRow(t, null); }))); }; return wrap; }; UI._renderDataExtensionsSection = function (container, raw) { var self = this; var isLoading = Schema.isDeListLoading(); var isFirstLoad = isLoading && !(Schema.deList && Schema.deList.length); var loadError = Schema.deListError(); var deMatches = (raw && !isFirstLoad && !loadError) ? matchLocalTables(Schema.searchableList(), raw).sort(compareTableNames) : null; var savedAt = Schema.deListSavedAt(); var reloadBtn = self._renderReloadButton('reload', 'the Data Extension list', savedAt, isLoading, savedAt ? 'Reloading the Data Extension list…' : 'Loading the Data Extension list…', function () { Schema.loadDataExtensionList(true).catch(function () { /* handled by the section re-render */ }); }); container.appendChild(h('div', { class: 'ss-tree__section-head' }, [ self._renderSectionTitle('dataExtensions', 'Data Extensions', deMatches ? deMatches.length : null), reloadBtn ])); if (this._sectionCollapsed.dataExtensions) return; var body = h('div', { class: 'ss-tree__section-body' }, null); container.appendChild(body); if (isFirstLoad) { body.appendChild(h('div', { class: 'ss-tree__loading' }, [spinner(), " Loading this Business Unit's Data Extensions..."])); return; }; if (loadError) { body.appendChild(h('div', { class: 'ss-tree__empty' }, [ 'Could not load Data Extensions: ' + loadError.message + ' ', h('button', { class: 'ss-tree__link', type: 'button', 'data-focus-key': 'retry', onclick: function () { Schema.loadDataExtensionList().catch(function () { /* handled by the section re-render */ }); } }, 'Retry') ])); return; }; if (!raw) { var tree = Schema.deTree(); if (tree) { body.appendChild(h('div', { class: 'ss-tree__hint' }, deListEmptyPrompt(Schema.searchableList()))); self._appendFolderContents(body, tree.top, tree.top.folders.concat(tree.unlisted ? [tree.unlisted] : [])); } else if (Schema.areDeFoldersLoading()) { body.appendChild(h('div', { class: 'ss-tree__hint' }, deListEmptyPrompt(Schema.searchableList()))); body.appendChild(h('div', { class: 'ss-tree__loading' }, [spinner(), ' Loading the folders...'])); } else { body.appendChild(h('div', { class: 'ss-tree__empty' }, deListEmptyPrompt(Schema.searchableList()))); }; } else { var ownMatches = deMatches.filter(function (m) { return !m.table.isShared; }); var parentMatches = deMatches.filter(function (m) { return !!m.table.isShared; }); if (!deMatches.length) { body.appendChild(h('div', { class: 'ss-tree__empty' }, 'No Data Extensions match.')); } else { self._appendTableMatches(body, ownMatches); if (parentMatches.length) { body.appendChild(h('div', { class: 'ss-tree__group-title' }, 'Parent BU')); self._appendTableMatches(body, parentMatches); }; }; }; var statusLines = []; if (isLoading && !savedAt) { statusLines.push(h('div', { class: 'ss-tree__list-status', role: 'status' }, [spinner(), ' Loading the list: ' + Schema.deList.length.toLocaleString('en-US') + ' so far...'])); } else if (!isLoading && savedAt && Schema.isParentListCapped()) { statusLines.push(h('div', { class: 'ss-tree__list-status' }, "The parent Business Unit's list stops short: it has more Data Extensions than SQL Studio reads.")); }; if (Session.isBackendBu() && isParentBusinessUnit() === false) { statusLines.push(h('div', { class: 'ss-tree__note', 'data-de-note': 'child-install' }, "SQL Studio is installed in this child Business Unit, so the parent's shared and synchronized Data Extensions are not listed and have no fields here. Queries on their ENT. names still run. Installed in the parent, SQL Studio lists them for every child.")); }; var hint = body.firstChild && (body.firstChild.classList.contains('ss-tree__hint') || body.firstChild.classList.contains('ss-tree__empty')) ? body.firstChild : null; statusLines.forEach(function (line) { body.insertBefore(line, hint ? hint.nextSibling : body.firstChild); }); }; UI._renderSnippetsSection = function (container, raw) { var self = this; if (!Snippets.isEnabled()) return; var isSearch = !!raw; var needle = raw.toLowerCase(); var all = Snippets.items || []; var matches = isSearch ? all.filter(function (item) { return item.name.toLowerCase().indexOf(needle) !== -1 || String(item.sql).toLowerCase().indexOf(needle) !== -1; }) : all; var saveBtn = h('button', { class: 'ss-tree__action ss-tree__section-action', type: 'button', 'data-tip': 'Save the selection, or the whole query, as a snippet', 'aria-label': 'Save as a snippet', 'data-focus-key': 'snippets:save', onclick: function () { Snippets.openSave(); } }, icon('save')); var reloadBtn = self._renderReloadButton('snippets:reload', 'the snippets', Snippets.savedAt || null, Snippets.status === 'loading', Snippets.items ? 'Reloading the snippets…' : 'Loading the snippets…', function () { Snippets.load(true); }); container.appendChild(h('div', { class: 'ss-tree__section-head' }, [ self._renderSectionTitle('snippets', 'Snippets', (isSearch && Snippets.items) ? matches.length : null), saveBtn, reloadBtn ])); if (this._sectionCollapsed.snippets) return; var body = h('div', { class: 'ss-tree__section-body' }, null); container.appendChild(body); body.appendChild(h('div', { class: 'ss-tree__hint' }, 'Shared with everyone who uses SQL Studio in this Business Unit.')); if (!Snippets.items) { if (Snippets.status === 'failed') { body.appendChild(h('div', { class: 'ss-tree__empty' }, [ 'Could not load the snippets: ' + Snippets.error.message + ' ', h('button', { class: 'ss-tree__link', type: 'button', 'aria-label': 'Retry the snippets', 'data-focus-key': 'snippets:retry', onclick: function () { Snippets.load(true); } }, 'Retry') ])); } else { body.appendChild(h('div', { class: 'ss-tree__loading' }, [spinner(), ' Loading the snippets...'])); }; return; }; if (!matches.length) { body.appendChild(h('div', { class: 'ss-tree__empty' }, isSearch ? 'No snippets match.' : 'No snippets yet. Select some SQL, or write a query, and use the save button in the title above.')); } else { matches.forEach(function (item) { body.appendChild(self._renderSnippetRow(item)); }); }; if (Snippets.status === 'failed' && Snippets.error) { body.insertBefore(h('div', { class: 'ss-tree__list-status', role: 'alert' }, [ 'Could not reload the snippets: ' + Snippets.error.message + ' ', h('button', { class: 'ss-tree__link', type: 'button', 'aria-label': 'Retry the snippets', 'data-focus-key': 'snippets:retry', onclick: function () { Snippets.load(true); } }, 'Retry') ]), body.firstChild ? body.firstChild.nextSibling : null); }; }; UI._renderSnippetRow = function (item) { var self = this; var focusKey = 'snippet:' + item.id; var insert = function () { self._insertSnippet(item); }; var toggle = h('div', { class: 'ss-tree__toggle ss-snippet__insert', tabindex: '0', role: 'button', 'aria-label': 'Insert the snippet ' + item.name, 'data-focus-key': focusKey, onclick: insert, onkeydown: pressOnKey(insert) }, [h('span', { class: 'ss-tree__caret', 'aria-hidden': 'true' }, null), h('span', { class: 'ss-tree__name' }, item.name)]); var actions = null; if (item.isOwn) { actions = h('span', { class: 'ss-tree__actions' }, [ h('button', { class: 'ss-tree__action ss-snippet__rename', type: 'button', 'data-tip': 'Rename', 'aria-label': 'Rename the snippet ' + item.name, 'data-focus-key': focusKey + ':rename', onclick: function () { Snippets.openRename(item); } }, icon('edit')), h('button', { class: 'ss-tree__action ss-snippet__delete', type: 'button', 'data-tip': 'Delete', 'aria-label': 'Delete the snippet ' + item.name, 'data-focus-key': focusKey + ':delete', onclick: function () { Snippets.confirmDelete(item); } }, icon('delete')) ]); }; return h('div', { class: 'ss-tree__row ss-tree__row--table ss-snippet', 'data-tip': snippetHoverText(item) }, [toggle, actions]); }; UI._insertSnippet = function (item) { var selection = Editor.getSelectionOffsets(); if (!Editor.getValue().trim() || selection.end > selection.start) { Editor.insertSnippet(item.sql); return; }; var modal = this.openModal({ title: 'Insert the snippet "' + item.name + '"?', body: h('p', null, 'This tab has a query already. Replace it with the snippet, or insert the snippet at the cursor on lines of its own. Undo takes either back.'), footer: [ h('button', { class: 'ss-btn', type: 'button', 'data-autofocus': true, onclick: function () { modal.close(); } }, 'Cancel'), h('button', { class: 'ss-btn', type: 'button', onclick: function () { modal.close(); Editor.insertSnippet(item.sql, 'cursor'); } }, 'Insert at the cursor'), h('button', { class: 'ss-btn ss-btn--primary', type: 'button', onclick: function () { modal.close(); Editor.insertSnippet(item.sql, 'replace'); } }, 'Replace the query') ] }); }; UI._appendTableMatches = function (container, matches) { var self = this; matches.slice(0, DE_SECTION_ROW_LIMIT).forEach(function (m) { container.appendChild(self._renderTreeRow(m.table, m.matchedFields)); }); if (matches.length > DE_SECTION_ROW_LIMIT) { container.appendChild(h('div', { class: 'ss-tree__empty' }, (matches.length - DE_SECTION_ROW_LIMIT) + ' more. Keep typing to narrow the list.')); }; }; UI._appendFolderContents = function (parentEl, node, folders) { var self = this; (folders || node.folders).forEach(function (child) { parentEl.appendChild(self._renderDeFolder(child)); }); var shown = this._deFolderShown[node.key] || DE_SECTION_ROW_LIMIT; node.tables.slice(0, shown).forEach(function (t) { parentEl.appendChild(self._renderTreeRow(t, null, { isInFolder: true })); }); if (node.tables.length > shown) { var step = Math.min(DE_SECTION_ROW_LIMIT, node.tables.length - shown); parentEl.appendChild(h('button', { class: 'ss-tree__link ss-tree__more', type: 'button', 'data-focus-key': 'more:' + node.key, onclick: function () { self._deFolderShown[node.key] = shown + step; self._nextSidebarFocusKey = tableFocusKey(node.tables[shown]); self.renderSidebarTree(); } }, 'Show ' + fmtNumber(step) + ' more')); }; }; UI._renderDeFolder = function (node) { var self = this; var isOpen = Object.prototype.hasOwnProperty.call(this._deFolderOpen, node.key) ? !!this._deFolderOpen[node.key] : !!node.isStartOpen; var countText = node.count ? fmtNumber(node.count) + (node.count === 1 ? ' Data Extension' : ' Data Extensions') : 'empty'; var toggle = function () { self._deFolderOpen[node.key] = !isOpen; self.renderSidebarTree(); }; var caret = h('button', { class: 'ss-tree__caret', type: 'button', 'aria-label': (isOpen ? 'Collapse ' : 'Expand ') + node.name + ' folder, ' + countText, 'aria-expanded': String(isOpen), 'data-focus-key': 'folder:' + node.key, onclick: function (ev) { ev.stopPropagation(); toggle(); } }, isOpen ? '▾' : '▸'); var row = h('div', { class: 'ss-tree__row ss-tree__folder ss-tree__folder--de' + (node.isMadeUp ? ' ss-tree__folder--made-up' : ''), 'data-tip': node.isMadeUp ? node.name + ': the Data Extensions whose folder is not in the folder list' : node.name, onclick: toggle }, [ caret, h('span', { class: 'ss-tree__name' }, node.name), node.count ? h('span', { class: 'ss-tree__count', 'data-tip': countText }, fmtNumber(node.count)) : null ]); var wrap = h('div', null, row); if (isOpen) { var children = h('div', { class: 'ss-tree__children' }, null); self._appendFolderContents(children, node); wrap.appendChild(children); }; return wrap; }; function tableFocusKey(t) { return 'table:' + t.kind + ':' + Schema.tableKey(t); }; function pressOnKey(action) { return function (ev) { if (ev.target !== ev.currentTarget || (!isPlainEnter(ev) && !isPlainSpace(ev))) return; ev.preventDefault(); action(); }; }; UI._renderTreeRow = function (t, matchedFields, options) { var self = this; var isMatched = !!matchedFields; var isExpanded = this._isTableOpen(t, isMatched); var focusKey = tableFocusKey(t); var wholeName = (t.isShared ? 'ENT.' : '') + t.name; var caret = h('span', { class: 'ss-tree__caret', 'aria-hidden': 'true' }, isExpanded ? '▾' : '▸'); var isInFolder = !!(options && options.isInFolder); var nameEl = h('span', { class: 'ss-tree__name' }, (t.isShared && !isInFolder) ? [h('span', { class: 'ss-tree__ent-prefix' }, 'ENT.'), t.name] : t.name); var toggleFields = function () { self._toggleTableFields(t, isMatched); }; var toggle = h('div', { class: 'ss-tree__toggle', tabindex: '0', role: 'button', 'aria-expanded': String(isExpanded), 'aria-label': 'Fields of ' + wholeName, 'data-focus-key': focusKey, onclick: toggleFields, onkeydown: pressOnKey(toggleFields) }, [caret, nameEl, t.isShared ? sharedKindBadge(t, isInFolder, true) : null]); var insertNameBtn = h('button', { class: 'ss-tree__action ss-tree__insert-name', type: 'button', 'data-tip': 'Insert name', 'aria-label': 'Insert name', 'data-focus-key': focusKey + ':insert-name', onclick: function () { self._insertTable(t); } }, icon('add')); var joinBtn = h('button', { class: 'ss-tree__action ss-tree__join', type: 'button', 'data-tip': 'Join to the query', 'aria-label': 'Join to the query', 'data-focus-key': focusKey + ':join', onclick: function () { self._joinToQuery(t); } }, icon('left_join')); this._joinRows.push({ button: joinBtn, table: t }); if (!this._joinPlan) this._joinPlan = this._planJoin(); this._showJoinState(joinBtn, t, this._joinPlan); var insertFullBtn = h('button', { class: 'ss-tree__action ss-tree__insert-full', type: 'button', 'data-tip': 'Insert table query (SELECT with all fields)', 'aria-label': 'Insert table query (SELECT with all fields)', 'data-focus-key': focusKey + ':insert-full', onclick: function () { self._insertFullQuery(t); } }, icon('table')); var row = h('div', { class: 'ss-tree__row ss-tree__row--table', 'data-tip': tableTitle(t, wholeName) }, [ toggle, h('span', { class: 'ss-tree__actions' }, [insertNameBtn, joinBtn, insertFullBtn]) ]); var wrap = h('div', null, row); if (t.isShared && t.folderPath && !isInFolder) wrap.appendChild(h('div', { class: 'ss-tree__path', 'data-tip': t.folderPath }, t.folderPath)); if (isExpanded) wrap.appendChild(isMatched ? self._buildFieldRows(t, matchedFields) : self._renderFieldsList(t)); return wrap; }; UI._insertTable = function (t) { var text = t.isShared ? ('ENT.' + Tools.formatIdentifier(t.name)) : (t.kind === 'dataview' ? Tools.dataViewTable(t.name, SQLData, isParentBusinessUnit() === false) : Tools.formatIdentifier(t.name)); Editor.insertTextAtCursor(text); Editor.prefetchFields(t); }; UI._planJoin = function () { try { return Tools.joinToQuery(Editor.getValue(), Editor.getCursorOffset(), Schema.getIndex(), SQLData, { isChildBusinessUnit: isParentBusinessUnit() === false }); } catch (e) { return { hasTable: false, isUnfinished: false, readTables: [], readViews: [], pendingTables: [], items: [] }; }; }; function joinState(isOn, title, item) { return { isOn: isOn, title: title, item: item }; }; UI._joinStateOf = function (t, plan) { if (!plan.hasTable) return joinState(false, 'Join to the query: the query has no table yet', null); if (plan.isUnfinished) return joinState(false, 'Join to the query: finish the join the query ends with first', null); var isRead = t.kind === 'dataview' ? plan.readViews.indexOf(t.name) !== -1 : plan.readTables.indexOf(t) !== -1; if (isRead) return joinState(false, 'Join to the query: it is in the query already', null); if (!plan.joinByTable) { plan.joinByTable = {}; plan.items.forEach(function (join) { plan.joinByTable[join.dataExtension ? 'de:' + Schema.tableKey(join.dataExtension) : 'view:' + join.view] = join; }); }; var item = plan.joinByTable[t.kind === 'dataview' ? 'view:' + t.name : 'de:' + Schema.tableKey(t)]; if (item) return joinState(true, 'Join to the query', item); var isUnknown = plan.pendingTables.length > 0 || (t.kind !== 'dataview' && (!t.fields || Schema.isDetailsPending(t))); if (isUnknown) return joinState(true, 'Join to the query', null); return joinState(false, 'Join to the query: no relationship to the query\'s tables is known', null); }; UI._showJoinState = function (button, t, plan) { var state = this._joinStateOf(t, plan); button.disabled = !state.isOn; button.setAttribute('data-tip', state.title); }; UI._refreshJoinButtons = function () { var self = this; if (!this._joinRows.length) { this._joinPlan = null; return; }; var plan = this._planJoin(); this._joinPlan = plan; this._joinRows.forEach(function (row) { self._showJoinState(row.button, row.table, plan); }); }; UI._scheduleJoinRefresh = debounce(function () { UI._refreshJoinButtons(); }, JOIN_REFRESH_MS); UI._joinToQuery = function (t) { var self = this; var key = Schema.tableKey(t); if (this._joinBusy[key]) return; var plan = this._planJoin(); var state = this._joinStateOf(t, plan); if (!state.isOn) { Toast.show(state.title.replace(/^Join to the query: /, '').replace(/^./, function (c) { return c.toUpperCase(); }) + '.', 'info'); return; }; if (state.item && !plan.pendingTables.length) { this._writeJoin(t, state.item); return; }; var loads = plan.pendingTables.slice(); if (t.kind !== 'dataview' && (!t.fields || Schema.isDetailsPending(t)) && loads.indexOf(t) === -1) loads.push(t); var awaited = loads.filter(function (table) { return Schema.isDetailsPending(table); }); var tab = QueryTabs.active(); var isTabLeft = function () { if (QueryTabs.active() === tab) return false; Toast.show(t.name + ' was not joined, as another tab is on screen now. Open ' + QueryTabs.labelFor(tab) + ' and join it again.', 'info'); return true; }; this._joinBusy[key] = true; Promise.all(loads.map(function (table) { return Schema.fields(table).then(function () { return Schema.details(table); }); })).then(function () { delete self._joinBusy[key]; self._refreshJoinButtons(); if (isTabLeft()) return; var next = self._joinStateOf(t, self._planJoin()); if (!next.item) { Toast.show('No relationship between ' + t.name + ' and the tables of the query is known, so no join was written.', 'info'); return; }; self._writeJoin(t, next.item); var unread = awaited.filter(function (table) { return table.hasDetails !== true; }).map(function (table) { return table.name; }); if (unread.length) Toast.show('Could not read the send relationship of ' + unread.join(', ') + ', so the join may not be the best one.', 'warning'); }, function (err) { delete self._joinBusy[key]; if (isTabLeft()) return; var known = self._joinStateOf(t, self._planJoin()); if (known.item) { self._writeJoin(t, known.item); Toast.show('Could not load the fields of a table of the query, so the join may not be the best one: ' + err.message, 'warning'); return; }; Toast.show('Could not load the fields to join ' + t.name + ': ' + err.message, 'error'); }); }; UI._writeJoin = function (t, item) { Editor.applyEdits(item.edits, item.cursor); Editor.prefetchFields(t); }; UI._insertFullQuery = function (t) { var self = this; if (t.kind === 'dataview') { var dvAlias = t.alias || computeDeAlias(t.name); var dvTable = Tools.dataViewTable(t.name, SQLData, isParentBusinessUnit() === false); var query = t.fullQuery ? t.fullQuery.replace('\nFROM ' + Tools.formatIdentifier(t.name) + ' ', '\nFROM ' + dvTable + ' ') : Tools.buildFullSelect(dvTable, t.fields, dvAlias); Editor.insertFullQuery(query); return; }; var alias = computeDeAlias(t.name); var tableName = t.isShared ? ('ENT.' + Tools.formatIdentifier(t.name)) : Tools.formatIdentifier(t.name); Schema.fields(t).then(function (fields) { Editor.insertFullQuery(Tools.buildFullSelect(tableName, fields || t.fields || [], alias)); self.renderSidebarTree(); }).catch(function (err) { Toast.show('Could not load fields for ' + t.name + ': ' + err.message, 'error'); }); }; UI._isTableOpen = function (t, isMatched) { var expandKey = Schema.tableKey(t); return isMatched ? !this._searchClosed[expandKey] : !!this._tableExpanded[expandKey]; }; UI._toggleTableFields = function (t, isMatched) { var self = this; var expandKey = Schema.tableKey(t); var isOpening = !this._isTableOpen(t, isMatched); this._tableExpanded[expandKey] = isOpening; if (isMatched) this._searchClosed[expandKey] = !isOpening; if (isOpening && !t.fields) { Schema.fields(t).then(function () { self.renderSidebarTree(); }).catch(function (err) { Toast.show('Could not load fields for ' + t.name + ': ' + err.message, 'error'); }); }; this.renderSidebarTree(); }; UI._buildFieldRows = function (t, fields) { var focusKey = tableFocusKey(t); var wrap = h('div', { class: 'ss-tree__fields' }, null); fields.forEach(function (f) { var insert = function () { Editor.insertTextAtCursor(Tools.formatIdentifier(f.name)); }; var fieldKey = focusKey + ':field:' + f.name; var row = h('div', { class: 'ss-field-row', tabindex: '0', role: 'button', 'aria-label': 'Insert ' + f.name, 'data-tip': f.name, 'data-focus-key': fieldKey, onclick: insert, onkeydown: pressOnKey(insert) }, [ f.isPrimaryKey ? h('span', { class: 'ss-field-row__key', 'data-tip': 'Primary key' }, icon('key')) : null, h('span', { class: 'ss-field-row__name' }, f.name), h('span', { class: 'ss-field-row__type' }, String(f.type || '') + (f.length ? '(' + f.length + ')' : '')) ]); var insertNameBtn = h('button', { class: 'ss-tree__action ss-tree__insert-name', type: 'button', 'data-tip': 'Insert name', 'aria-label': 'Insert name', 'data-focus-key': fieldKey + ':insert-name', onclick: insert }, icon('add')); wrap.appendChild(h('div', { class: 'ss-field-item' }, [row, h('span', { class: 'ss-tree__actions' }, [insertNameBtn])])); }); return wrap; }; UI._renderFieldsList = function (t) { if (!t.fields) return h('div', { class: 'ss-tree__fields' }, h('div', { class: 'ss-tree__loading' }, 'Loading fields...')); if (!t.fields.length) return h('div', { class: 'ss-tree__fields' }, h('div', { class: 'ss-tree__empty' }, 'No fields.')); return this._buildFieldRows(t, t.fields); }; UI._buildToolbar = function () { var self = this; var header = this._buildHeader(); var runBtn = toolbarButton('play', 'Run', function () { Runner.run(); }, { primary: true, title: 'Run (' + shortcutLabel('Enter') + ')', keys: shortcutKeys('Enter') }); var validateBtn = toolbarButton('check', 'Validate', function () { self._validateOnly(); }, { secondary: true }); var formatBtn = toolbarButton('magicwand', 'Format', function () { Editor.format(); }, { secondary: true, title: 'Format (' + shortcutLabel('F', true) + ')', keys: shortcutKeys('F', true) }); var saveAsBtn = toolbarButton('save', 'Save as', function () { Dialogs.openSaveAs(); }, { secondary: true, title: 'Save as (' + shortcutLabel('S') + ' while no query is open)' }); var openBtn = toolbarButton('open_folder', 'Open', function (ev) { UI.toggleDropdown(ev.currentTarget, 'open'); }, { secondary: true }); var historyBtn = toolbarButton('clock', 'History', function (ev) { UI.toggleDropdown(ev.currentTarget, 'history'); }, { secondary: true }); var saveBtn = toolbarButton('save', 'Save', function () { Dialogs.saveCurrent(); }, { secondary: true, title: 'Save (' + shortcutLabel('S') + ')', keys: shortcutKeys('S') }); var disconnectBtn = toolbarButton('clear', 'Disconnect', function () { self._disconnectOpenedQuery(); }, { secondary: true }); var openedQueryGroup = h('span', { class: 'ss-toolbar__group', hidden: true }, [toolbarSeparator(), saveBtn, disconnectBtn]); var documentMenu = self._buildDocumentMenu(); var toolbar = h('div', { class: 'ss-toolbar', role: 'toolbar', 'aria-label': 'Query actions' }, [ header.brand, toolbarSeparator(), formatBtn, validateBtn, runBtn, historyBtn, toolbarSeparator(), openBtn, saveAsBtn, openedQueryGroup, toolbarSeparator(), documentMenu, h('div', { class: 'ss-toolbar__spacer' }, null), header.actions ]); this.el.toolbar = toolbar; this.el.runBtn = runBtn; this.el.validateBtn = validateBtn; this.el.formatBtn = formatBtn; this.el.historyBtn = historyBtn; this.el.openedQueryGroup = openedQueryGroup; this.el.saveBtn = saveBtn; this.el.disconnectBtn = disconnectBtn; return toolbar; }; UI._validateOnly = function () { var self = this; var tab = QueryTabs.active(); var target = Editor.getRunTarget(); var sql = target.sql; if (!sql.trim()) { Toast.show('Nothing to validate.', 'warning'); return; }; var queryCount = Tools.splitStatements(sql).length; var validateScope = Tools.runScope(sql, Editor.getValue(), target.offset); var validateAbout = errorAbout(sql, validateScope !== false, (validateScope && typeof validateScope === 'object') ? validateScope : null, false); if (queryCount > 1) { var severalMessage = severalQueriesMessage(queryCount); Editor.clearServerMarkers(); UI.clearStatusNotices(); UI.showStatusError('Validate: ' + severalMessage, null, false, null, validateAbout); UI.setActiveTab('status'); UI.revealStatusError(); Toast.show(severalMessage, 'warning'); return; }; var attempt = tab.runner.attempt; var validatedText = Editor.getValue(); self._setValidateBusy(true); Api.call('validateQuery', { sql: sql }).then(function (res) { self._setValidateBusy(false); if (tab.runner.attempt !== attempt) return; if (tab !== QueryTabs.active()) return; Editor.clearServerMarkers(); UI.clearStatusNotices(); if (res.valid) { Toast.show('Marketing Cloud Engagement accepted this SQL.', 'success'); UI.clearStatusError(); state.validateAcceptedText = validatedText; UI.addStatusNotice(VALIDATE_ACCEPTED_NOTICE); UI.syncValidateNotice(); UI.setActiveTab('status'); if (target.offset === 0 && sql === Editor.getValue()) Editor.markServerAccepted(sql); } else { tab.runner._markServerErrors(sql, res.errors || [], target.offset); UI.showStatusError('Validate: ' + (res.errors || []).join('\n'), null, false, serverErrorHint(sql, res.errors || []), validateAbout); UI.setActiveTab('status'); UI.revealStatusError(); Toast.show((res.errors || [])[0] || 'Validation failed.', 'error'); }; }).catch(function (err) { self._setValidateBusy(false); if (tab.runner.attempt !== attempt) return; Toast.show('Could not validate: ' + err.message, 'error'); if (tab !== QueryTabs.active()) return; UI.showStatusError('Validate: ' + err.message, null, false, err && err.isConnection ? CONNECTION_HINT : null, validateAbout); UI.setActiveTab('status'); UI.revealStatusError(); }); }; UI._setValidateBusy = function (isBusy) { var btn = this.el.validateBtn; if (!btn) return; btn.disabled = isBusy; btn.classList.toggle('is-running', isBusy); btn.setAttribute('aria-busy', isBusy ? 'true' : 'false'); var label = btn.querySelector('span'); if (label) label.textContent = isBusy ? 'Validating' : 'Validate'; }; UI.renderToolbarState = function (s) { var runBtn = this.el.runBtn; var isBusy = isRunInFlight(s); runBtn.disabled = isBusy; runBtn.classList.toggle('is-running', isBusy); runBtn.setAttribute('aria-busy', isBusy ? 'true' : 'false'); var runLabel = runBtn.querySelector('span'); if (runLabel) runLabel.textContent = isBusy ? 'Running' : 'Run'; this._syncExportButton(); }; UI.renderOpenedQueryBadge = function () { if (QueryTabs._tabs.length) QueryTabs.render(); var group = this.el.openedQueryGroup; if (!group) return; var oq = state.openedQuery; group.hidden = !oq; if (!oq) return; var isDirty = Editor.isDirty(); this.el.saveBtn.disabled = !isDirty; this.el.saveBtn.title = isDirty ? 'Save your changes to "' + oq.name + '". SQL Studio asks first. (' + shortcutLabel('S') + ')' : 'No unsaved changes to "' + oq.name + '"'; this.el.disconnectBtn.title = 'Disconnect this tab from "' + oq.name + '". Its SQL stays here, and the Query Activity keeps its saved SQL.'; }; UI._disconnectOpenedQuery = function () { var self = this; var tab = QueryTabs.active(); var oq = tab.state.openedQuery; if (!oq) return; function doDisconnect() { if (tab.state.openedQuery !== oq) return; tab.state.openedQuery = null; QueryTabs.markChanged(tab); self.renderOpenedQueryBadge(); Toast.show('Disconnected from "' + oq.name + '". Its SQL stays in this tab.', 'success'); }; if (tab.state.dirty) { Dialogs.confirm('Disconnect from "' + oq.name + '"?', 'Your edits stay in this tab and are not saved to the Query Activity, which keeps its saved SQL.', doDisconnect); } else { doDisconnect(); }; }; UI._buildStatusPanel = function () { var list = h('div', { class: 'ss-problems__list' }, null); var errorPanel = h('div', { class: 'ss-error-panel', role: 'alert', style: { display: 'none' } }, null); var notices = h('div', { class: 'ss-notices' }, null); var empty = h('div', { class: 'ss-empty-state' }, 'No problems. Ctrl/Cmd+Enter runs the query.'); var runNote = h('div', { class: 'ss-status-run-note', 'aria-live': 'polite', style: { display: 'none' } }, null); var panel = h('div', { class: 'ss-status-panel', id: 'ss-panel-status', role: 'tabpanel', 'aria-labelledby': 'ss-tab-status' }, [runNote, errorPanel, list, notices, empty]); this.el.statusRunNote = runNote; this.el.statusPanel = panel; this.el.problemsList = list; this.el.statusErrorPanel = errorPanel; this.el.statusNotices = notices; this.el.statusEmpty = empty; return panel; }; var PROBLEM_ACTIONS = { reloadDataExtensionList: { busyText: 'Reloading...', isBusy: function () { return Schema.isDeListLoading(); }, run: function () { Schema.loadDataExtensionList(true).then(function () { Toast.show('Reloaded the Data Extension list.', 'success'); }, function () { /* the sidebar shows the failure, with its Retry link */ }); } } }; function runProblemAction(id) { var action = PROBLEM_ACTIONS[id]; if (!action || action.isBusy()) return; action.run(); UI.syncProblemActions(); }; UI.syncProblemActions = function () { if (!this.el || !this.el.problemsList) return; Array.prototype.forEach.call(this.el.problemsList.querySelectorAll('[data-problem-action]'), function (button) { var action = PROBLEM_ACTIONS[button.getAttribute('data-problem-action')]; var isBusy = !!action && action.isBusy(); button.disabled = isBusy; if (isBusy) button.setAttribute('aria-busy', 'true'); else button.removeAttribute('aria-busy'); button.textContent = isBusy ? action.busyText : button.getAttribute('data-problem-title'); }); }; UI.renderProblems = function (findings) { var self = this; findings = findings || []; this._problems = findings; this.syncDocumentAvailability(); if (this._lintGate === 'save') { var saveBlocking = saveBlockingFindings(findings).length; if (saveBlocking === 0) { this.clearStatusError(); } else { this.showStatusError(saveGateMessage(saveBlocking), null, 'save'); }; } else if (this._lintGate === 'run' && !isRunInFlight(state.runnerState)) { var gateFindings = findings; var gateTarget = null; try { gateTarget = Editor.getRunTarget(); gateFindings = Editor.withServerVerdict(gateTarget.sql, Tools.lint(gateTarget.sql, Editor.lintContext(true))); } catch (e) { gateFindings = findings; gateTarget = null; }; var blocking = runBlockingFindings(gateFindings, gateTarget ? gateTarget.sql : undefined).length; if (blocking === 0) { this.clearStatusError(); } else { this.showStatusError(lintGateMessage(blocking), null, 'run'); if (gateTarget) { findings = Editor.toDocumentFindings(gateFindings, gateTarget.offset); this._problems = findings; }; }; }; clearNode(this.el.problemsList); var shown = findings.filter(function (f) { return isShownAtStatusLevel(f.severity); }); shown.forEach(function (f) { var jump = function () { Editor.jumpTo(f.start); }; var rowChildren = [ h('span', { class: 'ss-badge ss-badge--' + f.severity }, f.severity), h('span', { class: 'ss-problem__loc' }, 'Ln ' + f.line + ', Col ' + f.col), h('span', { class: 'ss-problem__id' }, f.id), h('span', { class: 'ss-problem__message' }, f.message) ]; var fix = f.fixes && f.fixes[0]; if (fix) { rowChildren.push(h('button', { class: 'ss-btn ss-btn--sm ss-problem__fix' + (fix.isSafe ? '' : ' ss-problem__fix--changes-results'), type: 'button', title: fix.isSafe ? fix.title : (fix.title + ' (changes results)'), onclick: function (ev) { ev.stopPropagation(); if (!Editor.isLintCurrent()) { Editor.lintNow(); return; }; Editor.applyFixEdits(fix.edits); Toast.show('Applied 1 fix: ' + fix.title + '.', 'success'); } }, fix.title)); } else if (f.action && PROBLEM_ACTIONS[f.action.id]) { var action = PROBLEM_ACTIONS[f.action.id]; var isBusy = action.isBusy(); rowChildren.push(h('button', { class: 'ss-btn ss-btn--sm ss-problem__fix', type: 'button', title: f.action.title, disabled: isBusy, 'aria-busy': isBusy ? 'true' : null, 'data-problem-action': f.action.id, 'data-problem-title': f.action.title, onclick: function (ev) { ev.stopPropagation(); runProblemAction(f.action.id); } }, isBusy ? action.busyText : f.action.title)); }; self.el.problemsList.appendChild(h('div', { class: 'ss-problem', tabindex: '0', role: 'button', 'aria-label': f.message, onclick: jump, onkeydown: function (ev) { if (isPlainEnter(ev)) jump(); } }, rowChildren)); }); var hiddenCount = findings.length - shown.length; if (hiddenCount > 0) { this.el.problemsList.appendChild(h('div', { class: 'ss-problem-hidden-note' }, hiddenCount + (hiddenCount === 1 ? ' finding is' : ' findings are') + ' hidden by the Show filter.')); }; this._renderTabCounts(shown); this._updateFixAllButton(findings); this._refreshStatusEmptyState(); }; UI._updateFixAllButton = function (findings) { var reviewCount = 0; this._fixAllCount = (findings || []).filter(function (f) { var fix = f.fixes && f.fixes[0]; if (fix && !fix.isSafe) reviewCount += 1; return fix && fix.isSafe && fix.isInFixAll !== false; }).length; this._fixReviewCount = reviewCount; clearNode(this.el.fixAllBtn); this.el.fixAllBtn.appendChild(document.createTextNode('Fix all (' + this._fixAllCount + ')')); var safeText = this._fixAllCount === 1 ? '1 safe fix.' : this._fixAllCount + ' safe fixes.'; var reviewText = reviewCount + ' more ' + (reviewCount === 1 ? 'needs' : 'need') + ' a look'; this.el.fixAllBtn.title = reviewCount ? safeText + ' ' + reviewText + ': the fixes marked with a dot guess what you meant.' : safeText + ' Fix all applies them at once.'; clearNode(this.el.fixReviewNote); this.el.fixReviewNote.appendChild(document.createTextNode(reviewText)); this.el.fixReviewNote.title = 'The fixes marked with a dot guess what you meant, so Fix all leaves them. Apply each from its own button.'; this._syncFixAllVisibility(); }; UI._syncFixAllVisibility = function () { var show = this.activeTab === 'status' && this._fixAllCount > 0; this.el.fixAllBtn.style.display = show ? '' : 'none'; if (this.el.fixReviewNote) this.el.fixReviewNote.style.display = show && this._fixReviewCount > 0 ? '' : 'none'; }; UI._renderTabCounts = function (findings) { var counts = { error: 0, warning: 0, info: 0 }; (findings || []).forEach(function (f) { counts[f.severity] = (counts[f.severity] || 0) + 1; }); clearNode(this.el.tabStatusCounts); ['error', 'warning', 'info'].forEach(function (sev) { if (!counts[sev]) return; this.el.tabStatusCounts.appendChild(h('span', { class: 'ss-tab__count ss-tab__count--' + sev }, String(counts[sev]))); }, this); }; UI._refreshStatusEmptyState = function () { var hasProblems = this.el.problemsList.children.length > 0; var hasMessages = this.el.statusErrorPanel.style.display !== 'none' || this.el.statusNotices.children.length > 0; var isRunning = isRunInFlight(state.runnerState); this.el.statusEmpty.style.display = (hasProblems || hasMessages || isRunning) ? 'none' : ''; var note = this.el.statusRunNote; if (!note) return; note.style.display = isRunning ? '' : 'none'; if (isRunning && note.getAttribute('data-phase') !== state.runnerState) { note.setAttribute('data-phase', state.runnerState); note.textContent = runResultsWhen(state.runnerState, 'in the Results tab'); }; if (!isRunning) note.removeAttribute('data-phase'); }; UI.showStatusError = function (message, details, gate, hint, about) { var panel = this.el.statusErrorPanel; clearNode(panel); gate = (gate === 'save') ? 'save' : (gate ? 'run' : null); this._lintGate = gate; state.statusError = { message: message, details: details || null, gate: gate, hint: hint || null, about: about || null }; var messageEl = h('div', { class: 'ss-error-panel__message' }, message); if (gate) { messageEl.appendChild(document.createTextNode(' ')); messageEl.appendChild(h('button', { class: 'ss-link-button', type: 'button', onclick: function () { if (gate === 'save') Dialogs.saveCurrent({ skipLintGate: true }); else Runner.run({ skipLintGate: true }); } }, gate === 'save' ? 'Save anyway' : 'Run anyway')); }; panel.appendChild(messageEl); if (hint) panel.appendChild(h('div', { class: 'ss-error-panel__hint' }, hint)); if (details) { var det = document.createElement('details'); var summary = document.createElement('summary'); summary.textContent = 'Details'; var pre = document.createElement('pre'); pre.textContent = typeof details === 'string' ? details : JSON.stringify(details, null, 2); det.appendChild(summary); det.appendChild(pre); panel.appendChild(det); }; panel.style.display = ''; this._refreshStatusEmptyState(); this.syncStaleMark(); }; UI.revealStatusError = function () { if (this.el.statusPanel) this.el.statusPanel.scrollTop = 0; }; UI.clearStatusError = function () { var panel = this.el.statusErrorPanel; this._lintGate = null; state.statusError = null; if (panel.style.display === 'none') return; clearNode(panel); panel.style.display = 'none'; panel.classList.remove('is-stale'); this._refreshStatusEmptyState(); this.syncStaleMark(); }; UI.addStatusNotice = function (text) { if (state.statusNotices.indexOf(text) !== -1) return; state.statusNotices.push(text); this.el.statusNotices.appendChild(h('div', { class: 'ss-notice' }, text)); this._refreshStatusEmptyState(); }; UI.addTabStatusNotice = function (tab, text) { if (tab === QueryTabs.active()) { this.addStatusNotice(text); } else if (tab.state.statusNotices.indexOf(text) === -1) { tab.state.statusNotices.push(text); }; }; UI.clearStatusNotices = function () { state.statusNotices = []; clearNode(this.el.statusNotices); this._refreshStatusEmptyState(); }; UI.removeStatusNotice = function (text) { var at = state.statusNotices.indexOf(text); if (at === -1 || !this.el.statusNotices) return; state.statusNotices.splice(at, 1); Array.prototype.slice.call(this.el.statusNotices.children).forEach(function (line) { if (line.textContent === text) line.parentNode.removeChild(line); }); this._refreshStatusEmptyState(); }; UI.showFormatDone = function () { var self = this; var btn = this.el.formatBtn; var label = btn ? btn.querySelector('span') : null; if (label) { label.textContent = 'Formatted'; if (this._formatDoneTimer) global.clearTimeout(this._formatDoneTimer); this._formatDoneTimer = global.setTimeout(function () { self._formatDoneTimer = null; label.textContent = 'Format'; }, FORMAT_DONE_MS); }; this.removeStatusNotice(FORMAT_DONE_NOTICE); state.formatDoneText = Editor.getValue(); this.addStatusNotice(FORMAT_DONE_NOTICE); }; UI._buildTabs = function () { var self = this; var statusCounts = h('span', { class: 'ss-tab__counts' }, null); var tabStatus = h('button', { class: 'ss-tab', type: 'button', role: 'tab', id: 'ss-tab-status', 'aria-selected': 'true', 'aria-controls': 'ss-panel-status', tabindex: '0', onclick: function () { self.setActiveTab('status'); } }, [h('span', null, 'Status'), statusCounts]); var resultsLabel = h('span', null, 'Results'); var tabResults = h('button', { class: 'ss-tab', type: 'button', role: 'tab', id: 'ss-tab-results', 'aria-selected': 'false', 'aria-controls': 'ss-panel-results', tabindex: '-1', onclick: function () { self.setActiveTab('results'); } }, [resultsLabel]); var order = ['status', 'results']; var tabList = h('div', { class: 'ss-tabs', role: 'tablist', 'aria-label': 'Status and results', onkeydown: function (ev) { if (ev.key !== 'ArrowLeft' && ev.key !== 'ArrowRight') return; ev.preventDefault(); var idx = order.indexOf(self.activeTab); var next = order[(idx + (ev.key === 'ArrowRight' ? 1 : order.length - 1)) % order.length]; self.setActiveTab(next); (next === 'status' ? tabStatus : tabResults).focus(); } }, [tabStatus, tabResults]); this.el.tabStatus = tabStatus; this.el.tabResults = tabResults; this.el.tabStatusCounts = statusCounts; this.el.tabResultsLabel = resultsLabel; return tabList; }; UI.setActiveTab = function (tab) { if (tab !== 'status' && tab !== 'results') return; this.activeTab = tab; state.bottomPanel = tab; var isStatus = tab === 'status'; this.el.tabStatus.setAttribute('aria-selected', String(isStatus)); this.el.tabStatus.tabIndex = isStatus ? 0 : -1; this.el.tabResults.setAttribute('aria-selected', String(!isStatus)); this.el.tabResults.tabIndex = isStatus ? -1 : 0; this.el.statusPanel.style.display = isStatus ? '' : 'none'; this.el.resultsPanel.style.display = isStatus ? 'none' : ''; this.el.clientFilter.style.display = isStatus ? 'none' : ''; this.el.exportBtn.style.display = isStatus ? 'none' : ''; this._syncExportCost(); if (this.el.levelSelect) this.el.levelSelect.style.display = isStatus ? '' : 'none'; this._syncFixAllVisibility(); }; UI._syncExportCost = function () { var label = this.el.exportCost; var button = this.el.exportBtn; if (!label || !button) return; var isShown = this.activeTab === 'results' && Grid.hasData() && !isRunInFlight(state.runnerState); if (!isShown) { label.style.display = 'none'; clearNode(label); button.title = 'Download every row as CSV'; button.removeAttribute('aria-describedby'); this._syncExportButton(); return; }; var plan = Grid.exportPlan(); var cost = exportCostText(plan.apiCalls, plan.wsproxyCalls); var text = cost.charAt(0).toLowerCase() + cost.slice(1); clearNode(label); label.appendChild(h('span', { class: 'ss-export-cost__full' }, cost)); label.appendChild(h('span', { class: 'ss-export-cost__short', 'aria-hidden': 'true' }, exportCostShortText(plan.apiCalls, plan.wsproxyCalls))); label.style.display = ''; button.title = 'Download every row as CSV. It takes ' + text + '.'; button.setAttribute('aria-describedby', 'ss-export-cost'); this._syncExportButton(); }; UI._syncExportButton = function () { var button = this.el.exportBtn; var tab = QueryTabs.active(); if (!button || !tab) return; var progress = tab.activeExport; var isExporting = !!progress; var isSaving = !!tab.activeSave; var isUnread = !!unreadPageOf(tab.state); button.disabled = isExporting || isSaving || isRunInFlight(state.runnerState) || isUnread; button.classList.toggle('is-exporting', isExporting); if (isExporting) { button.setAttribute('aria-busy', 'true'); button.title = 'Exporting every row as CSV. The button is back when it ends.'; if (!this.el.exportSpinner) { this.el.exportSpinner = spinner(); button.insertBefore(this.el.exportSpinner, this.el.exportLabel); }; this.el.exportText.textContent = exportProgressText(progress.rowsRead, progress.totalRows); this.el.exportSizer.textContent = exportProgressText(progress.totalRows, progress.totalRows); } else { button.removeAttribute('aria-busy'); if (this.el.exportSpinner) { button.removeChild(this.el.exportSpinner); this.el.exportSpinner = null; }; this.el.exportText.textContent = EXPORT_BUTTON_LABEL; this.el.exportSizer.textContent = ''; if (isUnread) button.title = 'The rows did not load, so there is nothing to export yet. Read the results again first.'; }; if (!isExporting && isSaving) button.title = 'Export is on hold while this tab saves its results to a Data Extension.'; var runBtn = this.el.runBtn; if (runBtn) { if (isExporting || isSaving) { if (this._runTitle === undefined) this._runTitle = runBtn.title; runBtn.disabled = true; runBtn.title = isExporting ? 'Run is on hold while this tab exports its results.' : 'Run is on hold while this tab saves its results to a Data Extension.'; } else if (this._runTitle !== undefined) { runBtn.disabled = isRunInFlight(state.runnerState); runBtn.title = this._runTitle; this._runTitle = undefined; }; }; this._syncSaveButton(); }; UI._syncSaveButton = function () { var button = this.el.saveDeBtn; var tab = QueryTabs.active(); if (!button || !tab) return; var isSaving = !!tab.activeSave; var isShown = this.activeTab === 'results' && (Grid.hasData() || isRunInFlight(state.runnerState) || isSaving); button.style.display = isShown ? '' : 'none'; var reason = SaveToDe.blockReason(tab); button.disabled = !!reason; button.title = reason || SAVE_BUTTON_TITLE; button.classList.toggle('is-exporting', isSaving); if (isSaving) { button.setAttribute('aria-busy', 'true'); if (!this.el.saveDeSpinner) { this.el.saveDeSpinner = spinner(); button.insertBefore(this.el.saveDeSpinner, this.el.saveDeLabel); }; this.el.saveDeText.textContent = SAVE_BUTTON_BUSY_LABEL; } else { button.removeAttribute('aria-busy'); if (this.el.saveDeSpinner) { button.removeChild(this.el.saveDeSpinner); this.el.saveDeSpinner = null; }; this.el.saveDeText.textContent = SAVE_BUTTON_LABEL; }; }; UI.openExportProgress = function (tab, totalRows) { var progress = { rowsRead: 0, totalRows: typeof totalRows === 'number' ? totalRows : null, announcedQuarter: 0, hadFocus: document.activeElement === this.el.exportBtn }; tab.activeExport = progress; if (tab === QueryTabs.active()) { this._syncExportButton(); this.el.exportStatus.textContent = progress.totalRows === null ? 'Exporting rows.' : 'Exporting ' + fmtRows(progress.totalRows) + '.'; }; var handle = { update: function (rowsRead, totalRows) { if (tab.activeExport !== progress) return; progress.rowsRead = rowsRead; progress.totalRows = typeof totalRows === 'number' ? totalRows : null; if (tab !== QueryTabs.active()) return; UI._syncExportButton(); if (progress.totalRows > 0) { var quarter = Math.floor(Math.min(rowsRead, progress.totalRows) / progress.totalRows * 4); if (quarter > progress.announcedQuarter && quarter < 4) { progress.announcedQuarter = quarter; UI.el.exportStatus.textContent = fmtNumber(rowsRead) + ' of ' + fmtNumber(progress.totalRows) + ' rows read.'; }; }; }, close: function () { if (tab.activeExport !== progress) return; tab.activeExport = null; if (tab !== QueryTabs.active()) return; UI._syncExportCost(); UI.el.exportStatus.textContent = ''; var focused = document.activeElement; if (progress.hadFocus && (!focused || focused === document.body)) UI.el.exportBtn.focus(); } }; return handle; }; UI._buildBottomPanel = function () { var tabs = this._buildTabs(); var runSummary = h('div', { class: 'ss-run-summary' }, 'Run a query to see results here.'); var fixAllBtn = h('button', { class: 'ss-btn ss-btn--sm', type: 'button', title: 'Apply every safe fix at once', onclick: function () { Editor.fixAll(); } }, 'Fix all (0)'); var fixReviewNote = h('span', { class: 'ss-fix-review-note', style: { display: 'none' } }, null); var levelSelect = h('select', { class: 'ss-select ss-select--sm', 'aria-label': 'Show findings', title: 'Which findings the Status tab and the editor show', onchange: function (ev) { Settings.set('statusLevel', ev.target.value); UI.renderProblems(UI._problems); Editor._applyMarkers(Editor._lastFindings || []); } }, [ h('option', { value: 'info' }, 'Show: all'), h('option', { value: 'warning' }, 'Show: errors and warnings'), h('option', { value: 'error' }, 'Show: errors only') ]); levelSelect.value = Settings.data.statusLevel || 'info'; var clientFilter = h('input', { class: 'ss-input ss-results-filter', type: 'search', placeholder: 'Filter rows below', 'aria-label': 'Filter the rows on this page of results', title: 'Filter the rows on this page of results', style: { width: '180px' }, oninput: debounce(function (ev) { Grid.setClientFilter(ev.target.value); }, 150) }, null); var exportCost = h('span', { class: 'ss-export-cost', id: 'ss-export-cost', style: { display: 'none' } }, null); var exportText = h('span', { class: 'ss-export-btn__text' }, EXPORT_BUTTON_LABEL); var exportSizer = h('span', { class: 'ss-export-btn__sizer', 'aria-hidden': 'true' }, null); var exportLabel = h('span', { class: 'ss-export-btn__label' }, [exportText, exportSizer]); var exportBtn = h('button', { class: 'ss-btn ss-btn--sm ss-export-btn', type: 'button', title: 'Download every row as CSV', onclick: function () { Grid.exportCsv(); } }, [icon('download'), exportLabel]); var saveDeText = h('span', { class: 'ss-export-btn__text' }, SAVE_BUTTON_LABEL); var saveDeSizer = h('span', { class: 'ss-export-btn__sizer', 'aria-hidden': 'true' }, SAVE_BUTTON_LABEL); var saveDeLabel = h('span', { class: 'ss-export-btn__label ss-save-btn__label' }, [saveDeText, saveDeSizer]); var saveDeBtn = h('button', { class: 'ss-btn ss-btn--sm ss-export-btn ss-save-btn', type: 'button', title: SAVE_BUTTON_TITLE, onclick: function () { SaveToDe.open(); } }, [icon('database'), saveDeLabel]); var exportStatus = h('span', { class: 'ss-export-status', 'aria-live': 'polite', 'aria-atomic': 'true' }, null); var staleMark = this._buildStaleMark(); var header = h('div', { class: 'ss-results-header' }, [ tabs, staleMark, runSummary, h('div', { class: 'ss-toolbar__spacer' }, null), levelSelect, fixAllBtn, fixReviewNote, clientFilter, exportCost, exportBtn, saveDeBtn, exportStatus ]); var statusPanel = this._buildStatusPanel(); var gridScroll = h('div', { class: 'ss-grid-scroll' }, h('div', { class: 'ss-empty-state' }, 'Run a query to see results here.')); var pager = this._buildPager(); var resultsPanel = h('div', { class: 'ss-results-panel', id: 'ss-panel-results', role: 'tabpanel', 'aria-labelledby': 'ss-tab-results' }, [gridScroll, pager]); var pane = h('div', { class: 'ss-results-pane' }, [header, statusPanel, resultsPanel]); this.el.runSummary = runSummary; this.el.staleMark = staleMark; this.el.fixAllBtn = fixAllBtn; this.el.fixReviewNote = fixReviewNote; this.el.levelSelect = levelSelect; this.el.clientFilter = clientFilter; this.el.exportCost = exportCost; this.el.exportBtn = exportBtn; this.el.exportLabel = exportLabel; this.el.exportText = exportText; this.el.exportSizer = exportSizer; this.el.exportStatus = exportStatus; this.el.saveDeBtn = saveDeBtn; this.el.saveDeLabel = saveDeLabel; this.el.saveDeText = saveDeText; this.el.gridScroll = gridScroll; this.el.pager = pager; this.el.resultsPanel = resultsPanel; this._fixAllCount = 0; this._fixReviewCount = 0; this.setActiveTab('status'); return pane; }; UI.renderResults = function () { this._syncExportCost(); if (this._gridScrollHandler) { this.el.gridScroll.removeEventListener('scroll', this._gridScrollHandler); this._gridScrollHandler = null; }; clearNode(this.el.gridScroll); if (isRunInFlight(state.runnerState)) { this.el.gridScroll.appendChild(h('div', { class: 'ss-empty-state ss-empty-state--running' }, [ h('p', { class: 'ss-empty-state__title' }, runResultsWhen(state.runnerState, 'here')), state.runnerState === 'fetching' ? null : h('p', null, 'Marketing Cloud Engagement queues every query, so even a small one can take a minute.') ])); this._updatePagingUI(); return; }; var unread = unreadPageOf(state); if (unread) { this.el.gridScroll.appendChild(this._buildUnreadPage(unread)); this._updatePagingUI(); return; }; if (!Grid.hasData()) { this.el.gridScroll.appendChild(h('div', { class: 'ss-empty-state' }, 'Run a query to see results here.')); this._updatePagingUI(); return; }; this.el.gridScroll.appendChild(this._buildGridTable(Grid.data.columns, Grid.visibleRows())); this._updatePagingUI(); }; UI._buildUnreadPage = function (unread) { var run = unread.run; var runner = QueryTabs.active().runner; var costText = knownRowCount(run) !== null ? 'Reading them again takes no API call when SQL Studio can read the page without one, and 1 API call when it cannot.' : 'Reading them again takes 1 API call.'; var button = h('button', { class: 'ss-btn ss-btn--primary ss-unread-page__button', type: 'button', onclick: function () { runner.readResultsAgain(); } }, unread.isReading ? [spinner(), h('span', null, 'Reading the results')] : 'Read the results again'); if (unread.isReading) { button.disabled = true; button.setAttribute('aria-busy', 'true'); }; return h('div', { class: 'ss-empty-state ss-empty-state--unread' }, [ h('p', { class: 'ss-empty-state__title' }, 'The query finished, but its rows did not load.'), h('p', { class: 'ss-unread-page__error' }, unread.message), button, h('p', { class: 'ss-unread-page__detail' }, 'They are in the temporary Data Extension ' + run.deKey + '. ' + costText), h('p', { class: 'ss-unread-page__detail' }, 'Last tried at ' + fmtClock(unread.triedAt) + '.') ]); }; UI._buildPager = function () { var sizeSelect = h('select', { class: 'ss-select', style: { width: '110px' }, 'aria-label': 'Rows per page', onchange: function (ev) { Grid.setPageSize(Number(ev.target.value)); } }, PAGE_SIZES.map(function (n) { return h('option', { value: String(n) }, String(n) + ' per page'); })); var prevBtn = iconButton('Previous page', 'chevronleft', function () { Grid.goToPage(Grid.data.page - 1); }); var nextBtn = iconButton('Next page', 'chevronright', function () { Grid.goToPage(Grid.data.page + 1); }); var rangeLabel = h('span', { class: 'ss-pager__range' }, '0 rows'); var pageInput = h('input', { class: 'ss-input ss-pager__page-input', type: 'number', min: '1', 'aria-label': 'Page number' }, null); pageInput.addEventListener('keydown', function (ev) { if (isPlainEnter(ev)) Grid.goToPage(Number(pageInput.value)); }); var pageOfLabel = h('span', { class: 'ss-pager__page-of' }, 'of 1'); var pager = h('div', { class: 'ss-pager' }, [ h('div', { class: 'ss-pager__left' }, sizeSelect), h('div', { class: 'ss-pager__center' }, [prevBtn, rangeLabel, nextBtn]), h('div', { class: 'ss-pager__right' }, [h('span', null, 'Page'), pageInput, pageOfLabel]) ]); this.el.pageSizeSelect = sizeSelect; this.el.pagingRangeLabel = rangeLabel; this.el.pagingButtons = { prev: prevBtn, next: nextBtn }; this.el.pagePageInput = pageInput; this.el.pagePageOfLabel = pageOfLabel; return pager; }; UI._updatePagingUI = function () { if (!Grid.hasData() || isRunInFlight(state.runnerState)) { this.el.pagingRangeLabel.textContent = '0 rows'; this.el.pagingButtons.prev.disabled = true; this.el.pagingButtons.next.disabled = true; this.el.pagePageInput.value = ''; this.el.pagePageOfLabel.textContent = 'of 1'; return; }; var d = Grid.data; var from = d.count === 0 ? 0 : (d.page - 1) * d.pageSize + 1; var to = Math.min(d.page * d.pageSize, d.count); this.el.pagingRangeLabel.textContent = d.count === 0 ? '0 rows' : (fmtNumber(from) + '-' + fmtNumber(to) + ' of ' + fmtNumber(d.count)); this.el.pageSizeSelect.value = String(d.pageSize); var totalPages = Grid.totalPages(); this.el.pagingButtons.prev.disabled = d.page <= 1; this.el.pagingButtons.next.disabled = d.page >= totalPages; this.el.pagePageInput.value = String(d.page); this.el.pagePageOfLabel.textContent = 'of ' + fmtNumber(totalPages); }; UI._buildGridTable = function (columns, rows) { var self = this; var colgroup = h('colgroup', null, columns.map(function (c, i) { return h('col', { style: { width: (Grid.colWidths[i] || 160) + 'px' } }, null); })); var headRow = h('tr', null, columns.map(function (c, i) { return self._buildHeaderCell(c, i); })); var thead = h('thead', null, headRow); var tbody = h('tbody', null, null); var table = h('table', { class: 'ss-grid', role: 'table', 'aria-label': 'Query results' }, [colgroup, thead, tbody]); var isVirtualized = rows.length > VIRTUALIZE_THRESHOLD; function renderSlice(start, end) { clearNode(tbody); if (isVirtualized && start > 0) { tbody.appendChild(h('tr', { class: 'ss-grid__spacer-row' }, h('td', { colspan: String(columns.length), style: { height: (start * ROW_HEIGHT_PX) + 'px' } }, null))); }; for (var r = start; r < end; r++) tbody.appendChild(self._buildRow(rows[r], r, columns)); if (isVirtualized && end < rows.length) { tbody.appendChild(h('tr', { class: 'ss-grid__spacer-row' }, h('td', { colspan: String(columns.length), style: { height: ((rows.length - end) * ROW_HEIGHT_PX) + 'px' } }, null))); }; }; if (!isVirtualized) { renderSlice(0, rows.length); } else { var scrollEl = this.el.gridScroll; var update = debounce(function () { var top = scrollEl.scrollTop; var viewportH = scrollEl.clientHeight || 400; var buffer = 15; var start = Math.max(0, Math.floor(top / ROW_HEIGHT_PX) - buffer); var count = Math.ceil(viewportH / ROW_HEIGHT_PX) + buffer * 2; renderSlice(start, Math.min(rows.length, start + count)); }, 16); renderSlice(0, Math.min(rows.length, 80)); this._gridScrollHandler = update; scrollEl.addEventListener('scroll', update); setTimeout(update, 0); }; return table; }; UI._buildHeaderCell = function (name, index) { var self = this; var isSorted = Grid.sortColumn === index; var arrow = isSorted ? (Grid.sortDir === 'desc' ? ' ↓' : ' ↑') : ''; var sortBtn = h('button', { class: 'ss-grid__sort' + (isSorted ? ' is-sorted' : ''), type: 'button', title: 'Sort by ' + name, onclick: function () { Grid.sortBy(index); } }, name + arrow); var resizeHandle = h('div', { class: 'ss-grid__resize-handle', 'aria-hidden': 'true', onmousedown: function (ev) { self._startColResize(ev, index); }, ondblclick: function () { self._autosizeCol(index, name); } }, null); return h('th', { scope: 'col' }, [sortBtn, resizeHandle]); }; UI._startColResize = function (ev, index) { ev.preventDefault(); var startX = ev.clientX; var startWidth = Grid.colWidths[index] || 160; var cols = this.el.gridScroll.querySelectorAll('col'); var col = cols[index]; function onMove(e) { var w = clamp(startWidth + (e.clientX - startX), 80, 2000); Grid.setColWidth(index, w); if (col) col.style.width = w + 'px'; }; function onUp() { document.removeEventListener('mousemove', onMove); document.removeEventListener('mouseup', onUp); }; document.addEventListener('mousemove', onMove); document.addEventListener('mouseup', onUp); }; UI._autosizeCol = function (index, name) { var rows = Grid.visibleRows().slice(0, 300); var maxLen = name.length; rows.forEach(function (r) { var v = r[index]; if (v !== null && v !== undefined) maxLen = Math.max(maxLen, String(v).length); }); Grid.setColWidth(index, clamp(maxLen * 7 + 24, 80, 480)); this.renderResults(); }; UI._buildRow = function (row, rowIndex, columns) { var self = this; return h('tr', null, row.map(function (v, colIndex) { return self._buildCell(v, rowIndex, colIndex); })); }; UI._buildCell = function (value, rowIndex, colIndex) { var display, cls = ''; if (value === null || value === undefined) { display = 'NULL'; cls = 'ss-grid__cell--null'; } else if (value === '') { display = '(empty)'; cls = 'ss-grid__cell--empty'; } else { display = String(value); }; return h('td', { class: cls, title: value === null || value === undefined ? '' : String(value), onclick: function (ev) { UI.openCellPopover(ev.currentTarget, value); }, ondblclick: function () { UI.closeCellPopover(); announceCopy(copyToClipboard(value === null || value === undefined ? '' : String(value)), 'Cell copied.'); } }, display); }; UI.openCellPopover = function (cellEl, value) { var self = this; this.closeCellPopover(); var display = value === null || value === undefined ? 'NULL' : (value === '' ? '(empty)' : String(value)); var valueBox = h('div', { class: 'ss-cell-popover__value' }, display); var copyBtn = h('button', { class: 'ss-btn ss-btn--sm', type: 'button', onclick: function () { announceCopy(copyToClipboard(value === null || value === undefined ? '' : String(value)), 'Copied.'); } }, [icon('copy'), h('span', null, ' Copy')]); var pop = h('div', { class: 'ss-cell-popover', role: 'dialog', 'aria-modal': 'false', 'aria-label': 'Cell value', tabindex: '-1' }, [valueBox, copyBtn]); this.el.modalLayer.appendChild(pop); var rect = cellEl.getBoundingClientRect(); var rootRect = this.el.root.getBoundingClientRect(); var top = rect.bottom - rootRect.top + 4; var left = Math.max(8, Math.min(rect.left - rootRect.left, rootRect.width - 280 - 8)); pop.style.top = top + 'px'; pop.style.left = left + 'px'; function onKeydown(ev) { if (ev.key === 'Escape') self.closeCellPopover(); } function onMousedown(ev) { if (!pop.contains(ev.target)) self.closeCellPopover(); } document.addEventListener('keydown', onKeydown, true); document.addEventListener('mousedown', onMousedown, true); this._cellPopover = { el: pop, close: function () { document.removeEventListener('keydown', onKeydown, true); document.removeEventListener('mousedown', onMousedown, true); if (pop.parentNode) pop.parentNode.removeChild(pop); } }; pop.focus(); }; UI.closeCellPopover = function () { if (this._cellPopover) { this._cellPopover.close(); this._cellPopover = null; }; }; UI.renderRunSummary = function () { var el = this.el.runSummary; clearNode(el); var summaryTab = QueryTabs.active(); this._setStaleClasses(isRunStale(summaryTab), isErrorStale(summaryTab), isFailedRunStale(summaryTab)); var startedAt = Runner.currentRunStartedAt; if (!startedAt) { el.appendChild(document.createTextNode('Run a query to see results here.')); this._renderTabResultsCount(); return; }; var rs = state.runnerState; if (isRunInFlight(rs)) { el.appendChild(spinner()); el.appendChild(document.createTextNode(RUN_PHASE_LABELS[rs] + ' · ' + fmtDuration(Date.now() - startedAt))); this._renderTabResultsCount(); return; }; var finishedAt = Runner.currentRunFinishedAt || Date.now(); var dur = fmtDuration(finishedAt - startedAt); if (rs === 'failed') { el.appendChild(hintTrigger('Failed after ' + dur, runDetailsHint(state.runDetails))); } else if (rs === 'cancelled') { el.appendChild(document.createTextNode('Cancelled after ' + dur)); } else if (rs === 'done' && state.activeRun) { var run = state.activeRun; var rowCount = state.results ? state.results.count : (unreadPageOf(state) ? knownRowCount(run) : 0); var phaseAt = Runner.phaseAt || {}; var doneText = 'Done in ' + fmtDuration((phaseAt.done || finishedAt) - startedAt); el.appendChild(hintTrigger(doneText, joinHints([runStepsHint(startedAt, phaseAt), runDetailsHint(state.runDetails)]))); if (rowCount !== null) el.appendChild(h('span', { class: 'ss-run-summary__rows' }, '· ' + fmtRows(rowCount))); if (Runner._calls) { var runApiCalls = Runner._calls.reduce(function (sum, c) { return sum + (c.apiCalls || 0); }, 0); var runWsproxyCalls = Runner._calls.reduce(function (sum, c) { return sum + (c.wsproxyCalls || 0); }, 0); el.appendChild(document.createTextNode(' · ')); el.appendChild(hintTrigger(callCountsText(runApiCalls, runWsproxyCalls), runCallsHint(Runner._calls))); }; var tempUrl = dataExtensionUrl(Session.data && Session.data.stackKey, run.deId); el.appendChild(h('span', { class: 'ss-run-summary__temp' }, [ document.createTextNode('· '), hintTrigger('Temp DE', tempDeHint(run.deKey, !!tempUrl), tempUrl || undefined) ])); el.appendChild(h('button', { class: 'ss-btn ss-btn--link ss-btn--sm ss-run-summary__delete', type: 'button', title: 'Deletes these results from Marketing Cloud Engagement, with the temporary Data Extension ' + run.deKey + ' that holds them. Your query activity and saved queries stay.', onclick: function () { UI._deleteCurrentRun(); } }, 'Delete results')); } else { el.appendChild(document.createTextNode('Run a query to see results here.')); }; this._renderTabResultsCount(); }; var STALE_MARK_TIP = 'These rows come from the query as it last ran. Run it again to see what the edited query returns.'; var STALE_ERROR_MARK_TIP = 'This error comes from the query as it last ran. Run it again to see what the edited query answers.'; UI._buildStaleMark = function () { return h('span', { class: 'ss-stale-mark', tabindex: '0', role: 'note', 'data-tip': STALE_MARK_TIP, 'aria-label': STALE_MARK_TIP, hidden: true }, [document.createTextNode('Edited'), h('span', { class: 'ss-stale-mark__more' }, ' since this run')]); }; UI._setStaleClasses = function (isStale, isErrorStale, isFailedStale) { var isMarked = isStale || !!isFailedStale; if (this.el.gridScroll) this.el.gridScroll.classList.toggle('is-stale', isStale); if (this.el.runSummary) this.el.runSummary.classList.toggle('is-stale', isMarked); if (this.el.statusErrorPanel) this.el.statusErrorPanel.classList.toggle('is-stale', !!isErrorStale); var mark = this.el.staleMark; if (!mark) return; var tip = isStale ? STALE_MARK_TIP : STALE_ERROR_MARK_TIP; if (isMarked && mark.getAttribute('data-tip') !== tip) { mark.setAttribute('data-tip', tip); mark.setAttribute('aria-label', tip); }; if (mark.hidden === !isMarked) return; if (!isMarked && this._hideTipOf) this._hideTipOf(mark); mark.hidden = !isMarked; this._placeHint(this._hintTrigger); }; UI.syncStaleMark = function (isFromEdit) { var tab = QueryTabs.active(); if (!tab) return; var isStale = isRunStale(tab); this._setStaleClasses(isStale, isErrorStale(tab), isFailedRunStale(tab)); if (!isStale || !isFromEdit || tab.runner.hasStaleShownStatus) return; tab.runner.hasStaleShownStatus = true; this.setActiveTab('status'); }; UI.syncValidateNotice = function () { if (state.statusNotices.indexOf(VALIDATE_ACCEPTED_NOTICE) === -1) return; var accepted = state.validateAcceptedText; var text = Editor.getValue(); if (typeof accepted === 'string' && Tools.sameQuery(accepted, text) && Tools.sameQuery(text, accepted)) return; this.removeStatusNotice(VALIDATE_ACCEPTED_NOTICE); }; UI.syncFormatNotice = function () { if (state.statusNotices.indexOf(FORMAT_DONE_NOTICE) === -1) return; if (state.formatDoneText === Editor.getValue()) return; this.removeStatusNotice(FORMAT_DONE_NOTICE); }; UI.syncEditedState = function () { this.syncStaleMark(true); this.syncValidateNotice(); }; UI._renderTabResultsCount = function () { var label = this.el.tabResultsLabel; if (!label) return; label.textContent = !isRunInFlight(state.runnerState) && state.results && typeof state.results.count === 'number' ? 'Results · ' + fmtNumber(state.results.count) : 'Results'; }; UI._deleteCurrentRun = function () { var tab = QueryTabs.active(); var run = tab.state.activeRun; if (!run || run.isDeleting) return; if (tab.activeExport) { Toast.show('This tab is exporting its results. Delete them when the export has ended.', 'info'); return; }; if (tab.activeSave) { Toast.show('This tab is saving its results to a Data Extension. Delete them when the save has ended.', 'info'); return; }; Dialogs.confirm('Delete these results?', 'SQL Studio deletes them from Marketing Cloud Engagement, with the temporary Data Extension ' + run.deKey + ' that holds them. Your query stays, and running it again brings the results back.', function () { UI._deleteRunNow(tab, run); }, null, true, 'Delete'); }; UI._deleteRunNow = function (tab, run) { if (tab.state.activeRun !== run) return; if (tab.activeExport) { Toast.show('This tab is exporting its results. Delete them when the export has ended.', 'info'); return; }; if (tab.activeSave) { Toast.show('This tab is saving its results to a Data Extension. Delete them when the save has ended.', 'info'); return; }; if (run.isDeleting) return; run.isDeleting = true; Api.call('deleteRun', { deKey: run.deKey }).then(function () { Toast.show('Deleted the results and their temporary Data Extension ' + run.deKey + '.', 'success'); if (tab.state.activeRun !== run) return; tab.state.activeRun = null; tab.state.results = null; tab._restoringEntry = null; if (!isRunInFlight(tab.state.runnerState)) { tab.runner.attempt++; tab.runner.currentRunStartedAt = null; }; Grid.setData(tab, null); QueryTabs.markChanged(tab); if (tab === QueryTabs.active()) UI.renderRunSummary(); }).catch(function (err) { run.isDeleting = false; Toast.show('Delete failed: ' + err.message, 'error'); }); }; UI.reportRunOutcome = function (runner, hasRunAnywayLink) { var tab = runner.tab; var msgs = runner.messages || []; var errors = msgs.filter(function (m) { return m.kind === 'error'; }); var hints = msgs.filter(function (m) { return m.kind === 'hint'; }).map(function (m) { return m.text; }); var notices = msgs.filter(function (m) { return m.kind === 'warning' || m.kind === 'info' || (m.kind === 'hint' && !errors.length); }).map(function (m) { return m.text; }); tab.state.runDetails = msgs.filter(function (m) { return m.kind === 'detail'; }).map(function (m) { return m.text; }); var errorPayload = null; if (errors.length) { var text = errors.map(function (m) { return m.text; }).join('\n'); var details = null; for (var i = 0; i < errors.length; i++) { if (errors[i].details) { details = errors[i].details; break; }; }; errorPayload = { message: text, details: details, gate: hasRunAnywayLink ? 'run' : null, hint: hints.length ? hints.join(' ') : null, about: hasRunAnywayLink ? null : errorAbout(runner.currentRunSql, runner.currentRunIsFragment, runner.currentRunContext, true) }; }; tab.state.statusError = errorPayload; tab.state.statusNotices = notices; QueryTabs.render(); if (tab !== QueryTabs.active()) return; this.renderRunSummary(); if (errorPayload) { this.showStatusError(errorPayload.message, errorPayload.details, errorPayload.gate, errorPayload.hint, errorPayload.about); this.revealStatusError(); } else { this.clearStatusError(); }; this.clearStatusNotices(); for (var j = 0; j < notices.length; j++) this.addStatusNotice(notices[j]); }; UI.clearRunOutcome = function () { this.clearStatusError(); this.clearStatusNotices(); }; UI._wireSidebarResizer = function () { var self = this; this.el.sidebarResizer.addEventListener('mousedown', function (ev) { ev.preventDefault(); var startX = ev.clientX; var startWidth = self.el.sidebar.getBoundingClientRect().width; function onMove(e) { var w = clamp(startWidth - (e.clientX - startX), 240, 480); /* dragging left widens the right-hand sidebar */ self.el.sidebar.style.width = w + 'px'; }; function onUp() { document.removeEventListener('mousemove', onMove); document.removeEventListener('mouseup', onUp); Settings.set('sidebarWidth', parseInt(self.el.sidebar.style.width, 10) || 280); Editor.layout(); }; document.addEventListener('mousemove', onMove); document.addEventListener('mouseup', onUp); }); }; UI._wireSplitResizer = function () { var self = this; function resizeBy(delta) { var splitRect = self.el.split.getBoundingClientRect(); var cur = self.el.editorColumn.getBoundingClientRect().height; var next = clamp(cur + delta, 120, Math.max(120, splitRect.height - 120)); self.el.editorColumn.style.height = next + 'px'; Editor.layout(); return next / splitRect.height; }; this.el.splitResizer.addEventListener('mousedown', function (ev) { ev.preventDefault(); var startY = ev.clientY; var startHeight = self.el.editorColumn.getBoundingClientRect().height; function onMove(e) { var splitRect = self.el.split.getBoundingClientRect(); var next = clamp(startHeight + (e.clientY - startY), 120, Math.max(120, splitRect.height - 120)); self.el.editorColumn.style.height = next + 'px'; Editor.layout(); }; function onUp() { document.removeEventListener('mousemove', onMove); document.removeEventListener('mouseup', onUp); var splitRect = self.el.split.getBoundingClientRect(); var ratio = self.el.editorColumn.getBoundingClientRect().height / splitRect.height; if (ratio > 0 && ratio < 1) Settings.set('splitRatio', ratio); }; document.addEventListener('mousemove', onMove); document.addEventListener('mouseup', onUp); }); this.el.splitResizer.setAttribute('tabindex', '0'); this.el.splitResizer.addEventListener('keydown', function (ev) { if (ev.key === 'ArrowUp') { ev.preventDefault(); resizeBy(-20); } else if (ev.key === 'ArrowDown') { ev.preventDefault(); resizeBy(20); }; }); }; UI.applyInitialSplit = function () { var total = this.el.split.getBoundingClientRect().height; if (total > 40) this.el.editorColumn.style.height = Math.round(total * (Settings.data.splitRatio || DEFAULT_SETTINGS.splitRatio)) + 'px'; Editor.layout(); }; var CONNECTION_HINT = 'SQL Studio could not reach Marketing Cloud Engagement. This is most often a dropped connection, which comes back within a minute or so: try again then. If it keeps failing while other sites load, reload the page. Your tabs and their results come back after a reload.'; function firstQueryWord(sql) { var tokens = Tools.tokenize(String(sql || '')); for (var i = 0; i < tokens.length; i++) { var t = tokens[i]; if (t.type === 'whitespace' || t.type === 'comment' || (t.type === 'punct' && t.value === ';')) continue; return t; }; return null; }; function lineCommentPlaces(sql) { var places = { found: [], leading: [], hiding: [], closing: [] }; try { var text = String(sql || ''); var code = Tools.tokenize(text).filter(function (t) { return t.type !== 'comment' && t.type !== 'whitespace'; }); if (!code.length) return places; places.found = Tools.lint(text, {}).filter(function (f) { return f.id === 'MCE100'; }); places.found.forEach(function (f) { if (f.start < code[0].start) places.leading.push(f); else if (f.start < code[code.length - 1].start) places.hiding.push(f); else places.closing.push(f); }); } catch (e) { return { found: [], leading: [], hiding: [], closing: [] }; }; return places; }; function lineCommentHintTail(found) { var many = found.length > 1; var hasFix = found.every(function (f) { return f.fixes && f.fixes.length; }); return ' Lint reports ' + (many ? 'them' : 'it') + ' as MCE100' + (hasFix ? ', with a fix: ' + found[0].fixes[0].title + '.' : ', and its message says how to write ' + (many ? 'them' : 'it') + '.'); }; function parenQueryHintTail(sql, isAtStart, otherwise) { var finding = null; try { finding = Tools.lint(String(sql || ''), {}).filter(function (f) { return f.id === 'MCE077' && (f.message.indexOf('refuses a query that starts with (') !== -1) === isAtStart; })[0] || null; } catch (e) { finding = null; }; if (!finding) return otherwise; if (finding.fixes && finding.fixes.length) return 'Lint reports it as MCE077, with a fix: ' + finding.fixes[0].title + '.'; return 'Lint reports it as MCE077, and its message says how to write it.'; }; var MCE_ERROR_HINTS = [ { test: /Select must be the first word/i, isAboutQueryText: true, hint: function (sql, text, isAfterSave) { var tokens = Tools.tokenize(sql).filter(function (t) { return t.type !== 'whitespace'; }); var firstWord = firstQueryWord(sql); if (firstWord && firstWord.upper === 'WITH') return 'Marketing Cloud Engagement refuses a query that starts with WITH. Put the CTE\'s query in a subquery after FROM instead.'; if (firstWord && firstWord.type === 'punct' && firstWord.value === '(') { var startTail = parenQueryHintTail(sql, true, ''); return startTail ? 'Marketing Cloud Engagement refuses a query that starts with a parenthesis, even one around a SELECT. ' + startTail : 'Marketing Cloud Engagement refuses a query that starts with a parenthesis. Start it with SELECT.'; }; var leading = isAfterSave ? lineCommentPlaces(sql).leading : []; if (leading.length) return 'This query starts with a line comment, and Marketing Cloud Engagement\'s save loses everything after one, so it reads no query.' + lineCommentHintTail(leading); if (tokens[0] && tokens[0].type === 'comment') return 'This query starts with a comment. Marketing Cloud Engagement\'s check sometimes refuses one there, so move it below SELECT and validate again.'; return null; } }, { test: /No column name was specified for column/i, isAboutQueryText: true, hint: 'Marketing Cloud Engagement runs every query inside another one, named Qry, so every column needs a name. FOR JSON or FOR XML on the query itself gives a column without one (MCE070), and so does an expression without AS.' }, { test: /is not a known data extension/i, isAboutQueryText: true, hint: function (sql, text) { var match = /(?:Errors?:\s*)?([^\n]*?) is not a known data extension/i.exec(text); var firstWord = firstQueryWord(sql); if (firstWord && firstWord.upper === 'WITH') return null; var name = match ? match[1].replace(/^.*Errors?:\s*/i, '').split(/\.\s+(?=[A-Z_\['])/).pop() : ''; if (/^\(+\s*SELECT$/i.test(name)) return 'Marketing Cloud Engagement\'s check reads the word after FROM ( as a table name, so it takes (SELECT for a Data Extension and refuses a SELECT in parentheses there. ' + parenQueryHintTail(sql, false, 'Remove the parentheses before that SELECT.'); if (/(^|\s)(sys|INFORMATION_SCHEMA)\.\S+$/i.test(name)) return 'Marketing Cloud Engagement refuses SQL Server\'s system views, sys. and INFORMATION_SCHEMA. names (MCE063). Read a Data View or a Data Extension instead.'; if (name.indexOf('(') !== -1) return 'Marketing Cloud Engagement\'s check reads a table function with arguments, such as OPENJSON or STRING_SPLIT, as a Data Extension name and refuses it (MCE063, MCE067). Read the value with a subquery instead.'; if (/^N?'|^NULL$/i.test(name)) { if (/\bTRIM\s*\(|\bIS\s+(NOT\s+)?DISTINCT\s+FROM\b/i.test(sql)) return 'The check read the FROM inside TRIM(... FROM ...) or IS DISTINCT FROM as the start of a table list (MCE066). Write it another way, such as LTRIM or an IS NULL test.'; return 'A Data Extension name goes in square brackets, not quotes: FROM [My Data Extension].'; }; if (/^VALUES$/i.test(name)) return 'Marketing Cloud Engagement\'s check reads VALUES as a Data Extension name, so it refuses a VALUES table (MCE063). Write the rows as SELECT ... UNION ALL SELECT ..., which lint\'s fix does.'; if (/^ent\./i.test(name) && isParentBusinessUnit()) return 'This is the parent Business Unit, which reads its own Data Extensions without Ent. Only a child Business Unit uses Ent. for shared ones (MCE064).'; return 'Check the name\'s spelling. A shared Data Extension is found only through Ent., and only from a child Business Unit.'; } }, { test: /Incorrect syntax near/i, isAboutQueryText: true, hint: function (sql) { var hasPrefixedText = Tools.tokenize(sql).some(function (t) { return t.type === 'string' && /^N'/i.test(t.value); }); if (!hasPrefixedText) return null; return 'Marketing Cloud Engagement adds N before every text value itself, so N\'...\' becomes NN\'...\' (MCE068). Remove the N: plain quotes keep Polish and other letters.'; } }, { test: /Old style JOIN|ANSI JOINs/i, isAboutQueryText: true, hint: function (sql) { var firstWord = firstQueryWord(sql); if (firstWord && firstWord.upper === 'WITH') return null; return 'The check refuses tables separated by commas after FROM, so join them with JOIN ... ON, or CROSS JOIN: lint reports such a comma as MCE078, with a fix. It also reads the FROM inside TRIM(... FROM ...) or IS DISTINCT FROM that way (MCE066), and the FROM of a subquery with no WHERE before a comma after it (MCE095).'; } }, { test: /ORDER BY clause is invalid in views/i, isAboutQueryText: true, hint: 'Marketing Cloud Engagement runs every query inside another one, so an ORDER BY needs TOP, OFFSET or FOR XML, the query\'s own ORDER BY included (MCE013). A Data Extension keeps no row order: remove ORDER BY and sort the results in the grid.' }, { test: /';' is a reserved word/i, isAboutQueryText: true, hint: 'Marketing Cloud Engagement\'s check refuses a ; in the query, at its end and inside a text value too, though not inside a comment. Remove it, or write it in a text as CHAR(59): lint reports it as MCE002, with a fix.' }, { test: /Cannot find table 0/i, isAboutQueryText: true, hint: function (sql, text, isAfterSave) { if (!isAfterSave) return null; var leading = lineCommentPlaces(sql).leading; if (!leading.length) return null; return 'Marketing Cloud Engagement\'s save loses everything after a line comment, so ' + (leading.length > 1 ? 'the ones' : 'the one') + ' before SELECT hid the whole query.' + lineCommentHintTail(leading); } }, { test: /multi-part identifier[\s\S]*could not be bound|Invalid column name/i, isAboutQueryText: true, hint: function (sql, text, isAfterSave) { if (!isAfterSave) return null; var places = lineCommentPlaces(sql); var hiding = places.leading.concat(places.hiding); if (!hiding.length) return null; return 'A line comment in the query is a likely cause: Marketing Cloud Engagement\'s save loses everything after one, so a name used after it cannot be found.' + lineCommentHintTail(hiding); } }, { test: /Windowed functions can only appear in the SELECT or ORDER BY/i, isAboutQueryText: true, hint: 'Number the rows in a subquery\'s SELECT, then filter on that number outside it: SELECT x.* FROM (SELECT ..., ROW_NUMBER() OVER (...) AS n FROM ...) AS x WHERE x.n = 1 (MCE076).' }, { test: /DISTINCT is not allowed with the OVER clause/i, isAboutQueryText: true, hint: 'Count distinct values in a window with two DENSE_RANKs: DENSE_RANK() OVER (PARTITION BY p ORDER BY x) + DENSE_RANK() OVER (PARTITION BY p ORDER BY x DESC) - 1, less one when x can be NULL. Lint\'s MCE076 fix writes it.' }, { test: /Error while processing square brackets/i, isAboutQueryText: true, hint: 'The known cause is an unpaired apostrophe in a comment, which Marketing Cloud Engagement reads as the start of a text value (MCE069).' }, { test: /Automation failed due to system error/i, hint: function (sql, text, isAfterSave) { var readsJson = /\b(JSON_VALUE|JSON_QUERY|OPENJSON)\s*\(/i.test(sql); var converts = /\b(CAST|CONVERT)\s*\(/i.test(sql); var addsToDate = /\bDATEADD\s*\(/i.test(sql); var divides = false; try { divides = Tools.tokenize(sql).some(function (t) { return t.type === 'operator' && (t.value === '/' || t.value === '%'); }); } catch (e) { divides = false; }; var comments = isAfterSave ? lineCommentPlaces(sql).found : []; var causes = []; if (readsJson) causes.push('JSON_VALUE or JSON_QUERY met a value that is not valid JSON: guard it with CASE WHEN ISJSON(x) = 1 THEN ... END'); if (converts) causes.push('CAST or CONVERT met a value that is not a number or a date: TRY_CAST and TRY_CONVERT return NULL for it instead'); if (divides) causes.push('a division met a divisor of 0: NULLIF(divisor, 0) returns NULL for it instead'); if (addsToDate) causes.push('a number was too big for its type, as Unix milliseconds in DATEADD, whose number is an int: add the seconds instead'); var others = causes.length ? 'One row with a bad value fails the whole query. ' + causes.map(function (c, i) { return i ? c : c.charAt(0).toUpperCase() + c.slice(1); }).join('. Or ') + '.' : 'The causes SQL Studio has seen are JSON_VALUE on a value that is not valid JSON, CAST or CONVERT on a value that is not a number or a date, a division by zero, and a number too big for its type, such as Unix milliseconds in DATEADD. Automation Studio\'s Action Log says only "Automation failed due to system error". To find the column that fails, run the query with fewer columns.'; if (comments.length) return 'The query holds a line comment, and Marketing Cloud Engagement\'s save loses everything after one, which fails a run with no reason.' + lineCommentHintTail(comments) + ' If it is not the comment, ' + others.charAt(0).toLowerCase() + others.slice(1); return others; } }, { test: /JSON text is not properly formatted/i, hint: 'A value is not valid JSON, and one such value fails the whole query. Guard it: CASE WHEN ISJSON(x) = 1 THEN JSON_VALUE(x, \'$.a\') END.' }, { test: /Conversion failed when converting date and\/or time/i, hint: 'A text value that is not a date met a date, through CAST, CONVERT or a comparison with a date. TRY_CONVERT(DATETIME, x) returns NULL for it instead of failing. For a known format, give CONVERT its style number.' }, { test: /Conversion failed when converting the [\s\S]*to data type (big|small|tiny)?int/i, hint: 'A text value that is not a whole number met a number, through CAST, CONVERT or a comparison with a number. TRY_CAST(x AS INT) returns NULL for it instead of failing.' }, { test: /Error converting data type n?varchar to (numeric|decimal|float)/i, hint: 'A text value that is not a number met a decimal number. TRY_CAST(x AS DECIMAL(18, 2)) returns NULL for it instead of failing.' }, { test: /out-of-range value/i, hint: 'A date that does not exist, such as 31 February, or one outside the range of its type was converted. TRY_CONVERT returns NULL for it instead of failing.' }, { test: /Divide by zero/i, hint: 'A division by zero. Divide by NULLIF(x, 0), which gives NULL instead of failing.' }, { test: /Arithmetic overflow/i, hint: 'A number was too big for its type: INT stops at 2,147,483,647. Use BIGINT or DECIMAL instead.' } ]; function serverErrorHint(sql, errors, isAfterSave) { var hints = []; var text = (errors || []).map(function (e) { return String(e || ''); }).join('\n'); var queryCount = Tools.splitStatements(String(sql || '')).length; var isReadAsOne = false; MCE_ERROR_HINTS.forEach(function (entry) { if (!entry.test.test(text)) return; if (queryCount > 1 && entry.isAboutQueryText) { isReadAsOne = true; return; }; var hint = null; try { hint = typeof entry.hint === 'function' ? entry.hint(String(sql || ''), text, isAfterSave === true) : entry.hint; } catch (e) { hint = null; }; if (hint && hints.indexOf(hint) === -1) hints.push(hint); }); if (isReadAsOne) hints.unshift('This text holds ' + queryCount + ' queries, and the check reads them as one query, so what it says may be about neither. Select one query and run it, or validate it, alone.'); return hints.length ? hints.join(' ') : null; }; function whenPageVisible() { return new Promise(function (resolve) { if (!document.hidden) { resolve(); return; }; var onChange = function () { if (document.hidden) return; document.removeEventListener('visibilitychange', onChange); resolve(); }; document.addEventListener('visibilitychange', onChange); }); }; function runForDeKey(deKey) { if (!deKey || typeof QueryTabs === 'undefined' || !QueryTabs._tabs) return null; for (var i = 0; i < QueryTabs._tabs.length; i++) { var run = QueryTabs._tabs[i].state.activeRun; if (run && run.hasRowMark && run.deKey === deKey) return run; }; return null; }; function isParentBusinessUnit() { if (!Session.data || !Session.data.mid || !Session.data.enterpriseId) return null; return String(Session.data.mid) === String(Session.data.enterpriseId); }; function isTextEntryTarget(target) { if (!target || !target.tagName) return false; var tag = target.tagName.toLowerCase(); if (tag === 'textarea' || tag === 'select' || target.isContentEditable) return true; if (tag !== 'input') return false; var type = (target.getAttribute('type') || 'text').toLowerCase(); return ['text', 'search', 'email', 'number', 'password', 'url', 'tel'].indexOf(type) !== -1; }; function selectAllInScope(target) { var scope = null; if (target && target.closest && target.closest('.ss-cell-popover')) { UI.closeCellPopover(); scope = document.querySelector('.ss-grid-scroll'); } else if (target && target.closest) { scope = target.closest('.ss-grid-scroll, .ss-status-panel, [role="dialog"]'); }; if (!scope) { Editor.selectAll(); return; }; var selection = global.getSelection ? global.getSelection() : null; if (!selection) return; var range = document.createRange(); range.selectNodeContents(scope); selection.removeAllRanges(); selection.addRange(range); }; function isPlainEnter(ev) { return ev.key === 'Enter' && !ev.metaKey && !ev.ctrlKey; }; function isBareEnter(ev) { return ev.key === 'Enter' && !ev.metaKey && !ev.ctrlKey && !ev.shiftKey && !ev.altKey; }; function isPlainSpace(ev) { return (ev.key === ' ' || ev.key === 'Spacebar') && !ev.metaKey && !ev.ctrlKey && !ev.altKey; }; function isInEditor(target) { return !!(target && target.closest && target.closest('.ss-editor-container, .ss-editor-fallback')); }; var TIP_DELAY_MS = 250; var FORMAT_DONE_MS = 2000; var FORMAT_DONE_NOTICE = 'Format: done.'; var VALIDATE_ACCEPTED_NOTICE = 'Validate: Marketing Cloud Engagement accepted this SQL.'; UI._wireTips = function () { var self = this; var tip = h('div', { class: 'ss-tip', role: 'tooltip', hidden: true }, null); this.el.root.appendChild(tip); var timer = null; var current = null; var pressedEl = null; var pressedId = null; var isKeyTip = false; var lastPoint = null; function hide() { if (timer) global.clearTimeout(timer); timer = null; current = null; isKeyTip = false; tip.hidden = true; }; function show(target) { if (!target.isConnected || !target.getAttribute('data-tip')) return; tip.textContent = target.getAttribute('data-tip'); tip.style.left = '0px'; tip.style.top = '0px'; tip.hidden = false; var rootBox = self.el.root.getBoundingClientRect(); var box = target.getBoundingClientRect(); var left = Math.max(8, Math.min(box.right - rootBox.left - tip.offsetWidth, rootBox.width - tip.offsetWidth - 8)); var top = box.bottom - rootBox.top + 6; if (top + tip.offsetHeight > rootBox.height - 8) top = box.top - rootBox.top - tip.offsetHeight - 6; tip.style.left = left + 'px'; tip.style.top = top + 'px'; }; function schedule(target) { if (target && (target === pressedEl || (pressedId !== null && identity(target) === pressedId))) return; pressedEl = null; pressedId = null; if (target === current) return; if (target && current && !current.isConnected && identity(target) === identity(current)) { current = target; if (!timer) show(target); return; }; hide(); if (!target) return; current = target; timer = global.setTimeout(function () { timer = null; current = replacement(); if (current) show(current); }, TIP_DELAY_MS); }; function identity(el) { var keyed = el.hasAttribute('data-focus-key') ? el : el.querySelector('[data-focus-key]'); return keyed ? 'key:' + keyed.getAttribute('data-focus-key') : 'tip:' + el.className + '|' + el.getAttribute('data-tip'); }; function replacement() { if (!current || current.isConnected) return current; var el = tipOf(point ? document.elementFromPoint(point.x, point.y) : document.activeElement); return (el && identity(el) === identity(current)) ? el : null; }; var tipOf = function (node) { return (node && node.closest) ? node.closest('[data-tip]') : null; }; var point = null; function isAtRest(ev) { return !!lastPoint && lastPoint.x === ev.clientX && lastPoint.y === ev.clientY; }; document.addEventListener('pointermove', function (ev) { lastPoint = { x: ev.clientX, y: ev.clientY }; }, { capture: true, passive: true }); document.addEventListener('pointerover', function (ev) { var isStill = isAtRest(ev); lastPoint = { x: ev.clientX, y: ev.clientY }; if (isKeyTip && isStill) return; point = { x: ev.clientX, y: ev.clientY }; schedule(tipOf(ev.target)); }, true); document.addEventListener('pointerout', function (ev) { if (isKeyTip && ev.relatedTarget && isAtRest(ev)) return; if (current && (!ev.relatedTarget || (current.contains(ev.target) && !current.contains(ev.relatedTarget)))) hide(); }, true); document.addEventListener('focusin', function (ev) { var target = tipOf(ev.target); if (target && target.matches(':focus-visible')) { point = null; schedule(target); }; }, true); document.addEventListener('focusout', function () { hide(); }, true); document.addEventListener('pointerdown', function (ev) { var pressed = tipOf(ev.target); hide(); pressedEl = pressed; pressedId = pressed ? identity(pressed) : null; }, true); document.addEventListener('scroll', function (ev) { var scroller = ev.target; if (isKeyTip && current && scroller && scroller.nodeType === 1 && scroller.contains(current)) { var box = scroller.getBoundingClientRect(); var optionBox = current.getBoundingClientRect(); if (optionBox.top >= box.top - 1 && optionBox.bottom <= box.bottom + 1) { if (!timer) show(current); return; }; }; hide(); }, true); self._showTipOfOption = function (el) { point = null; schedule(el); if (current === el) isKeyTip = true; }; self._refreshTip = function (el) { if (el === current && !tip.hidden) show(el); }; self._hideTipOf = function (el) { if (current && el.contains(current)) hide(); }; document.addEventListener('keydown', function (ev) { if (ev.key === 'Escape') hide(); }, true); }; UI._wireKeyboard = function () { document.addEventListener('pointermove', function () { if (document.hasFocus()) return; try { global.focus(); } catch (e) { }; }, { capture: true, passive: true }); document.addEventListener('pointerdown', function (ev) { var hadFocus = document.hasFocus(); if (!hadFocus) { try { global.focus(); } catch (e) { }; }; var container = ev.target && ev.target.closest ? ev.target.closest('.ss-editor-container') : null; var isOnMonacoText = !!(ev.target && ev.target.closest && ev.target.closest('.monaco-editor .view-lines, .monaco-editor .margin, .monaco-scrollable-element')); if (container && !isOnMonacoText) { global.setTimeout(function () { Editor.focus(); }, 0); } else if (!hadFocus && container) { global.setTimeout(function () { if (!document.hasFocus()) Editor.focus(); }, 0); }; }, true); global.addEventListener('blur', function () { if (typeof console === 'undefined') return; var active = document.activeElement; var what = active ? (active.tagName.toLowerCase() + (active.className && typeof active.className === 'string' ? '.' + active.className.split(' ').slice(0, 2).join('.') : '')) : 'nothing'; console.info('[SQL Studio] Focus left SQL Studio\'s frame at ' + new Date().toLocaleTimeString() + ' (it was on ' + what + '). A click inside SQL Studio brings it back.'); }); document.addEventListener('keydown', function (ev) { if (ev.key === 'Backspace' && !isTextEntryTarget(ev.target) && !isInEditor(ev.target)) { ev.preventDefault(); return; }; var isModKey = ev.metaKey || ev.ctrlKey; if (isModKey && !ev.altKey && !ev.shiftKey && (ev.key === 'a' || ev.key === 'A') && !isTextEntryTarget(ev.target)) { ev.preventDefault(); selectAllInScope(ev.target); } else if (isModKey && (ev.key === 'b' || ev.key === 'B')) { ev.preventDefault(); UI.setSidebarCollapsed(!Shared.sidebarCollapsed); } else if (isModKey && !ev.altKey && !UI.modalStack.length && !UI._openDropdown && !isInEditor(ev.target)) { var key = String(ev.key || '').toLowerCase(); if (key === 'enter' && !ev.shiftKey) { ev.preventDefault(); Runner.run(); } else if (key === 's' && !ev.shiftKey) { ev.preventDefault(); Dialogs.saveCurrent(); } else if (key === 'f' && ev.shiftKey) { ev.preventDefault(); Editor.format(); }; }; }); }; UI._syncOverlay = function () { var isVisible = !!this._narrow && !Shared.sidebarCollapsed; this.el.overlayBg.style.display = isVisible ? '' : 'none'; }; UI._applyResponsive = function () { var isNarrow = global.innerWidth < 1024; if (isNarrow && !this._narrow) { Shared.sidebarCollapsed = true; this.el.sidebar.classList.add('is-collapsed'); this._syncSidebarToggleButton(); } else if (!isNarrow && this._narrow) { Shared.sidebarCollapsed = false; this.el.sidebar.classList.remove('is-collapsed'); this._syncSidebarToggleButton(); }; this._narrow = isNarrow; this._syncOverlay(); }; UI.showHint = function (trigger, content, isPinned) { var hint = this._hint; if (!hint) { hint = h('div', { class: 'ss-hint', role: 'tooltip' }, null); hint.addEventListener('click', function (ev) { ev.stopPropagation(); }); this.el.root.appendChild(hint); this._hint = hint; document.addEventListener('click', function () { UI.hideHint(null, true); }); document.addEventListener('keydown', function (ev) { if (ev.key === 'Escape') UI.hideHint(null, true); }); }; if (this._hintTrigger && this._hintTrigger !== trigger) this._hintTrigger.setAttribute('aria-expanded', 'false'); if (this._hintPinned && this._hintTrigger !== trigger && !isPinned) return; clearNode(hint); hint.appendChild(content); hint.style.display = 'block'; this._placeHint(trigger); trigger.setAttribute('aria-expanded', 'true'); this._hintTrigger = trigger; this._hintPinned = !!isPinned; }; UI._placeHint = function (trigger) { var hint = this._hint; if (!hint || hint.style.display !== 'block' || !trigger) return; var rootBox = this.el.root.getBoundingClientRect(); var box = trigger.getBoundingClientRect(); var left = Math.max(8, Math.min(box.left - rootBox.left, rootBox.width - hint.offsetWidth - 8)); hint.style.left = left + 'px'; hint.style.top = (box.bottom - rootBox.top + 6) + 'px'; if (box.bottom + 6 + hint.offsetHeight > rootBox.bottom) hint.style.top = (box.top - rootBox.top - hint.offsetHeight - 6) + 'px'; }; UI.hideHint = function (trigger, isPinnedToo) { if (!this._hint) return; if (this._hintPinned && !isPinnedToo) return; if (trigger && this._hintTrigger !== trigger) return; this._hint.style.display = 'none'; if (this._hintTrigger) this._hintTrigger.setAttribute('aria-expanded', 'false'); this._hintTrigger = null; this._hintPinned = false; }; UI.showUpdateBadge = function (latest) { var badge = this.el.updateBadge; if (!badge) return; badge.textContent = 'Update ' + latest.version; badge.title = 'SQL Studio ' + latest.version + ' is available'; badge.style.display = ''; }; UI.hideUpdateBadge = function () { if (this.el.updateBadge) this.el.updateBadge.style.display = 'none'; }; UI.showUpdateDialog = function () { var latest = UpdateCheck.latest; if (!latest) return; var body = [h('p', null, 'You are using SQL Studio ' + APP_VERSION + '.' + (latest.date ? ' Version ' + latest.version + ' came out on ' + latest.date + '.' : ''))]; if (latest.highlights.length) { body.push(h('ul', { class: 'ss-update-list' }, latest.highlights.map(function (item, i) { var lead = latest.highlightLeads[i]; return h('li', null, lead ? [h('strong', null, lead), item.slice(lead.length)] : item); }))); }; body.push(h('p', { class: 'ss-modal__status' }, 'Your Marketing Cloud Engagement admin updates SQL Studio by pasting the new release files, as the update guide describes. Your queries and settings stay.')); var modal = null; var footer = [h('button', { class: 'ss-btn', type: 'button', onclick: function () { UpdateCheck.dismiss(); modal.close(); } }, 'Hide until the next version')]; if (latest.changelog) footer.push(h('a', { class: 'ss-btn', href: latest.changelog, target: '_blank', rel: 'noopener noreferrer' }, 'All changes')); if (latest.updateGuide) footer.push(h('a', { class: 'ss-btn ss-btn--primary', href: latest.updateGuide, target: '_blank', rel: 'noopener noreferrer', 'data-autofocus': true }, 'Update guide')); modal = this.openModal({ title: 'SQL Studio ' + latest.version + ' is available', body: body, footer: footer }); }; UI.showSessionExpiredModal = function (reason) { var isEnding = reason === 'SESSION_ENDING'; var statusLine = h('p', { class: 'ss-modal__status', 'aria-live': 'polite' }, null); var renewBtn = h('button', { class: 'ss-btn ss-btn--primary', type: 'button', 'data-autofocus': true, onclick: function () { if (renewBtn.disabled) return; renewBtn.disabled = true; renewBtn.setAttribute('aria-busy', 'true'); clearNode(renewBtn); renewBtn.appendChild(spinner()); renewBtn.appendChild(document.createTextNode('Signing in')); statusLine.textContent = 'Signing you in to Marketing Cloud Engagement again. SQL Studio reloads when it is done.'; setTimeout(function () { renewBtn.disabled = false; renewBtn.setAttribute('aria-busy', 'false'); clearNode(renewBtn); renewBtn.appendChild(document.createTextNode('Try again')); statusLine.textContent = 'Sign-in is taking longer than usual. Try again, or open SQL Studio again from the Marketing Cloud Engagement menu.'; }, RENEW_SLOW_MS); Session.renew(); } }, 'Renew'); this.openModal({ title: isEnding ? 'Session ending' : 'Session expired', dismissable: false, body: [ h('p', null, isEnding ? 'Your Marketing Cloud Engagement session ends in less than two minutes, and SQL Studio could not renew it in the background. Your query text has been saved. Renew, then run again.' : 'Your Marketing Cloud Engagement session has expired. Your query text and results position have been saved. Renew to continue where you left off.'), statusLine ], footer: [renewBtn] }); }; UI.refreshHistoryDialogIfOpen = function () { if (this._historyDialogRefresh) this._historyDialogRefresh(); }; UI._buildMain = function () { var tabStrip = this._buildQueryTabStrip(); var editorPane = h('div', { class: 'ss-editor-pane' }, null); var editorColumn = h('div', { class: 'ss-editor-column' }, [tabStrip, editorPane]); var splitResizer = h('div', { class: 'ss-split-resizer', role: 'separator', 'aria-orientation': 'horizontal', 'aria-label': 'Resize editor and results panes' }, null); var resultsPane = this._buildBottomPanel(); var split = h('div', { class: 'ss-split' }, [editorColumn, splitResizer, resultsPane]); var main = h('div', { class: 'ss-main' }, [split]); this.el.main = main; this.el.editorColumn = editorColumn; this.el.editorPane = editorPane; this.el.splitResizer = splitResizer; this.el.split = split; return main; }; UI._buildQueryTabStrip = function () { var self = this; var buttons = []; for (var i = 0; i < QUERY_SLOT_COUNT; i++) { (function (index) { var btn = h('button', { class: 'ss-querytab', type: 'button', role: 'tab', tabindex: index === 0 ? '0' : '-1', onclick: function () { QueryTabs.switchTo(index); } }, null); buttons.push(btn); }(i)); }; var tray = h('div', { class: 'ss-querytabs__tray', role: 'tablist', 'aria-label': 'Query tabs' }, buttons); var strip = h('div', { class: 'ss-querytabs' }, tray); this.el.queryTabStrip = strip; this.el.queryTabButtons = buttons; return strip; }; UI.renderQueryTabStrip = function () { if (!this.el.queryTabButtons) return; /* before UI.buildShell has run, or after teardown */ var activeIndex = QueryTabs.activeIndex(); var tabs = QueryTabs.all(); for (var i = 0; i < tabs.length; i++) { var tab = tabs[i]; var btn = this.el.queryTabButtons[i]; if (!btn) continue; var isActive = i === activeIndex; btn.classList.toggle('is-active', isActive); btn.setAttribute('aria-selected', String(isActive)); btn.tabIndex = isActive ? 0 : -1; clearNode(btn); var rs = tab.state.runnerState; var isEmptyIdle = rs === 'idle' && !tab.state.results && !tab.state.dirty && !tab.state.openedQuery && !String(Editor.textFor(tab) || '').trim(); btn.classList.toggle('is-empty', isEmptyIdle); var fullLabel = QueryTabs.labelFor(tab, true); var tip = [fullLabel]; var stateIcon; var meta = null; if (isRunInFlight(rs)) { stateIcon = h('span', { class: 'ss-querytab__icon' }, spinner()); var startedAt = tab.runner.currentRunStartedAt || Date.now(); meta = h('span', { class: 'ss-querytab__meta' }, (TAB_PHASE_LABELS[rs] || 'Running') + ' · ' + fmtShortDuration(Date.now() - startedAt)); tip.push((TAB_PHASE_LABELS[rs] || 'Running') + ' for ' + fmtDuration(Date.now() - startedAt)); } else if (rs === 'done' && tab.state.results) { stateIcon = h('span', { class: 'ss-querytab__icon ss-querytab__icon--done' }, icon('check')); var rowsText = fmtRows(tab.state.results.count); var ranMs = (tab.runner.currentRunStartedAt && tab.runner.currentRunFinishedAt) ? tab.runner.currentRunFinishedAt - tab.runner.currentRunStartedAt : null; meta = h('span', { class: 'ss-querytab__meta' }, rowsText + (ranMs !== null ? ' · ' + fmtShortDuration(ranMs) : '')); tip.push(rowsText + (ranMs !== null ? ' in ' + fmtDuration(ranMs) : '') + (tab.runner.currentRunFinishedAt ? ', finished ' + fmtDateTime(new Date(tab.runner.currentRunFinishedAt)) : '')); } else if (rs === 'done' && unreadPageOf(tab.state)) { stateIcon = h('span', { class: 'ss-querytab__icon ss-querytab__icon--done' }, icon('check')); meta = h('span', { class: 'ss-querytab__meta' }, 'Not loaded'); tip.push('Done, but its rows did not load. Open the tab to read them again.'); } else if (rs === 'failed') { stateIcon = h('span', { class: 'ss-querytab__icon ss-querytab__icon--failed' }, icon('error')); meta = h('span', { class: 'ss-querytab__meta ss-querytab__meta--failed' }, 'Failed'); tip.push('Failed' + (tab.state.statusError ? ': ' + firstLine(tab.state.statusError.message, 160) : '')); } else { stateIcon = h('span', { class: 'ss-querytab__icon ss-querytab__icon--idle' }, icon('database')); }; var children = [stateIcon, h('span', { class: 'ss-querytab__label' }, fullLabel)]; if (tab.runner.currentRunStartedAt) { children.push(h('span', { class: 'ss-querytab__time' }, fmtClock(tab.runner.currentRunStartedAt))); tip.push('Last run started at ' + fmtClock(tab.runner.currentRunStartedAt)); }; if (tab.state.openedQuery && tab.state.dirty) { children.push(h('span', { class: 'ss-querytab__unsaved', 'aria-hidden': 'true' }, null)); tip.push('Unsaved changes to the Query Activity'); }; if (meta) children.push(meta); if (tab.hasUnseenFinish && !isActive) { children.push(h('span', { class: 'ss-querytab__dot', 'aria-hidden': 'true' }, null)); tip.push('Finished since you last looked'); }; var tabSql = isActive ? '' : String(Editor.textFor(tab) || '').trim(); btn.setAttribute('data-tip', tip.join('\n') + (tabSql ? '\n\n' + hoverSql(tabSql) : '')); btn.setAttribute('aria-label', tip.join('. ')); appendChildren(btn, children); if (this._refreshTip) this._refreshTip(btn); }; }; var Dialogs = {}; Dialogs.confirm = function (title, message, onConfirm, onCancel, isCancelFocused, confirmLabel) { var isConfirmed = false; var modal = UI.openModal({ title: title, body: h('p', null, message), footer: [ h('button', { class: 'ss-btn', type: 'button', 'data-autofocus': !!isCancelFocused, onclick: function () { modal.close(); } }, 'Cancel'), h('button', { class: 'ss-btn ss-btn--primary', type: 'button', 'data-autofocus': !isCancelFocused, onclick: function () { isConfirmed = true; modal.close(); onConfirm(); } }, confirmLabel || 'Confirm') ], onClose: function () { if (!isConfirmed && onCancel) onCancel(); } }); }; Dialogs.openAliasNeeded = function (extraction, issues, callback) { var list = h('ul', null, issues.map(function (i) { return h('li', null, (i.code === 'UNNAMED' ? 'No alias: ' : 'Duplicate name: ') + firstLine(i.message, 100)); })); var modal = UI.openModal({ title: issues.length + ' column' + (issues.length > 1 ? 's' : '') + ' need' + (issues.length > 1 ? '' : 's') + ' a name', body: [h('p', null, 'Marketing Cloud Engagement maps the SELECT output to the target Data Extension by name and rejects unnamed or duplicate columns.'), list], footer: [ h('button', { class: 'ss-btn', type: 'button', onclick: function () { modal.close(); callback(false); } }, 'Cancel'), h('button', { class: 'ss-btn ss-btn--primary', type: 'button', 'data-autofocus': true, onclick: function () { modal.close(); callback(true); } }, 'Add aliases and run') ] }); }; function showSaveFailed(err, sql) { var said = err && err.message ? err.message : 'No message.'; var hint = serverErrorHint(sql, [said], true); Toast.show('Save failed: ' + said + (hint ? ' ' + hint : ''), 'error', hint ? { duration: 15000 } : undefined); }; Dialogs.saveCurrent = function (options) { options = options || {}; if (!state.openedQuery) { Dialogs.openSaveAs(); return; }; if (!Editor.isDirty()) { Toast.show('No unsaved changes to "' + state.openedQuery.name + '".', 'info'); return; }; var tab = QueryTabs.active(); var oq = tab.state.openedQuery; var sql = Editor.textFor(tab); if (tab.isSavingQuery) { Toast.show('Saving "' + oq.name + '"…', 'info'); return; }; if (!options.skipLintGate) { var errors = saveLintErrors(sql); if (errors.length) { UI.showStatusError(saveGateMessage(errors.length), null, 'save'); UI.setActiveTab('status'); UI.revealStatusError(); return; }; }; function send() { if (tab.isSavingQuery) return; tab.isSavingQuery = true; Api.call('updateQueryActivity', { id: oq.id, sql: sql, targetKey: oq.targetKey, targetName: oq.targetName, targetId: oq.targetId || undefined, updateType: oq.updateType, categoryId: oq.categoryId }).then(function (res) { tab.state.lastLoadedText = sql; tab.state.dirty = Editor.textFor(tab) !== sql; tab.isSavingQuery = false; clearSaveGate(tab); if (tab === QueryTabs.active()) UI.renderOpenedQueryBadge(); QueryTabs.markChanged(tab); forgetOpenList(); Toast.show('Saved "' + (res.name || oq.name) + '".', 'success'); }).catch(function (err) { tab.isSavingQuery = false; showSaveFailed(err, sql); }); }; if (options.skipLintGate) { send(); return; }; Dialogs.confirm('Save changes to "' + oq.name + '"?', 'This overwrites the saved SQL for this Query Activity.', send); }; function targetListRow(t, folderPaths, isPicked, onPick, asOption) { var metaText = targetMetaText(t, folderPaths); var main = h('div', { class: 'ss-list-row__main' }, [ h('div', { class: 'ss-list-row__title' }, [t.name, ' ', t.isShared ? sharedKindBadge(t) : typeBadge('DE')]), metaText ? h('div', { class: 'ss-list-row__meta' }, metaText) : null ]); var row = h('div', { class: 'ss-list-row', role: 'option', id: asOption.id, 'aria-selected': 'false', onmousedown: function (ev) { ev.preventDefault(); }, onmousemove: asOption.onMove, onclick: function () { onPick(t); } }, main); if (isPicked) row.setAttribute('aria-current', 'true'); row.classList.toggle('is-selected', isPicked); return row; }; function isTextKey(ev) { var isListKey = ev.key === 'ArrowDown' || ev.key === 'ArrowUp' || ev.key === 'Home' || ev.key === 'End'; return isListKey && (ev.shiftKey || ev.altKey || ev.ctrlKey || ev.metaKey); }; function targetCombobox(opts) { var input = opts.input; var listboxId = opts.idPrefix + '-listbox'; var options = []; var activeIndex = -1; var activeKey = null; var isClosed = false; var pointer = null; function isLocked() { return !!opts.isLocked && opts.isLocked(); }; input.setAttribute('role', 'combobox'); input.setAttribute('aria-autocomplete', 'list'); input.setAttribute('aria-expanded', 'false'); input.setAttribute('aria-controls', listboxId); input.setAttribute('autocomplete', 'off'); input.setAttribute('spellcheck', 'false'); opts.list.setAttribute('tabindex', '-1'); function reset() { isClosed = false; activeKey = null; }; function setActive(index, isByPointer, isByKey) { activeIndex = index; activeKey = index === -1 ? null : options[index].key; options.forEach(function (option, i) { option.node.classList.toggle('is-active', i === index); option.node.setAttribute('aria-selected', String(i === index)); }); if (index === -1) { input.removeAttribute('aria-activedescendant'); return; }; var node = options[index].node; input.setAttribute('aria-activedescendant', node.id); if (isByPointer) return; if (node.offsetTop < opts.list.scrollTop) { opts.list.scrollTop = node.offsetTop; } else if (node.offsetTop + node.offsetHeight > opts.list.scrollTop + opts.list.clientHeight) { opts.list.scrollTop = node.offsetTop + node.offsetHeight - opts.list.clientHeight; }; if (isByKey && node.hasAttribute('data-tip') && UI._showTipOfOption) UI._showTipOfOption(node); }; function reopen() { reset(); opts.draw(); }; function onKeydown(ev) { if (ev.isComposing) return; var key = ev.key; if (isTextKey(ev)) return; if (isLocked()) { if (key === 'ArrowDown' || key === 'ArrowUp' || key === 'Home' || key === 'End' || isBareEnter(ev)) ev.preventDefault(); return; }; if (key === 'ArrowDown' || key === 'ArrowUp') { if (isClosed && opts.hasQuery()) { ev.preventDefault(); reopen(); } else if (options.length) { ev.preventDefault(); setActive(Math.max(0, Math.min(options.length - 1, activeIndex + (key === 'ArrowDown' ? 1 : -1))), false, true); }; } else if ((key === 'Home' || key === 'End') && options.length) { ev.preventDefault(); setActive(key === 'Home' ? 0 : options.length - 1, false, true); } else if (isBareEnter(ev) && options.length) { ev.preventDefault(); if (activeIndex !== -1) opts.pick(options[activeIndex].table); } else if (key === 'Escape' && options.length && !opts.isAlwaysOpen) { ev.preventDefault(); ev.stopPropagation(); isClosed = true; opts.draw(); }; }; input.addEventListener('keydown', onKeydown); input.addEventListener('click', function () { if (isClosed && opts.hasQuery()) reopen(); }); function isListClosed() { return isClosed; }; function start() { if (UI._hideTipOf) options.forEach(function (option) { UI._hideTipOf(option.node); }); options = []; }; function buildOptions(items, keyOf, makeRow) { var box = h('div', { id: listboxId, role: 'listbox', 'aria-label': opts.label }, null); items.forEach(function (item, i) { var row = makeRow(item, { id: opts.idPrefix + '-option-' + i, onMove: function (ev) { if (isLocked()) return; if (pointer && pointer.x === ev.clientX && pointer.y === ev.clientY) return; pointer = { x: ev.clientX, y: ev.clientY }; if (activeIndex !== i) setActive(i, true); } }, function () { activeKey = keyOf(item); opts.pick(item); }); options.push({ node: row, table: item, key: keyOf(item) }); box.appendChild(row); }); return box; }; function listbox(tables, folderPaths, picked) { return buildOptions(tables, Schema.tableKey, function (t, asOption, choose) { return targetListRow(t, folderPaths, !!picked && Schema.tableKey(picked) === Schema.tableKey(t), choose, asOption); }); }; function finish() { input.setAttribute('aria-expanded', String(options.length > 0)); var kept = options.map(function (option) { return option.key; }).indexOf(activeKey); setActive(options.length ? Math.max(0, kept) : -1); }; return { reset: reset, isClosed: isListClosed, start: start, listbox: listbox, options: buildOptions, finish: finish }; }; function saveAsOfferedTargets() { return Schema.deList.filter(function (t) { return !!t && !t.isSynchronized; }); }; Dialogs.openSaveAs = function () { var tab = QueryTabs.active(); if (tab.isSavingQuery) { Toast.show('This tab is still saving its query. Try again once Marketing Cloud Engagement has answered.', 'info'); return; }; var sql = Editor.getValue(); var extraction; try { extraction = Tools.extractColumns(sql, Schema.getIndex()); } catch (e) { extraction = { columns: [], issues: [] }; } var picked = { target: null, updateType: 'Overwrite', fields: null }; var isSaving = false; var stoppedFor = null; var stoppedAt = 0; var gateBox = lintErrorsBox(); var nameInput = h('input', { class: 'ss-input', type: 'text', maxlength: '100', required: true }, null); var descInput = h('textarea', { class: 'ss-textarea', rows: '2' }, null); var targetSearch = h('input', { class: 'ss-input', type: 'search', placeholder: 'Search Data Extensions...', oninput: function () { targetCombo.reset(); renderTargetList(targetSearch.value); } }, null); var targetList = h('div', { class: 'ss-list ss-list--picker' }, null); var targetCombo = targetCombobox({ input: targetSearch, list: targetList, idPrefix: 'ss-saveas-target', label: 'Matching Data Extensions', hasQuery: hasTargetQuery, draw: redrawTargetList, pick: selectTarget }); function hasTargetQuery() { return targetSearch.value.trim() !== ''; }; function redrawTargetList() { renderTargetList(targetSearch.value); }; var checkWrap = h('div', { class: 'ss-check-target', hidden: true }, null); var targetNote = h('p', { class: 'ss-hint ss-save__targets-note' }, 'Synchronized Data Extensions are not listed, as they are read-only.'); function renderTargetList(query) { query = String(query || '').trim(); clearNode(targetList); targetList.removeAttribute('aria-busy'); targetList.classList.add('is-closed'); targetCombo.start(); var loadError = Schema.deListError(); if (Schema.isDeListLoading()) { targetList.setAttribute('aria-busy', 'true'); targetList.appendChild(h('div', { class: 'ss-tree__loading' }, [spinner(), " Loading this Business Unit's Data Extensions..."])); } else if (loadError) { targetList.appendChild(h('div', { class: 'ss-hint' }, [ 'Could not load Data Extensions: ' + loadError.message + ' ', h('button', { class: 'ss-tree__link', type: 'button', onclick: loadTargetList }, 'Retry') ])); } else if (!query || targetCombo.isClosed()) { if (picked.target) { targetList.appendChild(chosenTargetSummary(picked.target, Schema.deFolderPaths(), query)); } else { targetList.appendChild(h('div', { class: 'ss-tree__empty' }, deListEmptyPrompt(saveAsOfferedTargets()))); }; } else { targetList.classList.remove('is-closed'); var matches = matchTargetTables(saveAsOfferedTargets(), query); if (matches.length) { targetList.appendChild(targetCombo.listbox(matches.slice(0, 200), Schema.deFolderPaths(), picked.target)); } else { targetList.appendChild(h('div', { class: 'ss-tree__empty' }, 'No Data Extensions match "' + query + '".')); }; }; targetCombo.finish(); }; function loadTargetList() { Schema.loadDataExtensionList().then(function () { renderTargetList(targetSearch.value); }).catch(function () { renderTargetList(targetSearch.value); }); }; function selectTarget(t) { picked.target = t; targetSearch.value = ''; targetCombo.reset(); renderTargetList(''); renderCheck(); syncGate(); }; function renderCheck() { clearNode(checkWrap); checkWrap.hidden = !picked.target; picked.fields = null; renderUpdateNote(); if (!picked.target) return; var target = picked.target; checkWrap.appendChild(h('div', { class: 'ss-hint' }, 'Loading target fields...')); Schema.fields(target).then(function (fields) { if (picked.target !== target) return; picked.fields = fields || []; renderUpdateNote(); clearNode(checkWrap); var targetNames = {}; (fields || []).forEach(function (f) { targetNames[f.name.toLowerCase()] = f; }); var outputNames = {}; extraction.columns.forEach(function (c) { if (c) outputNames[c.toLowerCase()] = c; }); var missingInTargetColumns = extraction.columns.filter(function (c) { return c && !targetNames[c.toLowerCase()]; }); var missingRequiredFields = (fields || []).filter(function (f) { return !f.isNullable && !f.isPrimaryKey && !outputNames[f.name.toLowerCase()]; }); var table = document.createElement('table'); var thead = document.createElement('tr'); ['Output column', 'In target?'].forEach(function (t) { var th = document.createElement('th'); th.textContent = t; thead.appendChild(th); }); table.appendChild(thead); extraction.columns.forEach(function (c) { var tr = document.createElement('tr'); var td1 = document.createElement('td'); td1.textContent = c || '(unnamed)'; var td2 = document.createElement('td'); td2.textContent = c && targetNames[c.toLowerCase()] ? 'yes' : 'no (Marketing Cloud Engagement ignores it)'; tr.appendChild(td1); tr.appendChild(td2); table.appendChild(tr); }); checkWrap.appendChild(table); if (missingInTargetColumns.length) checkWrap.appendChild(h('div', { class: 'ss-message-item ss-message-item--error' }, missingInTargetColumns.length + ' output column(s) are not on the target: ' + missingInTargetColumns.join(', '))); if (missingRequiredFields.length) checkWrap.appendChild(h('div', { class: 'ss-message-item ss-message-item--warning' }, missingRequiredFields.length + ' required target field(s) are not in the output: ' + missingRequiredFields.map(function (f) { return f.name; }).join(', '))); if (!missingInTargetColumns.length && !missingRequiredFields.length) checkWrap.appendChild(h('div', { class: 'ss-message-item ss-message-item--info' }, 'Every output column matches a target field.')); }).catch(function (err) { clearNode(checkWrap); checkWrap.appendChild(h('div', { class: 'ss-hint' }, 'Could not load target fields: ' + err.message)); }); }; var updateTypeLabelId = 'ss-update-type-' + uid(); var updateTypeGroup = h('div', { class: 'ss-radio-group ss-radio-group--row', role: 'radiogroup', 'aria-labelledby': updateTypeLabelId }, ['Overwrite', 'Update', 'Append'].map(function (t) { var input = h('input', { type: 'radio', name: 'ss-update-type', value: t, checked: t === 'Overwrite' }, null); var explain = { Overwrite: 'Replaces all rows in the target every run.', Update: 'Upserts rows using the target primary key.', Append: 'Adds rows without checking for duplicates.' }[t]; var card = h('label', { class: 'ss-radio-option' + (t === 'Overwrite' ? ' is-selected' : '') }, [input, h('strong', null, t), h('span', null, explain)]); input.addEventListener('change', function () { picked.updateType = t; Array.prototype.forEach.call(updateTypeGroup.querySelectorAll('.ss-radio-option'), function (el) { el.classList.remove('is-selected'); }); card.classList.add('is-selected'); renderUpdateNote(); syncGate(); }); return card; })); var updateNote = h('div', { class: 'ss-message-item ss-message-item--warning', role: 'alert', hidden: true }, null); function updateRefusal() { if (picked.updateType !== 'Update' || !picked.target || !picked.fields || !picked.fields.length) return ''; if (!picked.fields.every(function (f) { return !!f.isPrimaryKey; })) return ''; return 'Update needs a field outside the primary key, and every field of ' + picked.target.name + ' is part of it, so Marketing Cloud Engagement refuses the save. Choose Overwrite or Append, or another target.'; }; function renderUpdateNote() { var text = updateRefusal(); updateNote.textContent = text; updateNote.hidden = !text; }; var folderModel = { folderId: '', folderPath: '' }; var folderPicker = saveFolderPicker(folderModel, { read: Schema.queryFolderChoices.bind(Schema), idPrefix: 'ss-saveas-folder', rootText: '(root folder)', isFullWidth: true }); folderPicker.setLoading(true); function chosenCategoryId() { if (folderModel.folderId !== '') return Number(folderModel.folderId); var root = Schema.queryFolderChoices().root; return root ? root.id : null; }; Schema.queryFolders().then(function (queryFolders) { var root = Schema.queryFolderChoices().root; var sqlStudio = root ? queryFolders.filter(function (f) { return f.name === 'SQL Studio' && String(f.parentId) === String(root.id); })[0] : null; if (sqlStudio) { folderModel.folderId = String(sqlStudio.id); folderModel.folderPath = sqlStudio.path; }; folderPicker.setLoading(false); }).catch(function () { folderPicker.setLoading(false); }); function labelledField(labelText, control, extra) { control.id = 'ss-field-' + uid(); return h('div', { class: 'ss-field' }, [h('label', { class: 'ss-label', for: control.id }, labelText), control, extra || null]); }; var saveBtn = h('button', { class: 'ss-btn ss-btn--primary', type: 'button', onclick: function () { save(); } }, 'Save'); function setSaving(isBusy) { isSaving = isBusy; tab.isSavingQuery = isBusy; saveBtn.disabled = isBusy; clearNode(saveBtn); if (isBusy) { saveBtn.setAttribute('aria-busy', 'true'); saveBtn.appendChild(spinner()); saveBtn.appendChild(document.createTextNode('Saving…')); } else { saveBtn.removeAttribute('aria-busy'); saveBtn.textContent = stoppedFor === null ? 'Save' : 'Save anyway'; }; }; function gateKey() { if (!picked.target) return null; return JSON.stringify([Editor.getValue(), picked.target.key, picked.target.name, picked.updateType]); }; function syncGate() { if (stoppedFor === null || stoppedFor === gateKey()) return; stoppedFor = null; gateBox.hide(); if (!isSaving) saveBtn.textContent = 'Save'; }; function save() { if (isSaving) return; var name = nameInput.value.trim(); if (!name) { Toast.show('Name is required.', 'warning'); return; }; if (!picked.target) { Toast.show('Choose a target Data Extension.', 'warning'); return; }; var refusal = updateRefusal(); if (refusal) { Toast.show(refusal, 'warning'); return; }; var categoryId = chosenCategoryId(); var savedSql = Editor.getValue(); var target = picked.target; var updateType = picked.updateType; var errors = saveLintErrors(savedSql, { key: target.key, name: target.name, action: updateType }); if (errors.length) { if (stoppedFor !== gateKey()) { stoppedFor = gateKey(); stoppedAt = Date.now(); gateBox.show(errors); saveBtn.textContent = 'Save anyway'; return; }; if (Date.now() - stoppedAt < SAVE_GATE_CLICK_GAP_MS) return; } else { stoppedFor = null; gateBox.hide(); }; setSaving(true); Api.call('saveQueryActivity', { name: name, description: descInput.value, sql: savedSql, targetKey: target.key, targetName: target.name, targetId: target.objectId || undefined, updateType: updateType, categoryId: categoryId }).then(function (res) { tab.state.openedQuery = { id: res.queryId, key: res.key, name: res.name || name, targetKey: target.key, targetId: target.objectId || null, targetName: target.name, updateType: updateType, categoryId: categoryId }; tab.state.lastLoadedText = savedSql; tab.state.dirty = Editor.textFor(tab) !== savedSql; setSaving(false); QueryTabs.markChanged(tab); UI.renderOpenedQueryBadge(); forgetOpenList(); if (isOpen) modal.close(); Toast.show('Saved "' + (res.name || name) + '".', 'success'); }).catch(function (err) { setSaving(false); showSaveFailed(err, savedSql); }); }; var isOpen = true; var modal = UI.openModal({ title: 'Save As', wide: true, onClose: function () { isOpen = false; UI._saveAsTargetRefresh = null; UI._saveAsEdited = null; }, body: [ labelledField('Name', nameInput), labelledField('Description', descInput), labelledField('Target Data Extension', targetSearch, h('div', null, [targetList, targetNote, checkWrap])), h('div', { class: 'ss-field' }, [h('div', { class: 'ss-label', id: updateTypeLabelId }, 'Update type'), updateTypeGroup, updateNote]), h('div', { class: 'ss-field' }, [h('label', { class: 'ss-label', for: folderPicker.input.id }, 'Folder'), folderPicker.el]) ], footer: [ h('button', { class: 'ss-btn', type: 'button', onclick: function () { modal.close(); } }, 'Cancel'), saveBtn ] }); modal.dialog.insertBefore(gateBox.el, modal.dialog.querySelector('.ss-modal__footer')); UI._saveAsEdited = syncGate; renderTargetList(''); UI._saveAsTargetRefresh = function () { if (picked.target && picked.target.isSynchronized) { picked.target = null; renderCheck(); syncGate(); }; renderTargetList(targetSearch.value); }; loadTargetList(); }; function panelOptionRow(asOption, choose, attrs, children) { var props = { class: 'ss-list-row', role: 'option', id: asOption.id, 'aria-selected': 'false', onmousedown: function (ev) { ev.preventDefault(); }, onmousemove: asOption.onMove, onclick: function () { choose(); } }; Object.keys(attrs || {}).forEach(function (name) { props[name] = attrs[name]; }); return h('div', props, h('div', { class: 'ss-list-row__main' }, children)); }; var openList = { answers: {}, generation: 0, opening: null, redraw: null }; function forgetOpenList() { openList.answers = {}; openList.generation += 1; }; Dialogs.openOpenQuery = function (anchorEl) { var searchInput = h('input', { class: 'ss-input', type: 'search', placeholder: 'Search saved queries...', 'aria-label': 'Search saved queries', 'data-autofocus': true }, null); var list = h('div', { class: 'ss-list' }, null); var openingRegion = h('div', { class: 'ss-list-opening', role: 'status' }, null); var listFrame = h('div', { class: 'ss-list-frame', onmousedown: function (ev) { if (openList.opening) ev.preventDefault(); } }, [list, openingRegion]); var shownOpening = null; var status = h('div', { class: 'ss-hint ss-list-status' }, null); var modal; var latestRequest = 0; var latestAnswered = 0; var isTyping = false; var isFolderAsked = false; var loadError = ''; var openError = ''; var reloadRequest = 0; var metas = []; var reloadBtn = h('button', { class: 'ss-tree__action ss-tree__section-action', type: 'button', 'data-tip': 'Reload the saved queries', 'aria-label': 'Reload the saved queries', 'aria-busy': 'false', onmousedown: function (ev) { ev.preventDefault(); }, onclick: function () { reload(); } }, icon('refresh')); function hasSearchText() { return searchInput.value.trim() !== ''; }; function isOpening() { return !!openList.opening; }; var combo = targetCombobox({ input: searchInput, list: list, idPrefix: 'ss-open-query', label: 'Saved queries', hasQuery: hasSearchText, draw: draw, pick: openItem, isAlwaysOpen: true, isLocked: isOpening }); function openItem(item) { if (openList.opening) return; var existingTab = QueryTabs.findByOpenedQueryId(item.id); if (existingTab) { QueryTabs.switchTo(existingTab.index); modal.close(); Toast.show('Switched to the tab with "' + item.name + '" open.', 'success'); return; }; if (QueryTabs.active().activeExport) { Toast.show('This tab is exporting its results. Open the query when the export has ended.', 'info'); return; }; if (QueryTabs.active().activeSave) { Toast.show('This tab is saving its results to a Data Extension. Open the query when the save has ended.', 'info'); return; }; function doOpen() { var tab = QueryTabs.active(); openList.opening = { name: item.name }; openError = ''; draw(); Api.call('getQueryActivity', { id: item.id }).then(function (full) { if (QueryTabs.active() !== tab) QueryTabs.switchTo(tab.index); QueryTabs.clearResults(tab); Editor.setValue(full.sql || '', true); state.openedQuery = { id: full.id, key: full.key, name: full.name, targetKey: full.targetKey, targetName: full.targetName, targetId: full.targetId || null, updateType: full.updateType, categoryId: full.categoryId }; QueryTabs.markChanged(QueryTabs.active()); UI.renderOpenedQueryBadge(); openList.opening = null; modal.close(); if (openList.redraw) openList.redraw(); Toast.show('Opened "' + full.name + '".', 'success'); }).catch(function (err) { openList.opening = null; openError = 'Could not open "' + item.name + '": ' + err.message; if (openList.redraw) openList.redraw(); Toast.show('Could not open: ' + err.message, 'error'); }); }; if (Editor.isDirty() && Editor.getValue().trim()) { Dialogs.confirm('Discard unsaved changes?', 'Opening "' + item.name + '" replaces the current SQL.', doOpen); } else { doOpen(); }; }; function itemKey(item) { return String(item.id); }; function metaText(item) { var folderPath = Schema.queryFolderPath(item.categoryId); return (folderPath ? 'Folder: ' + folderPath + ' · ' : '') + 'Target: ' + (item.targetName || item.targetKey || '') + ' · ' + (item.updateType || ''); }; function matchesText(item, text) { return [item.name, item.targetName, item.targetKey].some(function (part) { return String(part || '').toLowerCase().indexOf(text) !== -1; }); }; function isTargetOnlyMatch(item, text) { return String(item.name || '').toLowerCase().indexOf(text) === -1 && matchesText(item, text); }; function itemsFor(text) { var answers = openList.answers; var lower = text.toLowerCase(); var isAnswered = Object.prototype.hasOwnProperty.call(answers, lower); var best = isAnswered ? lower : null; if (!isAnswered) { Object.keys(answers).forEach(function (searched) { if (lower.indexOf(searched) !== -1 && (best === null || searched.length > best.length)) best = searched; }); }; if (best === null) return null; var seen = {}; var found = []; var add = function (item) { if (seen[itemKey(item)]) return; seen[itemKey(item)] = true; found.push(item); }; answers[best].forEach(function (item) { if (isAnswered || matchesText(item, lower)) add(item); }); Object.keys(answers).filter(function (searched) { return searched !== best; }).forEach(function (searched) { answers[searched].forEach(function (item) { if (isAnswered ? isTargetOnlyMatch(item, lower) : matchesText(item, lower)) add(item); }); }); return found; }; function drawOpening() { var name = openList.opening ? openList.opening.name : null; listFrame.classList.toggle('is-opening', name !== null); searchInput.readOnly = name !== null; if (name !== null && document.activeElement === reloadBtn) searchInput.focus(); reloadBtn.disabled = name !== null; if (name === shownOpening) return; shownOpening = name; clearNode(openingRegion); if (name === null) return; openingRegion.appendChild(h('div', { class: 'ss-list-opening__box' }, [ spinner(), h('span', { class: 'ss-list-opening__text' }, 'Opening "' + name + '"...') ])); }; function draw() { var text = searchInput.value.trim(); var shown = itemsFor(text); var isSearching = isTyping || latestAnswered < latestRequest; var isReloading = reloadRequest > latestAnswered; var scrollTop = list.scrollTop; clearNode(list); combo.start(); metas = []; reloadBtn.classList.toggle('is-busy', isReloading); reloadBtn.setAttribute('aria-busy', String(isReloading)); drawOpening(); if (isSearching || openList.opening) { list.setAttribute('aria-busy', 'true'); } else { list.removeAttribute('aria-busy'); }; if (shown === null) { list.appendChild(h('div', { class: 'ss-hint' }, loadError && !isSearching ? 'Could not load: ' + loadError : 'Loading...')); } else if (shown.length) { list.appendChild(combo.options(shown, itemKey, function (item, asOption, choose) { var meta = h('div', { class: 'ss-list-row__meta' }, metaText(item)); metas.push({ node: meta, item: item }); return panelOptionRow(asOption, choose, null, [ h('div', { class: 'ss-list-row__title' }, [item.name, ' ', typeBadge('QUERY')]), meta ]); })); } else if (!isSearching && !loadError) { list.appendChild(h('div', { class: 'ss-tree__empty' }, text ? 'No saved queries match "' + text + '".' : 'No saved queries.')); }; list.hidden = !list.firstChild; list.scrollTop = scrollTop; clearNode(status); if (openList.opening) { status.textContent = ''; } else if (openError) { status.textContent = openError; } else if (shown === null) { status.textContent = ''; } else if (isSearching) { status.textContent = 'Searching...'; } else { status.textContent = loadError ? 'Could not load: ' + loadError : ''; }; combo.finish(); }; function hasAnswer(text) { return Object.prototype.hasOwnProperty.call(openList.answers, text.toLowerCase()); }; function askFolders() { if (isFolderAsked) return; isFolderAsked = true; Schema.queryFolders().then(function () { metas.forEach(function (m) { m.node.textContent = metaText(m.item); }); }, function () { /* no folders to show */ }); }; function load(search) { var number = ++latestRequest; var generation = openList.generation; loadError = ''; Api.call('listQueryActivities', { page: 1, pageSize: 50, search: search || undefined }).then(function (res) { if (number < latestAnswered) return; latestAnswered = number; loadError = ''; openError = ''; if (generation === openList.generation) { openList.answers[search.toLowerCase()] = res.items || []; } else if (number === latestRequest && list.isConnected && !hasAnswer(searchInput.value.trim())) { load(searchInput.value.trim()); return; }; draw(); askFolders(); }).catch(function (err) { if (number < latestAnswered) return; latestAnswered = number; loadError = err.message; openError = ''; draw(); }); draw(); }; function reload() { if (reloadRequest > latestAnswered) return; reloadRequest = latestRequest + 1; forgetOpenList(); isTyping = false; load(searchInput.value.trim()); }; var sendSearch = debounce(function () { isTyping = false; if (!list.isConnected) return; var text = searchInput.value.trim(); if (hasAnswer(text)) { draw(); } else { load(text); }; }, 200); searchInput.addEventListener('input', function () { combo.reset(); isTyping = !hasAnswer(searchInput.value.trim()); draw(); sendSearch(); }); var panelOpts = { title: 'Open Query Activity', body: [h('div', { class: 'ss-open-search' }, [searchInput, reloadBtn]), listFrame, status], onClose: function () { if (openList.redraw === draw) openList.redraw = null; } }; if (anchorEl) { panelOpts.xwide = true; modal = UI.openDropdown(anchorEl, 'open', panelOpts); if (!modal) return; } else { panelOpts.wide = true; modal = UI.openModal(panelOpts); }; openList.redraw = draw; if (hasAnswer('')) { draw(); askFolders(); } else { load(''); }; }; Dialogs.openHistory = function (anchorEl) { var isServerMode = !!Session.data.historyEnabled; var searchInput = h('input', { class: 'ss-input', type: 'search', placeholder: 'Search history...', 'aria-label': 'Search history', 'data-autofocus': true }, null); var list = h('div', { class: 'ss-list' }, null); function hasSearchText() { return searchInput.value.trim() !== ''; }; function loadEntry(item) { loadItem(item.sql); }; var combo = targetCombobox({ input: searchInput, list: list, idPrefix: 'ss-history', label: 'History entries', hasQuery: hasSearchText, draw: render, pick: loadEntry, isAlwaysOpen: true }); var whereText = isServerMode ? 'Kept in Marketing Cloud Engagement for 90 days, where anyone with Data Extension access in the Business Unit that holds SQL Studio can read it.' : 'Kept in this browser only. Your admin can keep it in Marketing Cloud Engagement instead, so it follows you to other browsers.'; var where = h('p', { class: 'ss-hint ss-history__where' }, whereText); var dropdown; var serverItems = []; function loadItem(sql) { if (QueryTabs.active().activeExport) { Toast.show('This tab is exporting its results. Load the entry when the export has ended.', 'info'); return; }; if (QueryTabs.active().activeSave) { Toast.show('This tab is saving its results to a Data Extension. Load the entry when the save has ended.', 'info'); return; }; function doLoad() { QueryTabs.clearResults(QueryTabs.active()); Editor.setValue(sql, true); state.openedQuery = null; QueryTabs.markChanged(QueryTabs.active()); UI.renderOpenedQueryBadge(); dropdown.close(); }; if (Editor.isDirty() && Editor.getValue().trim()) { Dialogs.confirm('Discard unsaved changes?', 'Loading this history entry replaces the current SQL.', doLoad); } else { doLoad(); }; }; function showNote(note) { clearNode(list); combo.start(); list.appendChild(note); combo.finish(); }; function entryKey(item) { return String(item.id || ((item.createdDate || item.startedAt) + '|' + item.sql)); }; function renderRows(items) { if (!items.length) { showNote(h('div', { class: 'ss-tree__empty' }, 'No history yet.')); return; }; clearNode(list); combo.start(); list.appendChild(combo.options(items, entryKey, function (item, asOption, choose) { var whenDate = item.createdDate || item.startedAt; var tipText = hoverSql(item.sql); var descriptionId = asOption.id + '-sql'; return panelOptionRow(asOption, choose, { 'data-tip': tipText, 'aria-describedby': descriptionId }, [ h('div', { class: 'ss-list-row__meta' }, fmtDateTime(whenDate) + ' · ' + fmtRows(item.rowCount) + ' · ' + fmtDuration(item.durationMs) + (item.status && item.status !== 'done' ? ' · ' + item.status : '')), h('div', { class: 'ss-list-row__sql' }, firstLine(item.sql, 140)), h('div', { id: descriptionId, hidden: true }, tipText) ]); })); combo.finish(); }; function render() { if (!isServerMode) { renderRows(History.search(searchInput.value)); return; }; var q = searchInput.value.trim().toLowerCase(); renderRows(!q ? serverItems : serverItems.filter(function (i) { return i.sql.toLowerCase().indexOf(q) !== -1; })); }; function loadServerItems() { showNote(h('div', { class: 'ss-tree__loading' }, 'Loading...')); Api.call('listHistory', {}).then(function (res) { serverItems = res.items || []; render(); }).catch(function (err) { showNote(h('div', { class: 'ss-hint' }, 'Could not load history: ' + err.message)); }); }; searchInput.addEventListener('input', function () { combo.reset(); render(); }); var clearBtn = h('button', { class: 'ss-btn ss-btn--sm', type: 'button', onclick: function () { Dialogs.confirm('Clear all history?', 'This removes every entry.', function () { if (isServerMode) { Api.call('clearHistory', {}).then(function () { serverItems = []; render(); }).catch(function (err) { Toast.show('Could not clear history: ' + err.message, 'error'); }); } else { History.clear(); render(); }; }); } }, 'Clear all'); dropdown = UI.openDropdown(anchorEl, 'history', { title: 'History', wide: true, body: [searchInput, where, list], footer: [clearBtn], onClose: function () { UI._historyDialogRefresh = null; } }); UI._historyDialogRefresh = render; if (isServerMode) loadServerItems(); else render(); }; Dialogs.openAbout = function () { var s = Session.data; function aboutLink(label, href, isInline) { return h('a', { class: isInline ? 'ss-about__link' : 'ss-about__pill', href: href, target: '_blank', rel: 'noopener noreferrer' }, label); }; function detail(label, value) { return [h('dt', null, label), h('dd', null, value)]; }; var mid = s.mid !== null && s.mid !== undefined ? String(s.mid) : '-'; var userName = s.userName || Config.data.userName || '-'; var userEmail = s.userEmail || Config.data.userEmail || ''; var body = [ h('div', { class: 'ss-about' }, [ h('div', { class: 'ss-about-logo', 'aria-hidden': 'true' }, null), h('div', { class: 'ss-about__title' }, [ h('h3', { class: 'ss-about__name' }, 'SQL Studio'), h('p', { class: 'ss-about__tagline' }, 'SQL for Marketing Cloud Engagement, without leaving it') ]), h('p', { class: 'ss-about__info' }, 'A free, open-source rebuild of Query Studio: write a query, run it and see the rows, all inside Marketing Cloud Engagement. It runs in your own account, on a Cloud Page and two Code Resources, with your Marketing Cloud Engagement login, so your data never leaves it.'), h('dl', { class: 'ss-about__details' }, [ detail('Version', 'Frontend ' + APP_VERSION + ' · Backend ' + (s.backendVersion || '-')), detail('Signed in as', userName + (userEmail ? ' · ' + userEmail : '')), detail('Business Unit', 'MID ' + mid), detail('This session', apiCallsText(Shared.apiCalls || 0) + (Shared.wsproxyCalls ? ' and ' + wsproxyCallsText(Shared.wsproxyCalls) : '')) ]), h('div', { class: 'ss-about__links' }, [ aboutLink('mateuszdabrowski.pl', 'https://mateuszdabrowski.pl'), aboutLink('GitHub', Config.data.repoUrl), aboutLink('Report an issue', Config.data.repoUrl + '/issues') ]), h('div', { class: 'ss-about__credits' }, [ h('p', null, ['Editor: ', aboutLink('Monaco Editor', 'https://microsoft.github.io/monaco-editor/', true), ', ©\u00a0Microsoft Corporation, ', aboutLink('MIT', 'https://github.com/microsoft/monaco-editor/blob/main/LICENSE.txt', true)]), h('p', null, ['Icons: ', aboutLink('Salesforce Lightning Design System', 'https://www.lightningdesignsystem.com/', true), ', ©\u00a0Salesforce, Inc., ', aboutLink('CC BY-ND 4.0', 'https://creativecommons.org/licenses/by-nd/4.0/', true)]) ]), h('div', { class: 'ss-about__license' }, [ h('p', null, ['© 2026 Mateusz Dąbrowski · Licensed under the ', aboutLink('EUPL 1.2', Config.data.repoUrl + '/blob/main/LICENSE', true)]), h('p', null, 'Free to use, change and share, commercial use included, as long as this credit stays.'), h('p', null, 'SQL Studio is an independent project. It is not created, sponsored or endorsed by Salesforce.') ]) ]) ]; var modal = UI.openModal({ title: 'About SQL Studio', body: body, footer: [h('button', { class: 'ss-btn ss-btn--primary', type: 'button', 'data-autofocus': true, onclick: function () { modal.close(); } }, 'Close')] }); return modal; }; var SkillOffer = { isBusy: false, hasFailed: false, redraw: null, downloadedVersion: function () { var stored = safeGet(LS.skillDownloaded); return /^\d+\.\d+\.\d+$/.test(String(stored)) ? stored : null; }, olderVersion: function () { var had = this.downloadedVersion(); return (had && compareVersions(APP_VERSION, had) > 0) ? had : null; }, download: function () { var self = this; if (self.isBusy) return; self.isBusy = true; self.hasFailed = false; self._changed(); var isDone = false; var controller = (typeof AbortController !== 'undefined') ? new AbortController() : null; var timer = setTimeout(function () { if (controller) controller.abort(); }, SKILL_DOWNLOAD_TIMEOUT_MS); function finish(error) { if (isDone) return; isDone = true; clearTimeout(timer); self.isBusy = false; self.hasFailed = !!error; if (error && typeof console !== 'undefined') console.info('[SQL Studio] The skill did not download: ' + (error.message ? error.message : String(error))); self._changed(); }; if (typeof fetch !== 'function') { finish(new Error('this browser has no fetch')); return; }; var fetchOpts = { credentials: 'omit', referrerPolicy: 'no-referrer', cache: 'no-store' }; if (controller) fetchOpts.signal = controller.signal; fetch(SKILL_FILE_URL, fetchOpts).then(function (res) { if (!res.ok) throw new Error('GitHub answered ' + res.status); return res.arrayBuffer(); }).then(function (buffer) { var bytes = new Uint8Array(buffer); if (bytes.length < 4 || bytes[0] !== 0x50 || bytes[1] !== 0x4B) throw new Error('the answer is not a ZIP'); downloadBlob(SKILL_FILE_NAME, new Blob([bytes], { type: 'application/zip' })); safeSet(LS.skillDownloaded, APP_VERSION); Toast.show('Saved ' + SKILL_FILE_NAME + '.', 'success'); finish(null); }).catch(function (err) { finish(err || new Error('the request failed')); }); }, _changed: function () { if (this.redraw) this.redraw(); } }; Dialogs.openSkill = function () { var olderNote = null; if (SkillOffer.olderVersion()) { olderNote = h('p', { class: 'ss-skill__note' }, [ 'You downloaded the skill of SQL Studio ', h('strong', null, SkillOffer.olderVersion()), ' in this browser. This is ', h('strong', null, APP_VERSION), ', so its skill may be newer.' ]); }; SkillOffer.hasFailed = false; var status = h('p', { class: 'ss-modal__status ss-skill__status', 'aria-live': 'polite' }, null); var downloadBtn = h('button', { class: 'ss-btn ss-skill__download', type: 'button', onclick: function () { SkillOffer.download(); } }, null); function draw() { var isBusy = SkillOffer.isBusy; clearNode(downloadBtn); downloadBtn.setAttribute('aria-busy', isBusy ? 'true' : 'false'); downloadBtn.setAttribute('aria-disabled', isBusy ? 'true' : 'false'); downloadBtn.classList.toggle('is-busy', isBusy); downloadBtn.appendChild(isBusy ? spinner() : icon('download')); downloadBtn.appendChild(h('span', null, isBusy ? 'Downloading…' : 'Download skill')); status.textContent = SkillOffer.hasFailed ? 'The download failed. Open the skill on GitHub to take the file from there, or try again.' : ''; if (olderNote && olderNote.parentNode && !SkillOffer.olderVersion()) olderNote.parentNode.removeChild(olderNote); }; var body = h('section', { class: 'ss-skill' }, [ h('h3', { class: 'ss-skill__title' }, 'The ' + SKILL_TITLE + ' skill'), h('p', { class: 'ss-skill__blurb' }, 'It gives Claude SQL Studio\'s lint, fixes and Format, the System Data Views with their fields, and the author\'s SQL style guide. The SQL Claude writes, for example through the MCP Server for MCE, then avoids what MCE refuses. It checks the columns against a target Data Extension when it knows the fields.'), h('p', { class: 'ss-skill__steps' }, ['In the Claude app or on claude.ai, open Settings > Skills and upload the ' + SKILL_FILE_NAME + ' file. For Claude Code, unzip it into ', h('code', null, '~/.claude/skills/'), '.']), olderNote, status, h('div', { class: 'ss-skill__actions' }, [ h('a', { class: 'ss-skill__view', href: Config.data.repoUrl + '/tree/main/skill', target: '_blank', rel: 'noopener noreferrer' }, 'View on GitHub'), downloadBtn ]) ]); var modal = UI.openModal({ title: 'Write SQL with Claude', body: body, footer: [h('button', { class: 'ss-btn ss-btn--primary', type: 'button', 'data-autofocus': true, onclick: function () { modal.close(); } }, 'Close')], onClose: function () { if (SkillOffer.redraw === draw) SkillOffer.redraw = null; } }); SkillOffer.redraw = draw; draw(); return modal; }; var TEMP_DE_SWEEP_MAX_AGE_MS = 6 * 60 * 60 * 1000; function parseTempDeKeyStamp(key) { var m = /_(\d{4})(\d{2})(\d{2})_(\d{2})(\d{2})(\d{2})(?:_\d+)?$/.exec(String(key || '')); if (!m) return null; var parsedDate = new Date(Number(m[1]), Number(m[2]) - 1, Number(m[3]), Number(m[4]), Number(m[5]), Number(m[6])); return isNaN(parsedDate.getTime()) ? null : parsedDate.getTime(); }; function sweepTempDataExtensions(protectedDeKeys) { var sweptAt = Number(safeGet(userKeyFor(LS.sweptAt))) || 0; if (Date.now() - sweptAt < SWEEP_EVERY_MS) return; Api.call('listRuns', {}).then(function (res) { safeSet(userKeyFor(LS.sweptAt), String(Date.now())); var des = (res && res.dataExtensions) || []; var staleDataExtensions = des.filter(function (de) { if (!de || !de.key || protectedDeKeys.indexOf(de.key) !== -1) return false; var ageMs; if (typeof de.ageSeconds === 'number') { ageMs = de.ageSeconds * 1000; } else { var stamp = parseTempDeKeyStamp(de.key); if (stamp === null) return false; ageMs = Date.now() - stamp; }; return ageMs > TEMP_DE_SWEEP_MAX_AGE_MS; }); (function step(i) { if (i >= staleDataExtensions.length) return; Api.call('deleteRun', { deKey: staleDataExtensions[i].key }).catch(function () { /* best effort */ }).then(function () { step(i + 1); }); })(0); }).catch(function () { /* best effort */ }); }; var SAVE_FIELD_TYPES = ['Text', 'Number', 'Date', 'Boolean', 'EmailAddress', 'Phone', 'Decimal', 'Locale']; var SAVE_SENDABLE_TYPES = { subscriberKey: ['Text', 'EmailAddress', 'Phone'], emailAddress: ['EmailAddress'] }; var SAVE_NAME_MAX = 128; var SAVE_KEY_MAX = 36; var SAVE_TEXT_MAX = 4000; var SAVE_TEXT_STEPS = [254, 500, 1000, 2000, 4000]; var SAVE_DEFAULT_MAX = 4000; var SAVE_DECIMAL_MAX_PRECISION = 29; var SAVE_DECIMAL_MAX_SCALE = 8; var SAVE_DECIMAL_DEFAULT_PRECISION = 18; var SAVE_DECIMAL_DEFAULT_SCALE = 4; var SAVE_PERIOD_MAX = 999; var SAVE_RETENTION_UNITS = ['Days', 'Weeks', 'Months', 'Years']; var SAVE_RETENTION_MODES = [ { id: 'none', label: 'Keep the rows', isRowBased: false, isDataExtensionDeleted: false }, { id: 'rows', label: 'Delete individual rows after', isRowBased: true, isDataExtensionDeleted: false }, { id: 'all', label: 'Delete all rows after', isRowBased: false, isDataExtensionDeleted: true }, { id: 'de', label: 'Delete all rows and the Data Extension after', isRowBased: false, isDataExtensionDeleted: false } ]; var SAVE_TEMP_KEY_RE = /^SQLStudio_[0-9a-f]{8}_[0-9]{8}_[0-9]{6}(_[2-9])?$/i; var SAVE_OBJECT_ID_RE = /^[0-9a-fA-F-]{36}$/; var SAVE_ROW_MARK_FIELD = 'SQLStudioRowMark'; var SAVE_CREATE_API_CALLS = 1; var SAVE_MOVE_API_CALLS = 5; var SAVE_EXPECTED_MOVE_MS = 20000; var SAVE_BUTTON_LABEL = 'Save to DE'; var SAVE_BUTTON_BUSY_LABEL = 'Saving…'; var SAVE_BUTTON_TITLE = 'Save these results to a Data Extension'; var SAVE_ROOT_FOLDER_TEXT = 'Data Extensions (the top folder)'; function readSavedFolder() { var stored = readJSON(userKeyFor(LS.saveFolder), null); if (!stored || typeof stored !== 'object') return null; var id = stored.folderId; if (typeof id !== 'string' && typeof id !== 'number') return null; return String(id) === '' ? null : String(id); }; function writeSavedFolder(folderId) { var key = userKeyFor(LS.saveFolder); if (!folderId) { safeRemove(key); return; }; writeJSON(key, { folderId: String(folderId) }); }; function saveHasControl(text) { return /[\u0000-\u001f]/.test(String(text)); }; function saveIsWhole(text, min, max) { var trimmed = String(text).trim(); if (!/^[0-9]+$/.test(trimmed)) return false; var n = parseInt(trimmed, 10); return n >= min && n <= max; }; function saveTypeOfSourceField(field) { if (field && field.untyped === true) return 'Text'; var key = String(field && field.type ? field.type : '').toLowerCase().replace(/[^a-z]/g, ''); var byKey = { text: 'Text', number: 'Number', date: 'Date', boolean: 'Boolean', email: 'EmailAddress', emailaddress: 'EmailAddress', phone: 'Phone', decimal: 'Decimal', locale: 'Locale' }; return Object.prototype.hasOwnProperty.call(byKey, key) ? byKey[key] : 'Text'; }; function saveFindFromEntry(fromTables, qualifier) { var wanted = String(qualifier).toUpperCase(); var byAlias = fromTables.filter(function (f) { return !!f.alias && f.alias.toUpperCase() === wanted; }); if (byAlias.length) return byAlias.length === 1 ? byAlias[0] : null; var byName = fromTables.filter(function (f) { return !f.alias && !!f.name && String(f.name).replace(/^ENT\.\s*/i, '').toUpperCase() === wanted; }); return byName.length === 1 ? byName[0] : null; }; function saveFieldsOfEntry(entry, cteNames) { if (!entry || entry.kind !== 'table' || entry.isFunction || !entry.name) return null; var key = String(entry.name).replace(/^\[|\]$/g, '').toUpperCase(); if (entry.nameParts === 1 && cteNames[key]) return null; var table = Tools.fieldCheckTable(Schema.byName, entry); if (!table || !Array.isArray(table.fields)) return null; var isOpen = table.fields.some(function (f) { return !!f && typeof f.name === 'string' && f.name.charAt(0) === '['; }); return isOpen ? null : table.fields; }; function saveFieldNamed(fields, name) { var wanted = String(name).toLowerCase(); for (var i = 0; i < fields.length; i++) { if (fields[i] && String(fields[i].name).toLowerCase() === wanted) return fields[i]; }; return null; }; function saveSourceFields(sql, count) { var none = []; for (var n = 0; n < count; n++) none.push(null); var parsed = null; try { parsed = Tools.parse(sql, Schema.getIndex()); } catch (err) { return none; }; if (!parsed || parsed._noSelect || !parsed.selectList || !parsed.selectList.items) return none; if (parsed.clauses && parsed.clauses.union && parsed.clauses.union.length > 1) return none; var fromTables = parsed.from || []; var cteNames = {}; (parsed.ctes || []).forEach(function (c) { cteNames[String(c.name).toUpperCase()] = true; }); var found = []; parsed.selectList.items.forEach(function (item) { if (item.kind === 'star') { var names = item._resolvedColumns; var entry = item.starTable ? saveFindFromEntry(fromTables, item.starTable) : (fromTables.length === 1 ? fromTables[0] : null); var starFields = names ? saveFieldsOfEntry(entry, cteNames) : null; (names || [null]).forEach(function (name) { found.push(starFields && name !== null ? saveFieldNamed(starFields, name) : null); }); } else if ((item.kind === 'column' || item.kind === 'alias') && item.sourceColumn) { var field = null; if (item.sourceTable) { var fields = saveFieldsOfEntry(saveFindFromEntry(fromTables, item.sourceTable), cteNames); field = fields ? saveFieldNamed(fields, item.sourceColumn) : null; } else { var isUnsure = false; fromTables.forEach(function (from) { var candidates = saveFieldsOfEntry(from, cteNames); if (!candidates) { isUnsure = true; return; }; var match = saveFieldNamed(candidates, item.sourceColumn); if (!match) return; if (field) isUnsure = true; field = match; }); if (isUnsure) field = null; }; found.push(field); } else { found.push(null); }; }); return found.length === count ? found : none; }; function saveDraftField(name, columnType, source, longest) { var type = 'Text'; var length = saveTextLengthOf(longest); var precision = ''; var scale = ''; if (columnType && SAVE_FIELD_TYPES.indexOf(columnType.type) !== -1) { type = columnType.type; } else if (source) { type = saveTypeOfSourceField(source); }; if (type === 'Text' && source && typeof source.length === 'number' && source.length >= 1 && source.length <= SAVE_TEXT_MAX) length = source.length; if (type === 'Decimal') { var givenPrecision = columnType && typeof columnType.precision === 'number' ? columnType.precision : (source ? (source.precision != null ? source.precision : source.length) : null); var givenScale = columnType && typeof columnType.scale === 'number' ? columnType.scale : (source && typeof source.scale === 'number' ? source.scale : null); var digits = (typeof givenPrecision === 'number' && givenPrecision >= 1) ? Math.min(SAVE_DECIMAL_MAX_PRECISION, Math.floor(givenPrecision)) : SAVE_DECIMAL_DEFAULT_PRECISION; var after = (typeof givenScale === 'number' && givenScale >= 0) ? Math.floor(givenScale) : SAVE_DECIMAL_DEFAULT_SCALE; precision = String(digits); scale = String(Math.min(after, digits, SAVE_DECIMAL_MAX_SCALE)); }; return { source: name, name: name, type: type, length: type === 'Text' ? String(length) : '', precision: precision, scale: scale, isPrimaryKey: false, isNullable: true, defaultValue: '' }; }; function saveLongestOnScreen(tab, columnIndex) { var rows = (tab && tab.state && tab.state.results && Array.isArray(tab.state.results.rows)) ? tab.state.results.rows : []; var longest = 0; rows.forEach(function (row) { var value = Array.isArray(row) ? row[columnIndex] : null; if (value !== null && value !== undefined && String(value).length > longest) longest = String(value).length; }); return longest; }; function saveValuesOnScreen(tab, columnIndex) { var rows = (tab && tab.state && tab.state.results && Array.isArray(tab.state.results.rows)) ? tab.state.results.rows : []; return rows.map(function (row) { return Array.isArray(row) ? row[columnIndex] : null; }); }; function saveIsEveryRowOnScreen(tab) { var results = tab && tab.state ? tab.state.results : null; return !!results && Array.isArray(results.rows) && typeof results.count === 'number' && results.count > 0 && results.rows.length === results.count; }; function saveDraftedTypes(sql, count) { var none = { types: [], isField: [], isAligned: false }; try { var types = Tools.inferSaveColumnTypes(sql, Schema.getIndex()); var isField = Tools.inferSaveColumnSources(sql, Schema.getIndex()); if (types.length !== count || isField.length !== count) return none; return { types: types, isField: isField, isAligned: true }; } catch (err) { return none; }; }; function saveDraftColumnType(tab, runType, drafted, source, i) { if (runType) return runType; var given = drafted.types[i]; var isPastScale = !!given && given.type === 'Decimal' && typeof given.scale === 'number' && given.scale > SAVE_DECIMAL_MAX_SCALE; if (given && !isPastScale) return given; if (source || !drafted.isAligned || drafted.isField[i] !== false || !saveIsEveryRowOnScreen(tab)) return null; return Tools.inferValueType(saveValuesOnScreen(tab, i), { maxPrecision: SAVE_DECIMAL_MAX_PRECISION, maxScale: SAVE_DECIMAL_MAX_SCALE }); }; function saveTextLengthOf(longest) { for (var i = 0; i < SAVE_TEXT_STEPS.length; i++) { if (longest <= SAVE_TEXT_STEPS[i]) return String(SAVE_TEXT_STEPS[i]); }; return String(SAVE_TEXT_MAX); }; function saveNumberMisfit(f, values) { if (f.type !== 'Number' && f.type !== 'Decimal') return null; var precision = SAVE_DECIMAL_DEFAULT_PRECISION; var scale = SAVE_DECIMAL_DEFAULT_SCALE; if (f.type === 'Decimal') { if (f.precision.trim() !== '' && !saveIsWhole(f.precision, 1, SAVE_DECIMAL_MAX_PRECISION)) return null; if (f.precision.trim() !== '') precision = parseInt(f.precision, 10); scale = Math.min(scale, precision); if (f.scale.trim() !== '' && !saveIsWhole(f.scale, 0, Math.min(precision, SAVE_DECIMAL_MAX_SCALE))) return null; if (f.scale.trim() !== '') scale = parseInt(f.scale, 10); }; var rounded = null; for (var i = 0; i < values.length; i++) { if (values[i] === null || values[i] === undefined || String(values[i]) === '') continue; var text = String(values[i]).trim(); var says = null; var isFatal = true; if (f.type === 'Number' && text === '') continue; if (f.type === 'Number') { var whole = /^([-+]?)0*(\d+)$/.exec(text); var limit = whole && whole[1] === '-' ? '2147483648' : '2147483647'; var isInRange = !!whole && (whole[2].length < limit.length || (whole[2].length === limit.length && whole[2] <= limit)); if (!isInRange) says = 'is no whole number from -2,147,483,648 to 2,147,483,647'; } else { var number = /^[-+]?(\d*)(?:\.(\d*))?$/.exec(text); if (!number || (number[1] === '' && !number[2])) { says = 'is no number'; } else if (number[1].replace(/^0+/, '').length > precision - scale) { says = 'has more whole digits than its precision of ' + precision + ' and scale of ' + scale + ' hold'; } else if ((number[2] || '').replace(/0+$/, '').length > scale) { says = 'has more decimals than its scale of ' + scale; isFatal = false; }; }; if (says) { var misfit = { value: firstLine(String(values[i]), 60), says: says, isFatal: isFatal }; if (isFatal) return misfit; if (!rounded) rounded = misfit; }; }; return rounded; }; function saveFieldWarnings(tab, model) { var found = []; model.fields.forEach(function (f, i) { var misfit = saveNumberMisfit(f, saveValuesOnScreen(tab, i)); if (misfit) { found.push((f.name.trim() || f.source) + ': a value on screen ' + misfit.says + ': "' + misfit.value + '". ' + (misfit.isFatal ? 'The move will fail.' : 'The move rounds it.')); return; }; if (f.type !== 'Text') return; var name = f.name.trim() || f.source; var longest = saveLongestOnScreen(tab, i); if (f.length.trim() === '') { found.push(name + ' has no length: any value fits. Use it only if you are sure values past ' + fmtNumber(SAVE_TEXT_MAX) + ' characters must fit. Such a field can slow queries on this Data Extension, long values such as whole e-mails or JSON count against the account\'s Data Extension storage limit and can use it up, and Contact Builder\'s record editor takes 255 characters at most in it.'); } else if (saveIsWhole(f.length, 1, SAVE_TEXT_MAX) && longest > parseInt(f.length, 10)) { found.push(name + ': values on screen run to ' + fmtNumber(longest) + ' characters, more than its length of ' + fmtNumber(parseInt(f.length, 10)) + ' holds. The move will fail.'); }; }); return found; }; function saveDraftName(sql) { var base = ''; try { var parsed = Tools.parse(sql, Schema.getIndex()); var first = parsed && parsed.from && parsed.from[0]; if (first && first.kind === 'table' && first.name && !first.isFunction) base = String(first.name); } catch (err) { base = ''; }; base = base.replace(/^ENT\.\s*/i, '').replace(/[\[\]]/g, '').replace(/[^A-Za-z0-9 _.-]/g, '_').replace(/^[_. ]+/, ''); if (!base) base = 'Results'; var today = new Date(); var pad = function (n) { return (n < 10 ? '0' : '') + n; }; var stamp = today.getFullYear() + '-' + pad(today.getMonth() + 1) + '-' + pad(today.getDate()); var name = base.slice(0, 100) + '_' + stamp; var taken = {}; Schema.deList.forEach(function (t) { if (t && t.name) taken[String(t.name).toLowerCase()] = true; }); var candidate = name; for (var n = 2; taken[candidate.toLowerCase()]; n++) candidate = name + '_' + n; return candidate; }; function saveCandidates(model, kind) { var types = SAVE_SENDABLE_TYPES[kind] || SAVE_SENDABLE_TYPES.subscriberKey; var found = []; model.fields.forEach(function (f, i) { if (types.indexOf(f.type) !== -1) found.push(i); }); return found; }; function saveSendableField(model) { var f = model.fields[model.sendable.fieldIndex]; if (!f) return null; return saveCandidates(model, model.sendable.kind).indexOf(model.sendable.fieldIndex) !== -1 ? f : null; }; function saveFolderOptions(folders, query, rootText) { var q = String(query || '').trim().toLowerCase(); var rootName = folders.root ? folders.root.name : (rootText || SAVE_ROOT_FOLDER_TEXT); var all = [{ id: '', name: rootName, parent: '', path: rootName }].concat(folders.choices.map(function (f) { return { id: String(f.id), name: f.name, parent: f.parent, path: f.path }; })); var byName = []; var byPath = []; all.forEach(function (option) { if (String(option.name).toLowerCase().indexOf(q) !== -1) { byName.push(option); } else if (String(option.path).toLowerCase().indexOf(q) !== -1) { byPath.push(option); }; }); var matches = byName.concat(byPath); return { options: matches.slice(0, DE_SECTION_ROW_LIMIT), more: Math.max(0, matches.length - DE_SECTION_ROW_LIMIT) }; }; function saveFolderPicker(model, opts) { opts = opts || {}; var read = opts.read || function () { return Schema.ownFolderChoices(); }; var idPrefix = opts.idPrefix || 'ss-save-folder'; var rootText = opts.rootText || SAVE_ROOT_FOLDER_TEXT; var folders = read(); var shown = { options: [], more: 0 }; var optionNodes = []; var activeIndex = -1; var isOpen = false; var isEdited = false; var isFocusing = false; var isOff = false; var isLoading = false; var isTouched = false; var input = h('input', { class: 'ss-input', type: 'text', id: idPrefix, 'data-save': 'folder', autocomplete: 'off', spellcheck: 'false', placeholder: 'Search folders...', role: 'combobox', 'aria-autocomplete': 'list', 'aria-expanded': 'false', 'aria-controls': idPrefix + '-list', onfocus: function () { input.select(); }, onclick: function () { if (isOpen) return; showList(); input.select(); }, oninput: function () { isEdited = true; showList(); }, onkeydown: onKeydown, onblur: function () { closeList(); showChosen(); }, onmousedown: function () { isFocusing = document.activeElement !== input; }, onmouseup: function (ev) { if (isFocusing) ev.preventDefault(); isFocusing = false; } }, null); var list = h('div', { class: 'ss-combo__list', id: idPrefix + '-list', role: 'listbox', 'aria-label': 'Folders' }, null); var note = h('div', { class: 'ss-tree__empty ss-combo__note', hidden: true }, null); var popup = h('div', { class: 'ss-list ss-list--picker ss-combo__popup', hidden: true, onmousedown: function (ev) { ev.preventDefault(); } }, [list, note]); function chosenPath() { if (model.folderId === '') return folders.root ? folders.root.name : rootText; var found = folders.choices.filter(function (f) { return String(f.id) === String(model.folderId); })[0]; return found ? found.path : (model.folderPath || ('Folder ' + model.folderId)); }; function showChosen() { isEdited = false; input.value = isLoading ? 'Loading folders...' : chosenPath(); input.setAttribute('data-folder-id', String(model.folderId)); }; function setActive(index) { activeIndex = index; optionNodes.forEach(function (node, i) { node.classList.toggle('is-active', i === index); }); if (index === -1) { input.removeAttribute('aria-activedescendant'); return; }; var node = optionNodes[index]; input.setAttribute('aria-activedescendant', node.id); if (node.offsetTop < popup.scrollTop) { popup.scrollTop = node.offsetTop; } else if (node.offsetTop + node.offsetHeight > popup.scrollTop + popup.clientHeight) { popup.scrollTop = node.offsetTop + node.offsetHeight - popup.clientHeight; }; }; function closeList() { isOpen = false; popup.hidden = true; input.setAttribute('aria-expanded', 'false'); setActive(-1); }; function pick(option) { isTouched = true; model.folderId = option.id; model.folderPath = option.path; showChosen(); closeList(); if (opts.onPick) opts.onPick(option); }; function showList() { if (input.disabled) return; isTouched = true; var isJustOpened = !isOpen; if (!isOpen) { folders = read(); isOpen = true; popup.hidden = false; input.setAttribute('aria-expanded', 'true'); }; var query = isEdited ? input.value.trim() : ''; shown = saveFolderOptions(folders, query, rootText); clearNode(list); optionNodes = shown.options.map(function (option, i) { var isChosen = option.id === String(model.folderId); var node = h('div', { class: 'ss-list-row ss-combo__option' + (isChosen ? ' is-selected' : ''), id: idPrefix + '-option-' + i, role: 'option', title: option.path, 'aria-selected': String(isChosen), 'data-folder-id': option.id, onclick: function () { pick(option); } }, h('div', { class: 'ss-list-row__main' }, [ h('div', { class: 'ss-list-row__title' }, option.name), option.parent ? h('div', { class: 'ss-list-row__meta' }, option.parent) : null ])); list.appendChild(node); return node; }); var noteText = ''; if (!shown.options.length) { noteText = 'No folders match "' + query + '".'; } else if (shown.more > 0) { noteText = shown.more + ' more. Keep typing to narrow the list.'; }; note.textContent = noteText; note.hidden = noteText === ''; var chosenIndex = query === '' ? shown.options.map(function (o) { return o.id; }).indexOf(String(model.folderId)) : -1; setActive(optionNodes.length ? Math.max(0, chosenIndex) : -1); if (isJustOpened && opts.isFullWidth) popup.scrollIntoView({ block: 'nearest' }); }; function onKeydown(ev) { if (ev.isComposing) return; if (isTextKey(ev)) return; var key = ev.key; if (key === 'ArrowDown' || key === 'ArrowUp') { ev.preventDefault(); if (!isOpen) { showList(); } else if (optionNodes.length) { setActive(Math.max(0, Math.min(optionNodes.length - 1, activeIndex + (key === 'ArrowDown' ? 1 : -1)))); }; } else if ((key === 'Home' || key === 'End') && isOpen && optionNodes.length) { ev.preventDefault(); setActive(key === 'Home' ? 0 : optionNodes.length - 1); } else if (isBareEnter(ev) && isOpen) { ev.preventDefault(); if (activeIndex !== -1) pick(shown.options[activeIndex]); } else if (key === 'Escape' && isOpen) { ev.preventDefault(); ev.stopPropagation(); closeList(); }; }; function setDisabled(isNowOff) { isOff = isNowOff; if (isOff) { closeList(); showChosen(); }; input.disabled = isOff || isLoading; }; function setLoading(isNowLoading) { isLoading = isNowLoading; if (isLoading) { closeList(); } else { folders = read(); }; showChosen(); input.disabled = isOff || isLoading; }; function adopt(option) { if (isTouched) return false; model.folderId = option.id; model.folderPath = option.path; folders = read(); showChosen(); return true; }; showChosen(); return { el: h('div', { class: 'ss-combo' + (opts.isFullWidth ? ' ss-combo--full' : '') }, [input, popup]), input: input, setDisabled: setDisabled, setLoading: setLoading, adopt: adopt }; }; function saveTypesText(types) { return types.length > 1 ? types.slice(0, -1).join(', ') + ' or ' + types[types.length - 1] : types[0]; }; function saveValidate(model) { var errors = { name: '', key: '', fields: [], retention: '', sendable: '', count: 0 }; var name = model.name.trim(); var isTaken = false; if (!model.created) { Schema.deList.forEach(function (t) { if (t && !t.isShared && String(t.name).toLowerCase() === name.toLowerCase()) isTaken = true; }); }; if (!name) errors.name = 'Enter a name.'; else if (name.length > SAVE_NAME_MAX) errors.name = 'A name takes ' + SAVE_NAME_MAX + ' characters at most.'; else if (name.charAt(0) === '_') errors.name = 'A name cannot start with an underscore.'; else if (saveHasControl(name)) errors.name = 'A name cannot hold a line break or another control character.'; else if (isTaken) errors.name = 'A Data Extension named ' + name + ' is already there.'; var key = model.key.trim(); if (key.length > SAVE_KEY_MAX) errors.key = 'A key takes ' + SAVE_KEY_MAX + ' characters at most.'; else if (saveHasControl(key)) errors.key = 'A key cannot hold a line break or another control character.'; else if (SAVE_TEMP_KEY_RE.test(key)) errors.key = 'This key is the shape of SQL Studio\'s temporary Data Extensions, which it sweeps. Choose another.'; var seen = {}; model.fields.forEach(function (f) { var lower = f.name.trim().toLowerCase(); if (lower) seen[lower] = (seen[lower] || 0) + 1; }); model.fields.forEach(function (f) { var e = { name: '', length: '', scale: '', defaultValue: '', key: '' }; var fieldName = f.name.trim(); if (!fieldName) e.name = 'Enter a name.'; else if (fieldName.length > SAVE_NAME_MAX) e.name = SAVE_NAME_MAX + ' characters at most.'; else if (saveHasControl(fieldName)) e.name = 'No control characters.'; else if (seen[fieldName.toLowerCase()] > 1) e.name = 'Another field has this name.'; else if (fieldName.toLowerCase() === SAVE_ROW_MARK_FIELD.toLowerCase()) e.name = 'SQL Studio uses this name itself.'; else if (model.mode === 'sql' && saveIsRenamed(f)) e.name = 'Keeps its column\'s name, ' + f.source + ', as the query runs again.'; if (f.type === 'Text' && f.length.trim() !== '' && !saveIsWhole(f.length, 1, SAVE_TEXT_MAX)) e.length = '1 to ' + fmtNumber(SAVE_TEXT_MAX) + '.'; if (f.type === 'Text' && f.isPrimaryKey && f.length.trim() === '') e.length = 'A primary key needs a length.'; var precision = SAVE_DECIMAL_DEFAULT_PRECISION; if (f.type === 'Decimal') { if (f.precision.trim() !== '' && !saveIsWhole(f.precision, 1, SAVE_DECIMAL_MAX_PRECISION)) { e.length = '1 to ' + SAVE_DECIMAL_MAX_PRECISION + '.'; } else if (f.precision.trim() !== '') { precision = parseInt(f.precision, 10); }; var maxScale = Math.min(precision, SAVE_DECIMAL_MAX_SCALE); if (f.scale.trim() !== '' && !saveIsWhole(f.scale, 0, maxScale)) e.scale = '0 to ' + maxScale + '.'; }; if (f.defaultValue.length > SAVE_DEFAULT_MAX) e.defaultValue = SAVE_DEFAULT_MAX + ' characters at most.'; else if (saveHasControl(f.defaultValue)) e.defaultValue = 'No control characters.'; else if (f.defaultValue !== '' && f.defaultValue.trim() === '') e.defaultValue = 'Not spaces alone.'; if (f.isPrimaryKey && f.isNullable) e.key = 'A primary key cannot be nullable.'; errors.fields.push(e); errors.count += (e.name ? 1 : 0) + (e.length ? 1 : 0) + (e.scale ? 1 : 0) + (e.defaultValue ? 1 : 0) + (e.key ? 1 : 0); }); if (model.retention.mode !== 'none' && !saveIsWhole(model.retention.period, 1, SAVE_PERIOD_MAX)) errors.retention = 'A period is a whole number from 1 to ' + SAVE_PERIOD_MAX + '.'; if (model.sendable.isOn) { var kind = model.sendable.kind; var relatesTo = kind === 'emailAddress' ? 'Email Address' : 'Subscriber Key'; if (!saveCandidates(model, kind).length) errors.sendable = 'No field is of type ' + saveTypesText(SAVE_SENDABLE_TYPES[kind]) + ', which the ' + relatesTo + ' needs. Change a field\'s type first.'; else if (!saveSendableField(model)) errors.sendable = 'Choose the field that holds the ' + relatesTo + '.'; }; errors.count += (errors.name ? 1 : 0) + (errors.key ? 1 : 0) + (errors.retention ? 1 : 0) + (errors.sendable ? 1 : 0); return errors; }; function saveCreatePayload(model) { var payload = { name: model.name.trim(), fields: model.fields.map(function (f) { var entry = { name: f.name.trim(), type: f.type, isPrimaryKey: f.isPrimaryKey, isNullable: f.isNullable }; if (f.type === 'Text' && f.length.trim() !== '') entry.maxLength = parseInt(f.length, 10); if (f.type === 'Decimal' && f.precision.trim() !== '') entry.precision = parseInt(f.precision, 10); if (f.type === 'Decimal' && f.scale.trim() !== '') entry.scale = parseInt(f.scale, 10); if (f.defaultValue !== '') entry.defaultValue = f.defaultValue; return entry; }) }; if (model.key.trim() !== '') payload.key = model.key.trim(); if (model.folderId !== '') payload.categoryId = Number(model.folderId); var mode = SAVE_RETENTION_MODES.filter(function (m) { return m.id === model.retention.mode; })[0]; if (mode && mode.id !== 'none') { payload.retention = { period: parseInt(model.retention.period, 10), unit: model.retention.unit, isRowBased: mode.isRowBased, isDataExtensionDeleted: mode.isDataExtensionDeleted, resetOnImport: mode.id === 'rows' && model.retention.reset === true }; }; if (model.sendable.isOn) { payload.sendable = { field: saveSendableField(model).name.trim(), subscriberField: model.sendable.kind }; }; return payload; }; function saveMoveColumns(model) { return model.fields.map(function (f) { var renamed = f.name.trim(); return renamed === f.source ? f.source : { name: f.source, as: renamed }; }); }; function saveIsRenamed(f) { return f.name.trim().toLowerCase() !== String(f.source).toLowerCase(); }; function saveUseColumnNames(model) { var hasChanged = false; model.fields.forEach(function (f) { if (f.name === f.source) return; f.name = f.source; hasChanged = true; }); return hasChanged; }; function saveIsFreeFollow(task) { return !!task.taskId && !!task.taskSig && task.via !== 'soap' && task.via !== 'none' && Session.isBackendBu(); }; function saveCheckWait(isFree, elapsed) { if (isFree) return elapsed < POLL_FREE_FAST_WINDOW_MS ? POLL_FREE_FAST_MS : POLL_FREE_SLOW_MS; return pollInterval(elapsed); }; function saveExpectedChecks(isFree) { var at = POLL_FAST_MS; var count = 1; while (at < SAVE_EXPECTED_MOVE_MS) { at += saveCheckWait(isFree, at); count++; }; return count; }; function saveCostText(model) { var isOwn = Session.isBackendBu(); var create = model.created ? 0 : SAVE_CREATE_API_CALLS; var check = (model.mode === 'from' && !isOwn) ? 1 : 0; var checks = saveExpectedChecks(isOwn); var api = create + check + (isOwn ? SAVE_MOVE_API_CALLS - 1 : SAVE_MOVE_API_CALLS + checks); var wsproxy = isOwn ? 1 + checks : 0; return (model.created ? 'Moving the rows takes about ' : 'Saving takes about ') + apiCallsText(api) + (wsproxy ? ' and ' + wsproxyCallsText(wsproxy) : '') + '.'; }; var SAVE_ENDED_UNREAD_TEXT = 'Marketing Cloud Engagement reports that the move has ended, and SQL Studio cannot read whether it worked. Check the Data Extension in Contact Builder.'; var SAVE_UNANSWERED_TEXT = 'The page was reloaded while the move\'s request was on its way, so SQL Studio cannot tell whether Marketing Cloud Engagement started the move.'; function saveUnconfirmed(reason, hasEnded) { var err = new Error(reason); err.isUnconfirmed = true; err.hasEnded = hasEnded === true; return err; }; function saveUnconfirmedText(name, error) { return 'The Data Extension ' + name + ' was created, but SQL Studio could not confirm that its rows were moved. ' + error.message + (error.hasEnded ? '' : ' The rows may still arrive.'); }; function saveCreateRefusedBy(error) { return (error && error.code === 'MCE_ERROR') ? 'Marketing Cloud Engagement' : 'SQL Studio'; }; function saveCreateUnknownText(name, error) { if (error.code === 'CREATED_NO_KEY') return saveSentence(error.message); if (error.code === 'CREATE_UNCONFIRMED') return saveSentence(error.message) + ' Check Contact Builder before you save again.'; return 'SQL Studio got no answer when it created the Data Extension ' + name + ', so it may have been made. ' + saveSentence(error.message) + ' Check Contact Builder before you save again.'; }; function saveSentence(text) { var said = String(text || 'No message').trim(); return /[.!?]$/.test(said) ? said : said + '.'; }; function saveMisfits(tab, model) { if (model && model.kind === 'existing') return saveExistingMisfits(tab, model, saveTargetFields(model)); if (!model || !Array.isArray(model.fields)) return []; var rows = (tab && tab.state && tab.state.results && Array.isArray(tab.state.results.rows)) ? tab.state.results.rows : []; var found = []; model.fields.forEach(function (f, i) { var name = f.name.trim() || f.source; if (f.type === 'Text' && f.length.trim() !== '') { var longest = saveLongestOnScreen(tab, i); var length = parseInt(f.length, 10); if (longest > length) found.push(name + ' has values of up to ' + fmtNumber(longest) + ' characters on screen, more than its length of ' + fmtNumber(length) + '.'); } else if (f.type === 'EmailAddress') { var odd = rows.map(function (row) { return Array.isArray(row) ? row[i] : null; }).filter(function (v) { return v !== null && v !== undefined && String(v) !== '' && !Tools.isEmailAddress(v); })[0]; if (odd !== undefined) found.push(name + ' has a value on screen that is no e-mail address: "' + firstLine(String(odd), 60) + '".'); } else { var misfit = saveNumberMisfit(f, rows.map(function (row) { return Array.isArray(row) ? row[i] : null; })); if (misfit && misfit.isFatal) found.push(name + ' has a value on screen that ' + misfit.says + ': "' + misfit.value + '".'); }; }); return found; }; function saveFailureText(taskStatus, tab, model) { var said = String((taskStatus && taskStatus.errorMsg) || '').trim(); if (said) return 'Marketing Cloud Engagement did not move the rows: ' + said; var noReason = 'Marketing Cloud Engagement did not move the rows and gives no reason (status ' + ((taskStatus && taskStatus.status) || 'unknown') + ').'; var misfits = saveMisfits(tab, model); if (misfits.length) return noReason + ' ' + misfits.join(' '); return noReason + ' A value that does not fit its field is the usual cause, such as an invalid address in an EmailAddress field.'; }; var SAVE_APP_DE_NAMES = ['sql studio auth log', 'sql studio error log', 'sqlstudiohistory', 'sqlstudiosnippets']; var SAVE_UPDATE_TYPES = [ { id: 'Append', line: 'Adds these rows to the rows it holds.' }, { id: 'Update', line: 'Updates the rows that share a primary key with these rows, and adds the others.' }, { id: 'Overwrite', line: 'Deletes every row it holds and puts these rows in their place.' } ]; var SAVE_EXISTING_MOVE_API_CALLS = SAVE_MOVE_API_CALLS + 1; var SAVE_COUNT_FRESH_MS = 60000; var SAVE_LEAVE_OUT_TEXT = 'Leave out'; var SAVE_NOTICE_PREFIX = 'Save to DE: '; function saveIsAppDataExtension(t) { var name = String(t && t.name ? t.name : '').trim().toLowerCase(); var key = String(t && t.key ? t.key : '').trim().toLowerCase(); return SAVE_APP_DE_NAMES.indexOf(name) !== -1 || SAVE_APP_DE_NAMES.indexOf(key) !== -1; }; function saveIsTargetOffered(t) { if (!t || !t.name || !t.key) return false; if (String(t.name).charAt(0) === '_') return false; if (Schema.isDataViewName(t.name)) return false; if (t.isSynchronized) return false; if (SAVE_TEMP_KEY_RE.test(String(t.key))) return false; if (saveIsAppDataExtension(t)) return false; return true; }; function saveTargetMatches(query) { var offered = Schema.deList.filter(saveIsTargetOffered); var text = String(query || '').trim(); if (text === '') return { tables: [], offered: offered }; return { tables: matchTargetTables(offered, text), offered: offered }; }; function saveTargetTable(model) { if (!model || !model.target) return null; var found = Schema.deList.filter(function (t) { return !!t && Schema.tableKey(t) === model.target.tableKey; }); return found.length ? found[0] : null; }; function saveTargetFields(model) { var table = saveTargetTable(model); return (table && Array.isArray(table.fields)) ? table.fields : null; }; function saveMatchColumns(columns, fields) { var taken = {}; var map = []; var isMatched = []; columns.forEach(function (column) { var field = saveFieldNamed(fields, column); var lower = field ? String(field.name).toLowerCase() : ''; if (field && !taken[lower]) { taken[lower] = true; map.push(String(field.name)); isMatched.push(true); } else { map.push(''); isMatched.push(false); }; }); return { map: map, isMatched: isMatched }; }; function saveEffectiveMap(model) { return model.map.map(function (field, i) { return (model.mode === 'sql' && !model.isMatched[i]) ? '' : field; }); }; function saveFieldTypeText(f) { var text = String(f.type || 'Text'); if (f.type === 'Text' && typeof f.length === 'number') text += ' (' + f.length + ')'; if (f.type === 'Decimal' && typeof f.length === 'number') text += ' (' + f.length + (typeof f.scale === 'number' ? ',' + f.scale : '') + ')'; return text; }; function saveRequiredFields(fields) { return fields.filter(function (f) { var hasDefault = f.defaultValue !== null && f.defaultValue !== undefined && f.defaultValue !== ''; return (f.isPrimaryKey || !f.isNullable) && !hasDefault; }); }; function saveMappedFields(model) { var mapped = {}; saveEffectiveMap(model).forEach(function (field) { if (field) mapped[field.toLowerCase()] = true; }); return mapped; }; function saveUpdateBlockReason(model, fields) { var keys = fields.filter(function (f) { return f.isPrimaryKey; }); if (!keys.length) return model.target.name + ' has no primary key, which Update needs.'; var mapped = saveMappedFields(model); var missing = keys.filter(function (f) { return !mapped[String(f.name).toLowerCase()]; }).map(function (f) { return f.name; }); if (missing.length) return 'Update needs a column for each primary key field. ' + (missing.length === 1 ? 'This one has none: ' : 'These have none: ') + missing.join(', ') + '.'; return ''; }; function saveSelfReadText(model) { if (model.mode !== 'sql' || !model.sql || !model.target || (model.updateType !== 'Append' && model.updateType !== 'Overwrite')) return ''; var key = [model.target.key, model.target.name, model.updateType].join('\n'); if (!model.selfRead || model.selfRead.key !== key) { var found = Tools.lint(model.sql, { schema: Schema.getIndex(), targetKey: model.target.key, targetName: model.target.name, targetAction: model.updateType, options: { disabled: DISABLED_LINT_RULES, isParentBusinessUnit: isParentBusinessUnit(), isSqlStudio: true } }).filter(function (f) { return f.id === 'MCE050' && f.severity === 'error'; }); model.selfRead = { key: key, text: found.length ? 'The query runs again into ' + model.target.name + ' and also reads it. MCE refuses that with ' + model.updateType + ': Update Type must be \'Update\' or the target data extension \'' + model.target.name + '\' cannot appear in the query. Choose Update, or run the query again and save its results.' : '' }; }; return model.selfRead.text; }; function saveSqlErrors(model) { if (model.mode !== 'sql' || !model.sql) { return { key: '', errors: [] }; }; var target = { key: null, name: null, action: null }; if (model.kind === 'existing' && model.target) { target = { key: model.target.key, name: model.target.name, action: model.updateType }; }; var key = JSON.stringify([model.sql, target.key, target.name, target.action]); if (!model.sqlErrors || model.sqlErrors.key !== key) { model.sqlErrors = { key: key, errors: saveLintErrors(model.sql, target).map(function (f) { return { id: f.id, message: f.message }; }) }; }; return model.sqlErrors; }; function saveExistingChecks(model, fields) { var name = model.target.name; var map = saveEffectiveMap(model); var mapped = saveMappedFields(model); var matchedCount = map.filter(function (field) { return !!field; }).length; var errors = []; var rowErrors = {}; if (model.mode === 'sql') { var unmatched = []; model.columns.forEach(function (column, i) { if (map[i]) return; unmatched.push(column); rowErrors[i] = 'No field of ' + name + ' has this name.'; }); if (unmatched.length) { errors.push({ id: 'sql-columns', text: 'The query runs again into ' + name + ', so each of its columns must match a field by name. No field matches: ' + unmatched.join(', ') + '.' }); }; }; if (matchedCount === 0 && !errors.length) { errors.push({ id: 'no-match', text: 'No column goes to a field of ' + name + '. Match at least one column to one of its fields.' }); }; var missing = saveRequiredFields(fields).filter(function (f) { return !mapped[String(f.name).toLowerCase()]; }).map(function (f) { return f.name; }); if (missing.length) { errors.push({ id: 'required', text: (missing.length === 1 ? 'The field ' + missing[0] + ' cannot be empty and has no default' : 'The fields ' + missing.join(', ') + ' cannot be empty and have no default') + ', so Marketing Cloud Engagement would fail the move. Give ' + (missing.length === 1 ? 'it a column.' : 'each a column.') }); }; var selfRead = saveSelfReadText(model); if (selfRead) { errors.push({ id: 'self-read', text: selfRead }); }; return { errors: errors, rowErrors: rowErrors }; }; function saveExistingMisfits(tab, model, fields) { if (!model || !fields || !Array.isArray(model.map)) return []; var rows = (tab && tab.state && tab.state.results && Array.isArray(tab.state.results.rows)) ? tab.state.results.rows : []; var map = saveEffectiveMap(model); var found = []; model.columns.forEach(function (column, i) { if (!map[i]) return; var field = saveFieldNamed(fields, map[i]); if (!field) return; var values = rows.map(function (row) { return Array.isArray(row) ? row[i] : null; }).filter(function (v) { return v !== null && v !== undefined && String(v) !== ''; }).map(function (v) { return String(v); }); var odd; if (field.type === 'Text' && typeof field.length === 'number') { var longest = saveLongestOnScreen(tab, i); if (longest > field.length) found.push(column + ': values on screen run to ' + fmtNumber(longest) + ' characters, more than ' + field.name + ' holds (' + fmtNumber(field.length) + ').'); } else if (field.type === 'EmailAddress') { odd = values.filter(function (v) { return !Tools.isEmailAddress(v); })[0]; if (odd !== undefined) found.push(column + ': a value on screen is no e-mail address, and ' + field.name + ' takes only those: "' + firstLine(odd, 60) + '".'); } else if (field.type === 'Number' || field.type === 'Decimal') { odd = values.filter(function (v) { return !/^[-+]?(\d+\.?\d*|\.\d+)$/.test(v.trim()); })[0]; if (odd !== undefined) found.push(column + ': a value on screen is no number, and ' + field.name + ' takes only numbers: "' + firstLine(odd, 60) + '".'); } else if (field.type === 'Date') { odd = values.filter(function (v) { return isNaN(Date.parse(v)); })[0]; if (odd !== undefined) found.push(column + ': a value on screen is no date, and ' + field.name + ' takes only dates: "' + firstLine(odd, 60) + '".'); }; }); return found; }; function saveExistingMoveColumns(model) { var map = saveEffectiveMap(model); var columns = []; model.columns.forEach(function (column, i) { if (!map[i]) return; columns.push(map[i] === column ? column : { name: column, as: map[i] }); }); return columns; }; function saveExistingCostText(model) { var isOwn = Session.isBackendBu(); var check = (model.mode === 'from' && !isOwn) ? 1 : 0; var checks = saveExpectedChecks(isOwn); var moveCalls = isOwn ? SAVE_EXISTING_MOVE_API_CALLS - 1 : SAVE_EXISTING_MOVE_API_CALLS + checks; var fieldCalls = (model.spent && model.spent.fields) ? model.spent.fields : 0; var countCalls = (model.spent && model.spent.count) ? model.spent.count : 0; var api = check + moveCalls + fieldCalls + countCalls; var wsproxy = isOwn ? 1 + checks : 0; var included = []; if (fieldCalls) included.push(fmtNumber(fieldCalls) + ' to load its fields'); if (countCalls) included.push(fmtNumber(countCalls) + ' to count its rows'); return 'Saving takes about ' + apiCallsText(api) + (wsproxy ? ' and ' + wsproxyCallsText(wsproxy) : '') + (included.length ? ', including ' + included.join(' and ') : '') + '.'; }; function saveOverwriteQuestion(model) { var name = model.target.name; var held = model.count.state === 'known' ? 'the ' + fmtRows(model.count.value) + ' ' + name + ' holds' : 'every row ' + name + ' holds'; var put = model.mode === 'sql' ? 'the rows the query returns now' : 'the ' + fmtRows(model.rowCount) + ' of this run'; return 'Overwrite deletes ' + held + ', and puts ' + put + ' in their place.'; }; function saveDoneText(updateType, name, rowCount) { var what = typeof rowCount === 'number' ? fmtRows(rowCount) : 'the query\'s results'; if (updateType === 'Append') return 'Added ' + what + ' to ' + name + '.'; if (updateType === 'Update') return 'Updated ' + name + ' from ' + what + '.'; return 'Replaced the rows of ' + name + ' with ' + what + '.'; }; function saveExistingUnconfirmedText(name, updateType, error) { return 'SQL Studio could not confirm that its rows were moved into ' + name + '. ' + error.message + (error.hasEnded ? '' : ' The rows may still arrive.') + (updateType === 'Append' ? ' Check ' + name + ' in Contact Builder before you save again, as another Append would add them twice.' : ''); }; function saveMoveMessage(error) { return error.hint ? saveSentence(error.message) + ' ' + error.hint : error.message; }; function saveExistingFailedText(name, message, isRefused) { if (isRefused) return 'The rows were not moved into ' + name + '. ' + saveSentence(message); return 'The move into ' + name + ' failed. ' + saveSentence(message) + ' Check what ' + name + ' holds in Contact Builder before you save again.'; }; var SaveToDe = { blockReason: function (tab) { if (!tab) return 'Run a query first.'; if (isRunInFlight(tab.state.runnerState)) return 'Save to DE is off while this tab runs a query.'; if (tab.activeExport) return 'Save to DE is off while this tab exports its results.'; if (tab.activeSave) return 'Save to DE is off while this tab saves its results.'; if (!tab.state.activeRun || !tab.state.results) return 'Run a query first. Save to DE saves its results.'; return ''; }, _newModel: function (tab) { var run = tab.state.activeRun; var sql = tab.runner.currentRunSql || ''; var sources = saveSourceFields(sql, run.columns.length); var drafted = saveDraftedTypes(sql, run.columns.length); return { runKey: run.deKey, rowCount: tab.state.results ? tab.state.results.count : 0, sql: sql, name: saveDraftName(sql), key: '', folderId: '', folderPath: '', isFolderSettled: false, fields: run.columns.map(function (column, i) { return saveDraftField(column, saveDraftColumnType(tab, (run.columnTypes || [])[i] || null, drafted, sources[i], i), sources[i], saveLongestOnScreen(tab, i)); }), retention: { mode: 'none', period: '30', unit: 'Days', reset: false }, sendable: { isOn: false, kind: 'subscriberKey', fieldIndex: -1 }, mode: 'from', checkState: 'pending', namesReset: false, created: null, error: null, view: null }; }, _settleFolder: function (model, picker) { if (model.isFolderSettled !== false || !Schema.hasDeFolders()) return; model.isFolderSettled = true; var saved = readSavedFolder(); if (saved === null) return; var found = Schema.ownFolderChoices().choices.filter(function (f) { return String(f.id) === saved; })[0]; if (!found) { writeSavedFolder(null); return; }; var option = { id: String(found.id), path: found.path }; if (picker) { picker.adopt(option); } else { model.folderId = option.id; model.folderPath = option.path; }; }, _check: function (tab, model, owner) { var known = tab.state.results && typeof tab.state.results.count === 'number' ? tab.state.results.count : null; var send = function (payload) { return Api.call('getResults', payload, owner); }; var columns = model.kind === 'existing' ? model.columns.slice() : model.fields.map(function (f) { return f.source; }); model.checkState = 'pending'; if (model.view) model.view.refresh(); return readCheckedPage(send, { deKey: model.runKey, page: 1, pageSize: 1, columns: columns }, known, 'The check before saving', send).then(function () { model.mode = 'from'; }, function (err) { if (isDataExtensionGoneError(err)) { model.mode = model.sql ? 'sql' : 'none'; if (model.mode === 'sql' && model.kind !== 'existing' && !model.created && saveUseColumnNames(model)) model.namesReset = true; } else { model.mode = 'from'; }; }).then(function () { model.checkState = 'done'; if (model.view) model.view.refresh(); }); }, _stoppedErrors: function (model) { if (!model.lintStop) return []; var found = saveSqlErrors(model); if (found.errors.length && model.lintStop.key === found.key) return found.errors; model.lintStop = null; return []; }, _stopsForLint: function (model) { var found = saveSqlErrors(model); if (!found.errors.length) { model.lintStop = null; return false; }; if (!model.lintStop || model.lintStop.key !== found.key) { model.lintStop = { key: found.key, at: Date.now() }; return true; }; return Date.now() - model.lintStop.at < SAVE_GATE_CLICK_GAP_MS; }, _lintStopAfterCheck: function (model) { var found = saveSqlErrors(model); if (!found.errors.length || (model.lintStop && model.lintStop.key === found.key)) return ''; model.lintStop = { key: found.key, at: Date.now() }; return 'The temporary Data Extension of these results is gone, so SQL Studio saved nothing yet. Saving runs the query again, and the query has ' + found.errors.length + (found.errors.length === 1 ? ' error' : ' errors') + ': open Save to DE to see ' + (found.errors.length === 1 ? 'it' : 'them') + '.'; }, open: function () { var tab = QueryTabs.active(); var reason = this.blockReason(tab); if (reason) { Toast.show(reason, 'info'); return; }; var model = tab.saveModel; if (!model || model.runKey !== tab.state.activeRun.deKey) { model = this._newModel(tab); tab.saveModel = model; }; this._openOverlay(tab, model); }, _newExistingModel: function (tab) { var run = tab.state.activeRun; return { kind: 'existing', runKey: run.deKey, rowCount: tab.state.results ? tab.state.results.count : 0, sql: tab.runner.currentRunSql || '', columns: run.columns.slice(), query: '', target: null, fieldsState: 'ready', fieldsError: '', updateType: null, map: [], isMatched: [], mapKey: null, count: { state: 'idle', value: null, at: 0 }, spent: { fields: 0, count: 0 }, mode: 'from', checkState: 'pending', error: null, view: null }; }, _openOverlay: function (tab, newModel) { var self = this; var forms = {}; var checked = {}; var gate = lintErrorsBox(); var canChooseExisting = !!(Session.data && Session.data.existingTargetsEnabled) || (tab.saveChoice === 'existing' && !!tab.activeSave); var choice = canChooseExisting && tab.saveChoice === 'existing' ? 'existing' : 'new'; var modal = null; var shell = { close: function () { modal.close(); } }; var labels = { 'new': { title: 'Save to a new Data Extension', name: 'New Data Extension', line: 'Creates it and moves these rows into it.' }, 'existing': { title: 'Save to an existing Data Extension', name: 'Existing Data Extension', line: 'Adds to it, updates it or replaces its rows.' } }; var inputs = {}; var cards = {}; var choiceLabelId = 'ss-save-choice-' + uid(); function modelOf(kind) { if (kind === 'new') return newModel; var model = tab.existingModel; if (!model || model.runKey !== tab.state.activeRun.deKey) { model = self._newExistingModel(tab); tab.existingModel = model; }; return model; }; function syncChoice() { ['new', 'existing'].forEach(function (kind) { inputs[kind].checked = choice === kind; inputs[kind].disabled = !!tab.activeSave; cards[kind].classList.toggle('is-selected', choice === kind); cards[kind].classList.toggle('is-disabled', !!tab.activeSave); }); var title = modal ? modal.dialog.querySelector('.ss-modal__title') : null; if (title) title.textContent = labels[choice].title; }; function mount(kind) { choice = kind; tab.saveChoice = kind; var model = modelOf(kind); var form = forms[kind]; if (!form) { form = kind === 'new' ? self._buildNewForm(tab, model, shell) : self._buildExistingForm(tab, model, shell); form.model = model; form.view.gate = gate; var drawForm = form.view.refresh; form.view.refresh = function () { drawForm(); syncChoice(); }; forms[kind] = form; }; var other = forms[kind === 'new' ? 'existing' : 'new']; if (other && other.model.view === other.view) other.model.view = null; model.view = form.view; form.view.modal = modal; clearNode(modal.body); appendChildren(modal.body, (canChooseExisting ? [choiceBar] : []).concat(form.body)); var footer = modal.dialog.querySelector('.ss-modal__footer'); clearNode(footer); appendChildren(footer, form.footer); if (!checked[kind]) { checked[kind] = true; if (kind === 'new' ? !model.created : !tab.activeSave) self._check(tab, model); }; form.view.refresh(); }; var choiceBar = h('div', { class: 'ss-field ss-save__choice' }, [ h('div', { class: 'ss-label', id: choiceLabelId }, 'Save the rows to'), h('div', { class: 'ss-radio-group ss-radio-group--row', role: 'radiogroup', 'aria-labelledby': choiceLabelId }, ['new', 'existing'].map(function (kind) { var input = h('input', { type: 'radio', name: 'ss-save-choice', value: kind, 'data-save': 'choice', onchange: function () { if (input.checked && choice !== kind) mount(kind); } }, null); var card = h('label', { class: 'ss-radio-option' }, [input, h('strong', null, labels[kind].name), h('span', null, labels[kind].line)]); inputs[kind] = input; cards[kind] = card; return card; })) ]); modal = UI.openModal({ title: labels[choice].title, xwide: true, body: [], footer: [], onClose: function () { if (newModel.view) newModel.view = null; if (tab.existingModel && tab.existingModel.view) tab.existingModel.view = null; UI._saveTargetRefresh = null; UI._saveFolderRefresh = null; } }); modal.dialog.insertBefore(gate.el, modal.dialog.querySelector('.ss-modal__footer')); mount(choice); }, _buildNewForm: function (tab, model, shell) { var self = this; var view = { rows: [], hint: h('p', { class: 'ss-hint', 'data-save': 'hint' }, null), notice: h('div', { class: 'ss-save__notice', 'aria-live': 'polite' }, null), warnings: h('div', { class: 'ss-save__warnings', 'data-save': 'warnings' }, null), cost: h('p', { class: 'ss-save__cost' }, null), fix: h('p', { class: 'ss-save__fix' }, null) }; var nameErr = h('div', { class: 'ss-save__error' }, null); var keyErr = h('div', { class: 'ss-save__error' }, null); var nameInput = h('input', { class: 'ss-input', type: 'text', id: 'ss-save-name', 'data-save': 'name', autocomplete: 'off', spellcheck: 'false', oninput: function (ev) { model.name = ev.target.value; view.refresh(); } }, null); var keyInput = h('input', { class: 'ss-input', type: 'text', id: 'ss-save-key', 'data-save': 'key', autocomplete: 'off', spellcheck: 'false', placeholder: 'Optional: Marketing Cloud Engagement makes one', oninput: function (ev) { model.key = ev.target.value; view.refresh(); } }, null); self._settleFolder(model); var folderPicker = saveFolderPicker(model, { onPick: function () { model.isFolderSettled = true; } }); if (model.isFolderSettled === false) UI._saveFolderRefresh = function () { self._settleFolder(model, folderPicker); }; nameInput.value = model.name; keyInput.value = model.key; var tbody = h('tbody', null, null); function buildRow(f, i) { var label = ' of ' + f.source; var row = {}; row.nameInput = h('input', { class: 'ss-input', type: 'text', 'aria-label': 'Field name' + label, 'data-save-field': String(i), 'data-save': 'field-name', autocomplete: 'off', spellcheck: 'false', oninput: function (ev) { f.name = ev.target.value; view.refresh(); } }, null); row.nameErr = h('div', { class: 'ss-save__error' }, null); row.typeSelect = h('select', { class: 'ss-select', 'aria-label': 'Type' + label, 'data-save-field': String(i), 'data-save': 'field-type', onchange: function (ev) { f.type = ev.target.value; if (f.type === 'Text' && f.length.trim() === '') f.length = saveTextLengthOf(saveLongestOnScreen(tab, i)); if (f.type === 'Decimal') { if (f.precision.trim() === '') f.precision = String(SAVE_DECIMAL_DEFAULT_PRECISION); if (f.scale.trim() === '') f.scale = String(SAVE_DECIMAL_DEFAULT_SCALE); }; row.lengthInput.value = f.type === 'Decimal' ? f.precision : f.length; row.scaleInput.value = f.scale; view.refresh(); } }, SAVE_FIELD_TYPES.map(function (t) { return h('option', { value: t }, t); })); row.lengthInput = h('input', { class: 'ss-input ss-save__number', type: 'text', inputmode: 'numeric', 'data-save-field': String(i), 'data-save': 'field-length', oninput: function (ev) { if (f.type === 'Decimal') { f.precision = ev.target.value; } else { f.length = ev.target.value; }; view.refresh(); } }, null); row.lengthErr = h('div', { class: 'ss-save__error' }, null); row.scaleInput = h('input', { class: 'ss-input ss-save__number', type: 'text', inputmode: 'numeric', 'aria-label': 'Scale' + label, 'data-save-field': String(i), 'data-save': 'field-scale', oninput: function (ev) { f.scale = ev.target.value; view.refresh(); } }, null); row.scaleErr = h('div', { class: 'ss-save__error' }, null); row.pk = h('input', { type: 'checkbox', 'aria-label': 'Primary key' + label, 'data-save-field': String(i), 'data-save': 'field-pk', onchange: function (ev) { f.isPrimaryKey = ev.target.checked; if (f.isPrimaryKey) f.isNullable = false; view.refresh(); } }, null); row.nullable = h('input', { type: 'checkbox', 'aria-label': 'Nullable' + label, 'data-save-field': String(i), 'data-save': 'field-nullable', onchange: function (ev) { f.isNullable = ev.target.checked; view.refresh(); } }, null); row.defaultInput = h('input', { class: 'ss-input', type: 'text', 'aria-label': 'Default value' + label, 'data-save-field': String(i), 'data-save': 'field-default', autocomplete: 'off', spellcheck: 'false', oninput: function (ev) { f.defaultValue = ev.target.value; view.refresh(); } }, null); row.defaultErr = h('div', { class: 'ss-save__error' }, null); row.nameInput.value = f.name; row.typeSelect.value = f.type; row.lengthInput.value = f.type === 'Decimal' ? f.precision : f.length; row.scaleInput.value = f.scale; row.defaultInput.value = f.defaultValue; row.tr = h('tr', null, [ h('td', { class: 'ss-save__source', title: f.source }, f.source), h('td', null, [row.nameInput, row.nameErr]), h('td', null, row.typeSelect), h('td', null, [row.lengthInput, row.lengthErr]), h('td', null, [row.scaleInput, row.scaleErr]), h('td', { class: 'ss-save__check' }, h('span', { class: 'ss-save__check-box' }, row.pk)), h('td', { class: 'ss-save__check' }, h('span', { class: 'ss-save__check-box' }, row.nullable)), h('td', null, [row.defaultInput, row.defaultErr]) ]); return row; }; model.fields.forEach(function (f, i) { var row = buildRow(f, i); view.rows.push(row); tbody.appendChild(row.tr); }); var table = h('table', { class: 'ss-save__table', 'aria-label': 'Fields of the new Data Extension' }, [ h('thead', null, h('tr', null, [ h('th', null, 'Result column'), h('th', null, 'Field name'), h('th', null, 'Type'), h('th', { title: 'The length of a Text field, or the precision of a Decimal' }, 'Length'), h('th', { title: 'The digits after the point of a Decimal' }, 'Scale'), h('th', { class: 'ss-save__check' }, 'Key'), h('th', { class: 'ss-save__check' }, 'Null'), h('th', null, 'Default') ])), tbody ]); var modeSelect = h('select', { class: 'ss-select', 'aria-label': 'Retention', 'data-save': 'retention-mode', onchange: function (ev) { model.retention.mode = ev.target.value; view.refresh(); } }, SAVE_RETENTION_MODES.map(function (m) { return h('option', { value: m.id }, m.label); })); var periodInput = h('input', { class: 'ss-input ss-save__number', type: 'text', inputmode: 'numeric', 'aria-label': 'Retention period', 'data-save': 'retention-period', oninput: function (ev) { model.retention.period = ev.target.value; view.refresh(); } }, null); var unitSelect = h('select', { class: 'ss-select', 'aria-label': 'Retention unit', 'data-save': 'retention-unit', onchange: function (ev) { model.retention.unit = ev.target.value; } }, SAVE_RETENTION_UNITS.map(function (u) { return h('option', { value: u }, u.toLowerCase()); })); var resetBox = h('input', { type: 'checkbox', 'data-save': 'retention-reset', onchange: function (ev) { model.retention.reset = ev.target.checked; } }, null); var resetLabel = h('label', { class: 'ss-save__inline' }, [resetBox, ' Start the period again when rows are imported']); var retentionErr = h('div', { class: 'ss-save__error' }, null); modeSelect.value = model.retention.mode; periodInput.value = model.retention.period; unitSelect.value = model.retention.unit; resetBox.checked = model.retention.reset === true; var sendBox = h('input', { type: 'checkbox', 'data-save': 'sendable', onchange: function (ev) { model.sendable.isOn = ev.target.checked; if (model.sendable.isOn) self._pickSendableField(model); view.refresh(); } }, null); var kindSelect = h('select', { class: 'ss-select', 'aria-label': 'Relates to', 'data-save': 'sendable-kind', onchange: function (ev) { model.sendable.kind = ev.target.value; self._pickSendableField(model); view.refresh(); } }, [h('option', { value: 'subscriberKey' }, 'Subscriber Key'), h('option', { value: 'emailAddress' }, 'Email Address')]); var sendFieldSelect = h('select', { class: 'ss-select', 'aria-label': 'Sendable field', 'data-save': 'sendable-field', onchange: function (ev) { model.sendable.fieldIndex = ev.target.value === '' ? -1 : parseInt(ev.target.value, 10); view.refresh(); } }, null); var sendErr = h('div', { class: 'ss-save__error' }, null); var sendNote = h('div', { class: 'ss-save__note', 'data-save': 'sendable-note' }, null); sendBox.checked = model.sendable.isOn; kindSelect.value = model.sendable.kind; var relatesText = h('span', null, 'relates to Subscribers on'); var sendRow = h('div', { class: 'ss-save__line' }, [ h('label', { class: 'ss-save__inline' }, [sendBox, ' Make it sendable.']), sendFieldSelect, relatesText, kindSelect ]); var retentionRow = h('div', { class: 'ss-save__line' }, [modeSelect, periodInput, unitSelect, resetLabel]); var movingNote = h('p', { class: 'ss-save__moving', role: 'status', 'data-save': 'moving-note' }, null); var cancelBtn = h('button', { class: 'ss-btn', type: 'button', onclick: function () { shell.close(); } }, 'Cancel'); var startOverBtn = h('button', { class: 'ss-btn ss-btn--link', type: 'button', title: 'Leave the Data Extension that was created where it is, and set up another one.', onclick: function () { model.created = null; model.error = null; if (model.mode === 'sql' && saveUseColumnNames(model)) model.namesReset = true; view.refresh(); } }, 'Save to a new Data Extension instead'); var primaryBtn = h('button', { class: 'ss-btn ss-btn--primary ss-save__primary', type: 'button', 'data-autofocus': true, 'data-save': 'primary', onclick: function () { if (primaryBtn.disabled) return; if (self._stopsForLint(model)) { view.refresh(); return; }; self._start(tab, model); } }, null); function setControlsDisabled(isOff) { [nameInput, keyInput, modeSelect, periodInput, unitSelect, resetBox, sendBox, kindSelect, sendFieldSelect].forEach(function (c) { c.disabled = isOff; }); folderPicker.setDisabled(isOff); view.rows.forEach(function (r) { [r.nameInput, r.typeSelect, r.lengthInput, r.scaleInput, r.pk, r.nullable, r.defaultInput].forEach(function (c) { c.disabled = isOff; }); }); }; view.refresh = function () { if (model.sendable.fieldIndex !== -1 && !saveSendableField(model)) model.sendable.fieldIndex = -1; var errors = saveValidate(model); view.hint.textContent = model.mode === 'sql' ? 'SQL Studio creates the Data Extension in Marketing Cloud Engagement, then runs the query again into it.' : 'SQL Studio creates the Data Extension in Marketing Cloud Engagement, then moves the ' + fmtRows(model.rowCount) + ' of these results into it.'; var isBusy = !!tab.activeSave; var isLocked = isBusy || !!model.created; var isNameLocked = model.mode === 'sql'; setControlsDisabled(isLocked); var mark = function (control, message, errorNode) { control.classList.toggle('is-invalid', !!message); control.setAttribute('aria-invalid', message ? 'true' : 'false'); if (errorNode) errorNode.textContent = message; }; mark(nameInput, errors.name, nameErr); mark(keyInput, errors.key, keyErr); clearNode(view.warnings); if (!model.created) { appendChildren(view.warnings, saveFieldWarnings(tab, model).map(function (text) { return h('div', { class: 'ss-message-item ss-message-item--warning', 'data-save': 'warning' }, text); })); }; model.fields.forEach(function (f, i) { var r = view.rows[i]; var e = errors.fields[i]; var isDecimal = f.type === 'Decimal'; var isText = f.type === 'Text'; if (r.nameInput.value !== f.name && document.activeElement !== r.nameInput) r.nameInput.value = f.name; r.nameInput.disabled = isLocked || isNameLocked; r.nameInput.title = isNameLocked ? 'The query runs again into the Data Extension, so each field takes its column\'s name.' : ''; mark(r.nameInput, e.name, r.nameErr); mark(r.lengthInput, e.length, r.lengthErr); mark(r.scaleInput, e.scale, r.scaleErr); mark(r.defaultInput, e.defaultValue, r.defaultErr); r.lengthInput.style.visibility = (isText || isDecimal) ? '' : 'hidden'; r.scaleInput.style.visibility = isDecimal ? '' : 'hidden'; r.lengthInput.setAttribute('aria-label', (isDecimal ? 'Precision' : 'Length') + ' of ' + f.source); r.lengthInput.placeholder = isText ? 'None' : ''; r.lengthInput.disabled = isLocked || !(isText || isDecimal); r.scaleInput.disabled = isLocked || !isDecimal; r.pk.checked = f.isPrimaryKey; r.nullable.checked = f.isNullable; r.nullable.disabled = isLocked || f.isPrimaryKey; r.nullable.title = f.isPrimaryKey ? 'A primary key is never nullable.' : ''; r.tr.classList.toggle('has-error', !!(e.name || e.length || e.scale || e.defaultValue || e.key)); }); var isRetained = model.retention.mode !== 'none'; periodInput.style.display = isRetained ? '' : 'none'; unitSelect.style.display = isRetained ? '' : 'none'; resetLabel.style.display = model.retention.mode === 'rows' ? '' : 'none'; mark(periodInput, errors.retention, null); retentionErr.textContent = errors.retention; var chosen = saveSendableField(model); clearNode(sendFieldSelect); if (!chosen) sendFieldSelect.appendChild(h('option', { value: '' }, 'Choose a field')); saveCandidates(model, model.sendable.kind).forEach(function (i) { var f = model.fields[i]; sendFieldSelect.appendChild(h('option', { value: String(i) }, f.name.trim() || f.source)); }); sendFieldSelect.value = chosen ? String(model.sendable.fieldIndex) : ''; sendFieldSelect.style.display = model.sendable.isOn ? '' : 'none'; relatesText.style.display = model.sendable.isOn ? '' : 'none'; kindSelect.style.display = model.sendable.isOn ? '' : 'none'; sendErr.textContent = model.sendable.isOn ? errors.sendable : ''; sendNote.textContent = (model.sendable.isOn && model.sendable.kind === 'emailAddress') ? 'Most accounts have the Subscriber Key business rule on, and refuse a relationship to the Email Address.' : ''; self._drawFooter(tab, model, view, errors, { movingNote: movingNote, cancelBtn: cancelBtn, startOverBtn: startOverBtn, primaryBtn: primaryBtn }); }; var body = [ view.notice, view.hint, h('div', { class: 'ss-save__top' }, [ h('div', { class: 'ss-field' }, [h('label', { class: 'ss-label', for: 'ss-save-name' }, 'Name'), nameInput, nameErr]), h('div', { class: 'ss-field' }, [h('label', { class: 'ss-label', for: 'ss-save-key' }, 'External key'), keyInput, keyErr]), h('div', { class: 'ss-field' }, [h('label', { class: 'ss-label', for: 'ss-save-folder' }, 'Folder'), folderPicker.el]) ]), h('div', { class: 'ss-label' }, 'Fields'), h('div', { class: 'ss-save__table-wrap' }, table), view.warnings, h('div', { class: 'ss-field ss-save__group' }, [h('div', { class: 'ss-label' }, 'Retention'), retentionRow, retentionErr]), h('div', { class: 'ss-field ss-save__group' }, [h('div', { class: 'ss-label' }, 'Send relationship (optional)'), sendRow, sendNote, sendErr]), view.fix ]; return { view: view, body: body, footer: [view.cost, movingNote, cancelBtn, startOverBtn, primaryBtn] }; }, _buildExistingForm: function (tab, model, shell) { var self = this; var view = { rows: [], mapFor: null, hint: h('p', { class: 'ss-hint', 'data-save': 'hint' }, null), notice: h('div', { class: 'ss-save__notice', 'aria-live': 'polite' }, null), checks: h('div', { class: 'ss-save__warnings', 'data-save': 'checks' }, null), warnings: h('div', { class: 'ss-save__warnings', 'data-save': 'warnings' }, null), cost: h('p', { class: 'ss-save__cost' }, null), fix: h('p', { class: 'ss-save__fix', 'data-save': 'fix' }, null) }; var targetSearch = h('input', { class: 'ss-input', type: 'search', id: 'ss-save-target-search', 'data-save': 'target-search', placeholder: 'Search Data Extensions…', 'data-autofocus': true, oninput: function (ev) { model.query = ev.target.value; targetCombo.reset(); drawTargets(); } }, null); var targetList = h('div', { class: 'ss-list ss-list--picker ss-save__targets', 'data-save': 'target-list' }, null); var targetCombo = targetCombobox({ input: targetSearch, list: targetList, idPrefix: 'ss-save-target', label: 'Matching Data Extensions', hasQuery: hasTargetQuery, draw: drawTargets, pick: pick }); var targetNote = h('p', { class: 'ss-hint ss-save__targets-note' }, 'Synchronized Data Extensions are not listed, as they are read-only. Nor are Marketing Cloud Engagement\'s own tables, whose names start with an underscore, or SQL Studio\'s own Data Extensions, which hold its runs, history, snippets and logs.'); targetSearch.value = model.query; function hasTargetQuery() { return model.query.trim() !== ''; }; function pick(table) { if (tab.activeSave) return; self._pickTarget(tab, model, table); model.query = ''; targetSearch.value = ''; targetCombo.reset(); drawTargets(); view.refresh(); }; function loadList() { Schema.loadDataExtensionList().then(drawTargets, drawTargets); }; function drawTargets() { clearNode(targetList); targetList.removeAttribute('aria-busy'); targetList.classList.add('is-closed'); targetCombo.start(); drawTargetBody(model.query.trim()); targetCombo.finish(); }; function drawTargetBody(query) { if (Schema.isDeListLoading()) { targetList.setAttribute('aria-busy', 'true'); targetList.appendChild(h('div', { class: 'ss-tree__loading' }, [spinner(), " Loading this Business Unit's Data Extensions..."])); return; }; var loadError = Schema.deListError(); if (loadError) { targetList.appendChild(h('div', { class: 'ss-hint' }, [ 'Could not load Data Extensions: ' + loadError.message + ' ', h('button', { class: 'ss-tree__link', type: 'button', onclick: loadList }, 'Retry') ])); return; }; var folderPaths = Schema.deFolderPaths(); var picked = saveTargetTable(model); var found = saveTargetMatches(query); if (!query || targetCombo.isClosed()) { if (picked) { targetList.appendChild(chosenTargetSummary(picked, folderPaths, query)); } else { targetList.appendChild(h('div', { class: 'ss-tree__empty' }, deListEmptyPrompt(found.offered))); }; return; }; targetList.classList.remove('is-closed'); var shown = found.tables.slice(0, DE_SECTION_ROW_LIMIT); if (shown.length) targetList.appendChild(targetCombo.listbox(shown, folderPaths, picked)); if (found.tables.length > DE_SECTION_ROW_LIMIT) targetList.appendChild(h('div', { class: 'ss-tree__empty' }, (found.tables.length - DE_SECTION_ROW_LIMIT) + ' more. Keep typing to narrow the list.')); if (!found.tables.length) targetList.appendChild(h('div', { class: 'ss-tree__empty' }, 'No Data Extensions match "' + query + '".')); }; var actionLabelId = 'ss-save-action-' + uid(); var actionCards = {}; var actionGroup = h('div', { class: 'ss-radio-group ss-radio-group--row', role: 'radiogroup', 'aria-labelledby': actionLabelId }, SAVE_UPDATE_TYPES.map(function (t) { var input = h('input', { type: 'radio', name: 'ss-save-update-type', value: t.id, 'data-save': 'update-type', onchange: function () { if (!input.checked) return; model.updateType = t.id; if (t.id === 'Overwrite') self._readCount(tab, model); view.refresh(); } }, null); var note = h('span', { class: 'ss-save__card-note', 'data-save': 'update-note' }, null); var card = h('label', { class: 'ss-radio-option' }, [input, h('strong', null, t.id), h('span', null, t.line), note]); actionCards[t.id] = { input: input, card: card, note: note }; return card; })); var countNote = h('p', { class: 'ss-save__note', 'data-save': 'count', 'aria-live': 'polite' }, null); var fieldsNote = h('div', { class: 'ss-save__note', 'data-save': 'fields-note', 'aria-live': 'polite' }, null); var mapBody = h('tbody', null, null); var mapWrap = h('div', { class: 'ss-save__table-wrap', 'data-save': 'map-wrap' }, h('table', { class: 'ss-save__table ss-save__map', 'aria-label': 'The field each result column goes to' }, [ h('thead', null, h('tr', null, [h('th', null, 'Result column'), h('th', null, 'Goes to field'), h('th', null, 'Field type')])), mapBody ])); var mapSection = h('div', { class: 'ss-field' }, [h('div', { class: 'ss-label' }, 'Columns to fields'), fieldsNote, mapWrap]); function buildRows() { var wanted = String(model.mapKey) + '|' + model.mode; if (view.mapFor === wanted) return; view.mapFor = wanted; view.rows = []; clearNode(mapBody); model.columns.forEach(function (column, i) { var row = {}; row.select = h('select', { class: 'ss-select', 'aria-label': 'Field for ' + column, 'data-save': 'map', 'data-save-field': String(i), onchange: function (ev) { model.map[i] = ev.target.value; view.refresh(); } }, null); row.matched = h('span', { class: 'ss-save__matched', 'data-save': 'matched', 'data-save-field': String(i) }, null); row.error = h('div', { class: 'ss-save__error' }, null); row.type = h('td', { class: 'ss-save__ftype' }, null); row.tr = h('tr', { 'data-save-row': String(i) }, [ h('td', { class: 'ss-save__source', title: column }, column), h('td', null, [row.select, row.matched, row.error]), row.type ]); view.rows.push(row); mapBody.appendChild(row.tr); }); }; var movingNote = h('p', { class: 'ss-save__moving', role: 'status', 'data-save': 'moving-note' }, null); var cancelBtn = h('button', { class: 'ss-btn', type: 'button', onclick: function () { shell.close(); } }, 'Cancel'); var primaryBtn = h('button', { class: 'ss-btn ss-btn--primary ss-save__primary', type: 'button', 'data-autofocus': true, 'data-save': 'primary', onclick: function () { if (primaryBtn.disabled) return; if (self._stopsForLint(model)) { view.refresh(); return; }; if (model.updateType === 'Overwrite') { Dialogs.confirm('Overwrite the rows of ' + model.target.name + '?', saveOverwriteQuestion(model), function () { self._startExisting(tab, model, true); }, null, true, 'Overwrite'); return; }; self._startExisting(tab, model, false); } }, 'Save'); view.refresh = function () { var save = tab.activeSave; var isBusy = !!save; var target = model.target; var table = target ? saveTargetTable(model) : null; if (target && !table && !isBusy) { self._clearTarget(model); target = null; } else if (target && table && !Array.isArray(table.fields) && model.fieldsState === 'ready') { self._loadTargetFields(tab, model, table); }; var fields = target ? saveTargetFields(model) : null; if (target && fields && model.fieldsState !== 'ready') model.fieldsState = 'ready'; if (target && fields && model.mapKey !== target.tableKey) { var matched = saveMatchColumns(model.columns, fields); model.map = matched.map; model.isMatched = matched.isMatched; model.mapKey = target.tableKey; }; var updateReason = (target && fields) ? saveUpdateBlockReason(model, fields) : ''; if (model.updateType === 'Update' && updateReason) model.updateType = null; var checks = (target && fields) ? saveExistingChecks(model, fields) : { errors: [], rowErrors: {} }; var misfits = (target && fields) ? saveExistingMisfits(tab, model, fields) : []; view.hint.textContent = model.mode === 'sql' ? 'SQL Studio runs the query again into a Data Extension that exists.' : 'SQL Studio moves the ' + fmtRows(model.rowCount) + ' of these results into a Data Extension that exists.'; targetSearch.disabled = isBusy; clearNode(fieldsNote); if (target && model.fieldsState === 'loading') { appendChildren(fieldsNote, [spinner(), ' Loading the fields of ' + target.name + '…']); } else if (target && model.fieldsState === 'failed') { appendChildren(fieldsNote, [ 'Could not load the fields of ' + target.name + ': ' + model.fieldsError + ' ', h('button', { class: 'ss-tree__link', type: 'button', onclick: function () { self._loadTargetFields(tab, model, saveTargetTable(model)); view.refresh(); } }, 'Retry') ]); }; mapSection.style.display = target ? '' : 'none'; mapWrap.style.display = (target && fields) ? '' : 'none'; if (target && fields) { buildRows(); var effective = saveEffectiveMap(model); view.rows.forEach(function (row, i) { var field = effective[i] ? saveFieldNamed(fields, effective[i]) : null; var isFixed = model.isMatched[i] || model.mode === 'sql'; row.select.style.display = isFixed ? 'none' : ''; row.matched.style.display = isFixed ? '' : 'none'; row.matched.textContent = field ? field.name : (isFixed ? 'No field of this name' : ''); if (!isFixed) { var taken = {}; effective.forEach(function (name, j) { if (name && j !== i) taken[name.toLowerCase()] = true; }); clearNode(row.select); row.select.appendChild(h('option', { value: '' }, SAVE_LEAVE_OUT_TEXT)); fields.forEach(function (f) { if (!taken[String(f.name).toLowerCase()]) row.select.appendChild(h('option', { value: String(f.name) }, String(f.name))); }); row.select.value = model.map[i]; row.select.disabled = isBusy; }; row.type.textContent = field ? saveFieldTypeText(field) + (field.isPrimaryKey ? ' · primary key' : (!field.isNullable ? ' · required' : '')) : ''; row.error.textContent = checks.rowErrors[i] || ''; row.tr.classList.toggle('has-error', !!checks.rowErrors[i]); }); }; var isChoosable = !isBusy && !!target && !!fields; SAVE_UPDATE_TYPES.forEach(function (t) { var c = actionCards[t.id]; var isOff = !isChoosable || (t.id === 'Update' && updateReason !== ''); c.input.disabled = isOff; c.input.checked = model.updateType === t.id; c.card.classList.toggle('is-selected', model.updateType === t.id); c.card.classList.toggle('is-disabled', isOff); c.note.textContent = (t.id === 'Update' && target && fields) ? updateReason : ''; }); if (model.updateType === 'Overwrite' && model.count.state === 'loading') { countNote.textContent = 'Counting the rows ' + target.name + ' holds…'; } else if (model.updateType === 'Overwrite' && model.count.state === 'known') { countNote.textContent = target.name + ' holds ' + fmtRows(model.count.value) + '.'; } else if (model.updateType === 'Overwrite' && model.count.state === 'unknown') { countNote.textContent = 'SQL Studio could not count the rows ' + target.name + ' holds.'; } else { countNote.textContent = ''; }; clearNode(view.checks); appendChildren(view.checks, checks.errors.map(function (e) { return h('div', { class: 'ss-message-item ss-message-item--error', 'data-save': 'check', 'data-check': e.id }, e.text); })); clearNode(view.warnings); appendChildren(view.warnings, misfits.map(function (text) { return h('div', { class: 'ss-message-item ss-message-item--warning', 'data-save': 'warning' }, text); })); clearNode(view.notice); var notices = []; if (model.checkState === 'pending' && !save) { notices.push(h('p', { class: 'ss-save__note' }, [spinner(), ' Checking that the results are still there…'])); } else if (model.checkState === 'done' && model.mode === 'sql') { notices.push(h('div', { class: 'ss-message-item ss-message-item--info', 'data-save': 'gone' }, 'The temporary Data Extension that holds these results is gone. Saving runs the query again, so each of its columns must match a field of the target by name.')); } else if (model.checkState === 'done' && model.mode === 'none') { notices.push(h('div', { class: 'ss-message-item ss-message-item--error', 'data-save': 'gone' }, 'These results and the query that made them are gone. Run the query again, then save.')); }; if (model.error) { var name = model.error.name || (target ? target.name : ''); var url = (target && target.objectId) ? dataExtensionUrl(Session.data && Session.data.stackKey, target.objectId) : null; var children = [h('div', null, model.error.isUnconfirmed ? saveExistingUnconfirmedText(name, model.error.updateType, model.error) : saveExistingFailedText(name, saveMoveMessage(model.error), model.error.isRefused))]; if (url) children.push(h('a', { class: 'ss-save__link', href: url, target: '_blank', rel: 'noopener noreferrer' }, 'Open it in Contact Builder')); notices.push(h('div', { class: 'ss-message-item ss-message-item--' + (model.error.isUnconfirmed ? 'warning' : 'error'), role: 'alert', 'data-save': 'error' }, children)); }; appendChildren(view.notice, notices); clearNode(movingNote); if (save && save.phase === 'moving') { appendChildren(movingNote, [ h('span', { class: 'ss-save__moving-line' }, 'The query is moving the rows into ' + save.name + ' now.'), h('span', { class: 'ss-save__moving-line' }, 'You can close this window: a message says when the move ends.') ]); }; view.cost.textContent = save ? '' : saveExistingCostText(model); var todo = ''; if (!target) todo = 'Choose a Data Extension to save into.'; else if (model.fieldsState === 'failed') todo = 'Its fields are needed to match the columns.'; else if (!fields) todo = 'Loading its fields…'; else if (!model.updateType) todo = 'Choose what to do with the rows.'; var hasErrors = checks.errors.length > 0; var stopped = save ? [] : self._stoppedErrors(model); if (view.gate) { if (stopped.length) view.gate.show(stopped); else view.gate.hide(); }; view.fix.classList.toggle('is-neutral', !hasErrors); view.fix.textContent = isBusy ? '' : (hasErrors ? 'Fix the marked values to save. ' + checks.errors.length + (checks.errors.length === 1 ? ' value needs' : ' values need') + ' a change.' : todo); var isReady = !!target && !!fields && !!model.updateType && !hasErrors && model.checkState === 'done' && model.mode !== 'none' && !(model.updateType === 'Overwrite' && model.count.state === 'loading'); clearNode(primaryBtn); if (save) { var step = { checking: 'Checking…', moving: 'Moving rows…' }; primaryBtn.disabled = true; primaryBtn.setAttribute('aria-busy', 'true'); primaryBtn.appendChild(spinner()); primaryBtn.appendChild(document.createTextNode(step[save.phase] || step.moving)); } else { primaryBtn.removeAttribute('aria-busy'); primaryBtn.disabled = !isReady; primaryBtn.textContent = stopped.length ? 'Save anyway' : 'Save'; }; cancelBtn.textContent = save ? 'Close' : 'Cancel'; }; UI._saveTargetRefresh = drawTargets; drawTargets(); if (!Schema.deListSavedAt() || Schema.isDeListLoading()) loadList(); return { view: view, body: [ view.notice, view.hint, h('div', { class: 'ss-field' }, [ h('label', { class: 'ss-label', for: 'ss-save-target-search' }, 'Data Extension to save into'), targetSearch, h('div', null, [targetList]), targetNote ]), h('div', { class: 'ss-field' }, [h('div', { class: 'ss-label', id: actionLabelId }, 'What to do with the rows'), actionGroup, countNote]), mapSection, view.checks, view.warnings, view.fix ], footer: [view.cost, movingNote, cancelBtn, primaryBtn] }; }, _pickTarget: function (tab, model, table) { this._clearTarget(model); model.target = { key: String(table.key), name: String(table.name), objectId: table.objectId || null, isShared: !!table.isShared, categoryId: (table.categoryId === undefined) ? null : table.categoryId, tableKey: Schema.tableKey(table) }; this._loadTargetFields(tab, model, table); }, _clearTarget: function (model) { model.target = null; model.fieldsState = 'ready'; model.fieldsError = ''; model.updateType = null; model.map = []; model.isMatched = []; model.mapKey = null; model.count = { state: 'idle', value: null, at: 0 }; model.spent = { fields: 0, count: 0 }; model.error = null; }, _loadTargetFields: function (tab, model, table) { var target = model.target; var draw = function () { if (model.view) model.view.refresh(); }; if (!target || !table) return; if (Array.isArray(table.fields)) { model.fieldsState = 'ready'; model.fieldsError = ''; return; }; model.fieldsState = 'loading'; model.fieldsError = ''; model.spent.fields += 1; Schema.fields(table).then(function () { if (model.target !== target) return; if (Array.isArray(table.fields)) { model.fieldsState = 'ready'; } else { model.fieldsState = 'failed'; model.fieldsError = 'No fields came back.'; }; }, function (err) { if (model.target !== target) return; model.fieldsState = 'failed'; model.fieldsError = (err && err.message) ? err.message : 'No message.'; }).then(draw); }, _readCount: function (tab, model) { var target = model.target; var count = model.count; if (!target) return; if (count.state === 'loading' || (count.state === 'known' && Date.now() - count.at < SAVE_COUNT_FRESH_MS)) return; var mine = { state: 'loading', value: null, at: Date.now() }; model.count = mine; model.spent.count = 1; var draw = function () { if (model.view) model.view.refresh(); }; Api.call('getResults', { deKey: target.key, page: 1, pageSize: 1, restOnly: true }).then(function (res) { if (model.count !== mine) return; if (res && typeof res.count === 'number') { mine.state = 'known'; mine.value = res.count; } else { mine.state = 'unknown'; }; mine.at = Date.now(); }, function () { if (model.count !== mine) return; mine.state = 'unknown'; }).then(draw); }, _startExisting: function (tab, model, isConfirmed) { var self = this; if (tab.activeSave) return; var fields = saveTargetFields(model); if (!model.target || !fields || !model.updateType || model.checkState !== 'done' || model.mode === 'none') return; if (saveExistingChecks(model, fields).errors.length > 0) return; if (model.updateType === 'Update' && saveUpdateBlockReason(model, fields) !== '') return; if (model.updateType === 'Overwrite' && isConfirmed !== true) return; var owner = { _calls: [] }; model.error = null; tab.saveChoice = 'existing'; var save = { kind: 'existing', updateType: model.updateType, isOverwriteConfirmed: model.updateType === 'Overwrite' && isConfirmed === true, phase: model.mode === 'from' ? 'checking' : 'moving', name: model.target.name, rowCount: model.mode === 'sql' ? null : model.rowCount, slot: null, made: { objectId: model.target.objectId, key: model.target.key, name: model.target.name, targetSig: null }, task: null }; tab.activeSave = save; self._redraw(model); var checked = model.mode === 'from' ? self._check(tab, model, owner) : Promise.resolve(); var saved = checked.then(function () { var stop = null; if (model.mode === 'none') { stop = 'These results and the query that made them are gone, so SQL Studio saved nothing. Run the query again, then save.'; } else if (model.mode === 'sql' && saveExistingChecks(model, fields).errors.length > 0) { stop = 'The temporary Data Extension of these results is gone, so SQL Studio saved nothing yet. Saving runs the query again, so each of its columns must match a field of ' + model.target.name + ' by name' + (saveSelfReadText(model) ? ', and a query that reads ' + model.target.name + ' needs the Update action' : '') + ': open Save to DE to see which do not.'; } else { stop = self._lintStopAfterCheck(model) || null; }; if (stop) { var stopped = new Error(stop); stopped.isStop = true; throw stopped; }; save.rowCount = model.mode === 'sql' ? null : model.rowCount; save.phase = 'moving'; self._redraw(model); return self._move(tab, model, save, owner); }); self._settle(tab, model, save, owner, saved, function () { return 'move'; }); }, _pickSendableField: function (model) { if (saveSendableField(model)) return; var candidates = saveCandidates(model, model.sendable.kind); var likely = model.sendable.kind === 'emailAddress' ? /e-?mail/i : /subscriber|contact.?key/i; var named = candidates.filter(function (i) { return likely.test(model.fields[i].name); }); model.sendable.fieldIndex = named.length ? named[0] : (candidates.length ? candidates[0] : -1); }, _drawFooter: function (tab, model, view, errors, buttons) { var save = tab.activeSave; var created = model.created; var renamed = created ? model.fields.filter(saveIsRenamed).map(function (f) { return f.name.trim(); }) : []; var url = created ? dataExtensionUrl(Session.data && Session.data.stackKey, created.objectId) : null; clearNode(view.notice); var notices = []; if (model.checkState === 'pending' && !save) { notices.push(h('p', { class: 'ss-save__note' }, [spinner(), ' Checking that the results are still there…'])); } else if (model.checkState === 'done' && model.mode === 'sql') { if (!created) { notices.push(h('div', { class: 'ss-message-item ss-message-item--info', 'data-save': 'gone' }, 'The temporary Data Extension that holds these results is gone. Saving runs the query again, into the new Data Extension, so each field takes its column\'s name.' + (model.namesReset ? ' The names you changed are the columns\' names again.' : ''))); } else if (renamed.length) { notices.push(h('div', { class: 'ss-message-item ss-message-item--error', 'data-save': 'gone' }, 'The temporary Data Extension that held these results is gone. Running the query again into ' + created.name + ' would leave the fields you renamed empty: ' + renamed.join(', ') + '. Save to a new Data Extension instead.')); } else { notices.push(h('div', { class: 'ss-message-item ss-message-item--info', 'data-save': 'gone' }, 'The temporary Data Extension that held these results is gone. Moving the rows again runs the query again, into ' + created.name + '.')); }; } else if (model.checkState === 'done' && model.mode === 'none') { notices.push(h('div', { class: 'ss-message-item ss-message-item--error', 'data-save': 'gone' }, created ? 'These results and the query that made them are gone, so SQL Studio cannot move the rows into ' + created.name + '. Run the query again, then save.' : 'These results and the query that made them are gone. Run the query again, then save.')); }; if (model.error) { var children = []; if (model.error.stage === 'create' && model.error.mayExist) { children.push(h('div', null, saveCreateUnknownText(model.error.name, model.error))); } else if (model.error.stage === 'create') { children.push(h('div', null, saveCreateRefusedBy(model.error) + ' did not create the Data Extension, and nothing was made: ' + model.error.message)); } else if (model.error.isUnconfirmed) { children.push(h('div', null, saveUnconfirmedText(created.name, model.error))); } else { children.push(h('div', null, 'The Data Extension ' + created.name + ' was created and kept, but its rows were not moved. ' + saveMoveMessage(model.error))); children.push(h('div', { class: 'ss-save__locked', 'data-save': 'locked-note' }, 'Its fields are set now. To change one, save to a new Data Extension instead.')); }; if (model.error.stage !== 'create' && url) children.push(h('a', { class: 'ss-save__link', href: url, target: '_blank', rel: 'noopener noreferrer' }, 'Open it in Contact Builder')); var isWarning = model.error.isUnconfirmed || model.error.mayExist; notices.push(h('div', { class: 'ss-message-item ss-message-item--' + (isWarning ? 'warning' : 'error'), role: 'alert', 'data-save': 'error' }, children)); }; appendChildren(view.notice, notices); clearNode(buttons.movingNote); if (save && save.phase === 'moving' && created) { appendChildren(buttons.movingNote, [ h('span', { class: 'ss-save__moving-line' }, 'The Data Extension ' + created.name + ' is created. The query is moving the rows into it now.'), h('span', { class: 'ss-save__moving-line' }, 'You can close this window: a message says when the move ends.') ]); }; var isMoveAgain = !!created && !save; var canMoveAgain = isMoveAgain && model.checkState === 'done' && model.mode !== 'none' && !(model.mode === 'sql' && renamed.length > 0); view.cost.textContent = save ? '' : saveCostText(model); var isReady = errors.count === 0 && model.checkState === 'done' && model.mode !== 'none'; view.fix.textContent = (!save && !isMoveAgain && errors.count > 0) ? 'Fix the marked values to save. ' + errors.count + (errors.count === 1 ? ' value needs' : ' values need') + ' a change.' : ''; var stopped = save ? [] : this._stoppedErrors(model); if (view.gate) { if (stopped.length) view.gate.show(stopped); else view.gate.hide(); }; clearNode(buttons.primaryBtn); if (save) { var step = { checking: 'Checking…', creating: 'Creating…', moving: 'Moving rows…' }; buttons.primaryBtn.disabled = true; buttons.primaryBtn.setAttribute('aria-busy', 'true'); buttons.primaryBtn.appendChild(spinner()); buttons.primaryBtn.appendChild(document.createTextNode(step[save.phase] || step.moving)); } else { buttons.primaryBtn.removeAttribute('aria-busy'); buttons.primaryBtn.disabled = isMoveAgain ? !canMoveAgain : !isReady; buttons.primaryBtn.textContent = (isMoveAgain ? 'Move the rows again' : 'Save') + (stopped.length ? ' anyway' : ''); }; buttons.cancelBtn.textContent = (save || isMoveAgain) ? 'Close' : 'Cancel'; buttons.startOverBtn.style.display = isMoveAgain ? '' : 'none'; }, _redraw: function (model) { UI._syncExportCost(); if (model && model.view) model.view.refresh(); }, _start: function (tab, model) { var self = this; if (tab.activeSave) return; var isMoveOnly = !!model.created; if (model.checkState !== 'done' || model.mode === 'none') return; if (!isMoveOnly && saveValidate(model).count > 0) return; var owner = { _calls: [] }; var stage = isMoveOnly ? 'move' : 'create'; model.error = null; model.isFolderSettled = true; tab.saveChoice = 'new'; var save = { phase: model.mode === 'from' ? 'checking' : (isMoveOnly ? 'moving' : 'creating'), name: isMoveOnly ? model.created.name : model.name.trim(), rowCount: model.mode === 'sql' ? null : model.rowCount, slot: null, made: isMoveOnly ? model.created : null, task: null }; tab.activeSave = save; self._redraw(model); var checked = model.mode === 'from' ? self._check(tab, model, owner) : Promise.resolve(); var namesBefore = model.fields.map(function (f) { return f.name; }).join('\n'); var saved = checked.then(function () { var stop = null; if (model.mode === 'none') { stop = 'These results and the query that made them are gone, so SQL Studio saved nothing. Run the query again, then save.'; } else if (!isMoveOnly && model.fields.map(function (f) { return f.name; }).join('\n') !== namesBefore) { stop = 'The temporary Data Extension of these results is gone, so SQL Studio saved nothing yet. Saving runs the query again, so each field takes its column\'s name: open Save to DE to save that way.'; } else if (isMoveOnly && model.mode === 'sql' && model.fields.some(saveIsRenamed)) { stop = 'The temporary Data Extension of these results is gone, and running the query again into ' + model.created.name + ' would leave the fields you renamed empty. Open Save to DE to save to a new Data Extension instead.'; } else { stop = self._lintStopAfterCheck(model) || null; }; if (stop) { var stopped = new Error(stop); stopped.isStop = true; throw stopped; }; if (isMoveOnly) return model.created; save.phase = 'creating'; self._redraw(model); return Api.call('createDataExtension', saveCreatePayload(model), owner).then(function (res) { var made = { objectId: res.objectId || null, key: res.key, name: res.name || model.name.trim(), targetSig: typeof res.targetSig === 'string' ? res.targetSig : null }; model.created = made; Schema.addCreatedDataExtension(made, model); writeSavedFolder(model.folderId); return made; }, function (err) { if (err && (err.code === 'NETWORK' || err.code === 'CREATE_UNCONFIRMED' || err.code === 'CREATED_NO_KEY')) err.mayExist = true; throw err; }); }).then(function (made) { stage = 'move'; save.made = made; save.name = made.name; save.rowCount = model.mode === 'sql' ? null : model.rowCount; save.phase = 'moving'; self._redraw(model); return self._move(tab, model, save, owner); }); self._settle(tab, model, save, owner, saved, function () { return stage; }); }, _move: function (tab, model, save, owner) { var self = this; return self._sendMove(tab, model, save, owner).then(function (moved) { if (moved && moved.slot) save.slot = moved.slot; if (moved && moved.queryId) safeSet(adhocQueryKey(save.slot), moved.queryId); save.task = { queryId: moved.queryId, taskId: moved.taskId || null, taskSig: (moved.taskId && typeof moved.taskSig === 'string') ? moved.taskSig : null, startedAt: Date.now(), via: null, hasTaskRow: false, hasSeenRunning: false, notRunningSince: null }; QueryTabs.markChanged(tab); QueryTabs.persist(); return self._follow(tab, save, owner); }); }, _sendMove: function (tab, model, save, owner) { var self = this; var folders = Schema.workspace().catch(function () { return {}; }); return folders.then(function (ws) { return runStartQueue.then(function () { save.slot = QueryTabs.pickSlot(tab); var payload = { slot: save.slot, targetKey: save.made.key }; var isExisting = save.kind === 'existing'; if (isExisting) { payload.targetName = save.made.name; if (typeof save.made.objectId === 'string' && SAVE_OBJECT_ID_RE.test(save.made.objectId)) payload.targetObjectId = save.made.objectId; payload.updateType = save.updateType; if (save.isOverwriteConfirmed === true) payload.confirmOverwrite = true; } else if (save.made.targetSig) { payload.targetSig = save.made.targetSig; }; if (model.mode === 'sql') { payload.sql = model.sql; } else { payload.from = { deKey: model.runKey, columns: isExisting ? saveExistingMoveColumns(model) : saveMoveColumns(model) }; }; if (ws && ws.queryFolderId) payload.queryFolderId = ws.queryFolderId; var cachedQueryId = safeGet(adhocQueryKey(save.slot)); if (cachedQueryId) payload.queryId = cachedQueryId; save.sentAt = Date.now(); QueryTabs.markChanged(tab); QueryTabs.persist(); return Api.call('moveToDataExtension', payload, owner).catch(function (err) { if (err && err.code === 'START_UNCONFIRMED') throw saveUnconfirmed(saveSentence(err.message)); if (err && err.code === 'NETWORK') throw saveUnconfirmed('The move\'s request got no answer SQL Studio can read: ' + saveSentence(err.message) + ' SQL Studio cannot tell whether Marketing Cloud Engagement started the move.'); if (isFolderRefusal(err) && Schema.forgetWorkspace()) { save.sentAt = null; QueryTabs.markChanged(tab); QueryTabs.persist(); return self._sendMove(tab, model, save, owner); }; throw err; }); }); }); }, _follow: function (tab, save, owner) { var task = save.task; return new Promise(function (resolve, reject) { var failures = 0; function isCurrent() { return tab.activeSave === save; }; function later() { setTimeout(check, saveCheckWait(saveIsFreeFollow(task), Date.now() - task.startedAt)); }; function check() { if (!isCurrent()) return; var payload = { queryId: task.queryId }; if (task.taskId && task.via !== 'none') { payload.taskId = task.taskId; if (task.taskSig) payload.taskSig = task.taskSig; payload.taskOnly = true; }; var isHinted = Session.isBackendBu(); var noteVia = function (via) { if (via && (isHinted || via === 'wsproxy')) task.via = via; }; Api.call('getRunStatus', payload, owner).then(function (res) { if (!isCurrent()) return; failures = 0; var now = Date.now(); if (res && res.taskStatus) { noteVia(res.taskStatus.via); task.hasTaskRow = true; task.notRunningSince = null; var kind = classifyRunOutcome(res.taskStatus); if (kind === 'complete') { resolve(); return; }; if (kind === 'failed') { reject(new Error(saveFailureText(res.taskStatus, tab, save.kind === 'existing' ? tab.existingModel : tab.saveModel))); return; }; if (kind === 'unknown') logUnknownRunStatus(res.taskStatus.status); } else if (res && res.pending === true) { noteVia(res.pendingVia === 'soap' ? 'soap' : 'wsproxy'); if (!task.hasTaskRow && now - task.startedAt > TASK_ROW_WAIT_MS) { reject(saveUnconfirmed('Marketing Cloud Engagement gave no status for the move in 5 minutes.')); return; }; } else if (res && (res.isRunning === true || res.isRunning === false)) { var isTaskUnread = !!payload.taskId; if (isTaskUnread) task.via = 'soap'; if (res.isRunning) { task.hasSeenRunning = true; task.notRunningSince = null; } else if (task.hasSeenRunning && isTaskUnread) { task.unreadEnds = (task.unreadEnds || 0) + 1; if (task.unreadEnds > 3) { reject(saveUnconfirmed(SAVE_ENDED_UNREAD_TEXT, true)); return; }; } else if (task.hasSeenRunning) { reject(saveUnconfirmed(SAVE_ENDED_UNREAD_TEXT, true)); return; } else { if (!task.notRunningSince) task.notRunningSince = now; if (now - task.notRunningSince >= POLL_QUEUE_WAIT_MS) { reject(saveUnconfirmed('Marketing Cloud Engagement never said the move was running, so SQL Studio cannot tell whether it ran or still waits in the queue.')); return; }; }; }; if (now - task.startedAt > POLL_MAX_MS) { reject(saveUnconfirmed('Marketing Cloud Engagement had not finished the move after 31 minutes.')); return; }; later(); }, function (err) { if (!isCurrent()) return; failures++; if (err && (err.code === 'NETWORK' || err.code === 'RATE_LIMITED') && failures < POLL_MAX_NETWORK_FAILURES) { later(); return; }; if (err && (err.code === 'SESSION_EXPIRED' || err.code === 'SESSION_INVALID')) { var recovery = Session.recovery(); if (recovery) { recovery.then(function () { failures = 0; check(); }, function () { /* the dialog has taken over: its snapshot holds the save, which goes on after Renew */ }); return; }; if (Session._expired) return; }; reject(saveUnconfirmed('Checking the move failed: ' + saveSentence(err && err.message))); }); }; setTimeout(check, Math.max(0, task.startedAt + POLL_FAST_MS - Date.now())); }); }, _settle: function (tab, model, save, owner, promise, stageOf) { var self = this; promise.then(function () { if (tab.activeSave === save) self._finish(tab, model, save, owner); }, function (err) { if (tab.activeSave !== save) return; var stage = stageOf(); var message = err && err.message ? err.message : 'No message.'; var isUnconfirmed = !!(err && err.isUnconfirmed); var mayExist = !!(err && err.mayExist); tab.activeSave = null; if (isUnconfirmed && save.slot) { var movedAt = (save.task && save.task.startedAt) || save.sentAt; if (movedAt) QueryTabs.holdSlot(tab, save.slot, movedAt + POLL_MAX_MS); }; QueryTabs.markChanged(tab); QueryTabs.persist(); if (err && err.isStop) { UI.addTabStatusNotice(tab, SAVE_NOTICE_PREFIX + message); if (model && model.view) { self._redraw(model); } else { UI._syncExportCost(); Toast.show(message, 'info', { duration: 15000 }); }; return; }; var name = save.made ? save.made.name : save.name; var url = save.made ? dataExtensionUrl(Session.data && Session.data.stackKey, save.made.objectId) : null; var queryHint = (stage === 'move' && !isUnconfirmed && model && model.mode === 'sql' && model.sql) ? serverErrorHint(model.sql, [message], true) : null; var error = { stage: stage, code: (err && err.code) || null, name: name, message: message, hint: queryHint, details: (err && err.details) || null, isUnconfirmed: isUnconfirmed, hasEnded: !!(err && err.hasEnded), mayExist: mayExist, updateType: save.kind === 'existing' ? save.updateType : null, isRefused: !isUnconfirmed && !!(err && err.code) }; if (model) model.error = error; var linked = url ? { duration: 15000, link: { text: 'Open in Contact Builder', href: url } } : { duration: 15000 }; var plain = { duration: 15000 }; var text; var kind = 'error'; var options = plain; if (save.kind === 'existing') { options = linked; kind = isUnconfirmed ? 'warning' : 'error'; text = isUnconfirmed ? saveExistingUnconfirmedText(name, save.updateType, error) : saveExistingFailedText(name, saveMoveMessage(error), error.isRefused); } else if (stage === 'create' && mayExist) { kind = 'warning'; text = saveCreateUnknownText(name, error); } else if (stage === 'create') { text = saveCreateRefusedBy(error) + ' did not create the Data Extension ' + name + ': ' + message; } else if (isUnconfirmed) { options = linked; kind = 'warning'; text = saveUnconfirmedText(name, error); } else { text = 'The Data Extension ' + name + ' was created and kept, but its rows were not moved. ' + saveMoveMessage(error) + (model ? ' Open Save to DE to move them again.' : ''); }; UI.addTabStatusNotice(tab, SAVE_NOTICE_PREFIX + text); if (model && model.view) { self._redraw(model); } else { UI._syncExportCost(); Toast.show(text, kind, options); }; if (typeof console !== 'undefined') console.warn('[SQL Studio] Save to Data Extension ' + (isUnconfirmed ? 'could not confirm the move' : (mayExist ? 'could not read the create\'s answer' : 'failed at the ' + stage)) + ': ' + message); if (model && stage === 'move' && !isUnconfirmed && model.mode === 'from') self._check(tab, model); }); }, _finish: function (tab, model, save, owner) { var made = save.made; var isExisting = save.kind === 'existing'; tab.activeSave = null; if (isExisting) { if (!model || tab.existingModel === model) tab.existingModel = null; } else if (!model || tab.saveModel === model) { tab.saveModel = null; }; QueryTabs.markChanged(tab); QueryTabs.persist(); var view = model ? model.view : null; if (model) model.view = null; if (view && view.modal) view.modal.close(); UI._syncExportCost(); var url = dataExtensionUrl(Session.data && Session.data.stackKey, made.objectId); var what = typeof save.rowCount === 'number' ? fmtRows(save.rowCount) : 'the query\'s results'; var done = isExisting ? saveDoneText(save.updateType, made.name, save.rowCount) : 'Saved ' + what + ' to ' + made.name + '.'; Toast.show(done, 'success', url ? { duration: 10000, link: { text: 'Open in Contact Builder', href: url } } : undefined); UI.addTabStatusNotice(tab, SAVE_NOTICE_PREFIX + done); if (typeof console !== 'undefined') { var api = owner._calls.reduce(function (sum, c) { return sum + (c.apiCalls || 0); }, 0); var wsproxy = owner._calls.reduce(function (sum, c) { return sum + (c.wsproxyCalls || 0); }, 0); var consoleText = isExisting ? done.replace(/\.$/, '') + ' (' + made.key + ')' : 'Saved ' + what + ' to the Data Extension ' + made.name + ' (' + made.key + ')'; console.info('[SQL Studio] ' + consoleText + ' with ' + callCountsText(api, wsproxy) + '.'); }; }, snapshotOf: function (tab) { var save = tab.activeSave; if (!save || !save.made || (!save.task && !save.sentAt)) return null; var isExisting = save.kind === 'existing'; var source = isExisting ? tab.existingModel : tab.saveModel; var model = null; if (source) { model = {}; Object.keys(source).forEach(function (key) { if (key !== 'view' && key !== 'error') model[key] = source[key]; }); }; var entry = { slot: save.slot, targetKey: save.made.key, targetSig: save.made.targetSig || null, objectId: save.made.objectId || null, name: save.made.name, rowCount: save.rowCount, sentAt: save.sentAt || null, task: save.task ? Object.assign({}, save.task) : null, model: model }; if (isExisting) { entry.kind = 'existing'; entry.updateType = save.updateType; }; return entry; }, unansweredHoldOf: function (tab) { var save = tab.activeSave; if (!save || save.task || !save.sentAt || !save.slot) return null; return { slot: save.slot, until: save.sentAt + POLL_MAX_MS }; }, resume: function (tab, entry) { if (tab.activeSave || !entry || !entry.targetKey) return; var isUnanswered = !entry.task && typeof entry.sentAt === 'number'; if (!isUnanswered && !(entry.task && entry.task.queryId)) return; var startedAt = isUnanswered ? entry.sentAt : (entry.task.startedAt || 0); if (!(Date.now() - startedAt <= POLL_MAX_MS)) return; var made = { objectId: entry.objectId || null, key: entry.targetKey, name: entry.name || entry.targetKey, targetSig: entry.targetSig || null }; var isExisting = entry.kind === 'existing'; var model = null; if (isExisting) { model = (entry.model && typeof entry.model === 'object' && Array.isArray(entry.model.columns) && entry.model.kind === 'existing') ? entry.model : null; if (model) { model.view = null; model.error = null; tab.existingModel = model; }; tab.saveChoice = 'existing'; } else { model = (entry.model && typeof entry.model === 'object' && Array.isArray(entry.model.fields)) ? entry.model : null; if (model) { model.view = null; model.error = null; model.created = made; tab.saveModel = model; }; }; var save = { phase: 'moving', name: made.name, rowCount: typeof entry.rowCount === 'number' ? entry.rowCount : null, slot: entry.slot || null, made: made, sentAt: typeof entry.sentAt === 'number' ? entry.sentAt : null, task: isUnanswered ? null : Object.assign({}, entry.task) }; if (isExisting) { save.kind = 'existing'; save.updateType = (entry.updateType === 'Append' || entry.updateType === 'Update' || entry.updateType === 'Overwrite') ? entry.updateType : 'Append'; }; tab.activeSave = save; var owner = { _calls: [] }; UI._syncExportCost(); if (isUnanswered) { if (typeof console !== 'undefined') console.info('[SQL Studio] The move into the Data Extension ' + made.name + ' had no answer before the page was reloaded.'); this._settle(tab, model, save, owner, Promise.reject(saveUnconfirmed(SAVE_UNANSWERED_TEXT)), function () { return 'move'; }); return; }; if (typeof console !== 'undefined') console.info('[SQL Studio] Following the move into the Data Extension ' + made.name + ' again.'); this._settle(tab, model, save, owner, this._follow(tab, save, owner), function () { return 'move'; }); } }; function injectEmbeddedCss() { var css = global.SQLStudioEmbeddedCss; if (typeof css !== 'string') return; var style = document.createElement('style'); style.id = 'sqlstudio-css'; style.textContent = css; document.head.appendChild(style); }; function stripSetupParamFromUrl() { var url = new URL(global.location.href); url.searchParams.delete('setup'); url.searchParams.delete('retention'); global.history.replaceState(null, '', url.toString()); }; var FIRST_RUN_SETUP_STEPS = ['folder', 'authLog', 'errorLog']; function runFirstRunSetup(progress) { var steps = progress.steps || FIRST_RUN_SETUP_STEPS; progress.failedSteps = progress.failedSteps || []; if (progress.stepIndex >= steps.length) { finishFirstRunSetup(progress); return; }; var stepName = steps[progress.stepIndex]; Api.call('finishSetup', { step: stepName, folderId: progress.folderId }).then(function (res) { if (global.console) global.console.info('[SQL Studio] Setup step ' + stepName + ' took ' + res.elapsedMs + ' ms.'); if (stepName === 'folder') progress.folderId = res.folderId; progress.outcomes = progress.outcomes || {}; progress.outcomes[stepName] = res; }, function (err) { progress.failedSteps.push(stepName); progress.lastError = (err && err.message) ? err.message : 'unknown error'; }).then(function () { progress.stepIndex += 1; writeJSON(LS.setup, progress); runFirstRunSetup(progress); }); }; var SETUP_STEP_LABELS = { folder: 'creating the SQL Studio folder', authLog: 'moving SQL Studio Auth Log into its folder and setting its retention', errorLog: 'creating SQL Studio Error Log' }; function finishFirstRunSetup(progress) { var failed = progress.failedSteps || []; if (!failed.length) { safeRemove(LS.setup); reportFirstRunSetup(progress.outcomes || {}, !!progress.isRepair, progress.folderId); return; }; var retrySteps = failed.indexOf('folder') !== -1 ? (progress.steps || FIRST_RUN_SETUP_STEPS) : failed; writeJSON(LS.setup, { steps: retrySteps, stepIndex: 0, folderId: progress.folderId, outcomes: progress.outcomes || {}, isRepair: !!progress.isRepair }); var labels = failed.map(function (stepName) { return SETUP_STEP_LABELS[stepName] || stepName; }); UI.addStatusNotice('SQL Studio could not finish ' + labels.join(' and ') + ': ' + progress.lastError + ' It tries again the next time you open it.'); }; function reportFirstRunSetup(outcomes, isRepair, folderId) { var errorLogStatus = outcomes.errorLog ? outcomes.errorLog.errorLog : 'failed'; var knownFolderId = (outcomes.folder && outcomes.folder.folderId) || folderId || null; if (isRepair) { if (errorLogStatus === 'created' || errorLogStatus === 'created-no-retention') { UI.addStatusNotice('SQL Studio recreated its missing error log, SQL Studio Error Log' + (knownFolderId ? ', in the SQL Studio folder.' : '.')); }; if (errorLogStatus === 'failed') { UI.addStatusNotice('SQL Studio\'s error log, SQL Studio Error Log, is missing and could not be recreated, so backend errors are not logged. Create it by hand, as the deployment guide describes.'); }; if (errorLogStatus === 'created-no-retention') { UI.addStatusNotice('Marketing Cloud Engagement did not let SQL Studio set its retention. Set SQL Studio Error Log to 180 days in its properties.'); }; return; }; var isInFolder = !!(knownFolderId && outcomes.authLog && outcomes.authLog.isMoved); UI.addStatusNotice('First run: SQL Studio created its Data Extensions ' + (isInFolder ? 'in the SQL Studio folder.' : "in this Business Unit's Data Extensions folder.")); if (errorLogStatus === 'failed') { UI.addStatusNotice('SQL Studio could not create its error log, so backend errors are not logged. Create SQL Studio Error Log by hand, as the deployment guide describes.'); }; var isRetentionMissing = !(outcomes.authLog && outcomes.authLog.isRetentionSet) || errorLogStatus === 'created-no-retention'; if (isRetentionMissing) { UI.addStatusNotice('Marketing Cloud Engagement did not let SQL Studio set their retention. Set it by hand in each Data Extension\'s properties: SQL Studio Auth Log to 1 day, SQL Studio Error Log to 180 days. The 1-day retention keeps each sign-in row no longer than it is needed.'); }; }; function compareVersions(a, b) { var partsA = String(a).split('.'); var partsB = String(b).split('.'); for (var i = 0; i < 3; i++) { var diff = (Number(partsA[i]) || 0) - (Number(partsB[i]) || 0); if (diff !== 0) return diff > 0 ? 1 : -1; }; return 0; }; function parseReleaseManifest(json) { if (!json || typeof json !== 'object' || !/^\d+\.\d+\.\d+$/.test(String(json.version || ''))) return null; var sent = Array.isArray(json.highlights) ? json.highlights : []; var sentLeads = (Array.isArray(json.highlightLeads) && json.highlightLeads.length === sent.length) ? json.highlightLeads : []; var highlights = []; var highlightLeads = []; sent.forEach(function (item, i) { if (typeof item !== 'string' || item.trim() === '' || highlights.length >= UPDATE_HIGHLIGHT_MAX_COUNT) return; var text = item.slice(0, UPDATE_HIGHLIGHT_MAX_LENGTH); var lead = sentLeads[i]; var isLead = typeof lead === 'string' && lead.trim() !== '' && lead.length <= UPDATE_LEAD_MAX_LENGTH && text.indexOf(lead) === 0; highlights.push(text); highlightLeads.push(isLead ? lead : ''); }); function projectLink(url) { return (typeof url === 'string' && UPDATE_LINK_RE.test(url)) ? url : null; }; return { version: String(json.version), date: typeof json.date === 'string' ? json.date.slice(0, 20) : '', highlights: highlights, highlightLeads: highlightLeads, updateGuide: projectLink(json.updateGuide), changelog: projectLink(json.changelog) }; }; var UpdateCheck = { latest: null, run: function () { var self = this; var cached = readJSON(LS.update, null); if (cached && cached.checkedAt && Date.now() - cached.checkedAt < UPDATE_CHECK_EVERY_MS) { self._offer(parseReleaseManifest(cached.latest)); return; }; if (Config.data.mock) { var devVersion = (/(?:^|[?&])update=([0-9.]+)/.exec(global.location.search) || [])[1]; if (devVersion) { self._offer(parseReleaseManifest({ version: devVersion, date: '2026-10-01', highlights: ['Tabs: each with its own query and results.', 'A quicker first run in a new Business Unit.'], highlightLeads: ['Tabs:', ''], updateGuide: 'https://github.com/MateuszDabrowski/sqlstudio/blob/main/docs/DEPLOYMENT.md#8-updating', changelog: 'https://github.com/MateuszDabrowski/sqlstudio/blob/main/CHANGELOG.md' })); }; return; }; if (typeof fetch !== 'function') return; var controller = (typeof AbortController !== 'undefined') ? new AbortController() : null; var timer = setTimeout(function () { if (controller) controller.abort(); }, UPDATE_CHECK_TIMEOUT_MS); var fetchOpts = { credentials: 'omit', referrerPolicy: 'no-referrer', cache: 'no-store' }; if (controller) fetchOpts.signal = controller.signal; fetch(UPDATE_CHECK_URL, fetchOpts).then(function (res) { return res.ok ? res.json() : null; }).then(function (json) { clearTimeout(timer); writeJSON(LS.update, { checkedAt: Date.now(), latest: json }); self._offer(parseReleaseManifest(json)); }).catch(function (err) { clearTimeout(timer); writeJSON(LS.update, { checkedAt: Date.now(), latest: null }); if (typeof console !== 'undefined') console.info('[SQL Studio] Update check skipped: ' + (err && err.message ? err.message : String(err))); }); }, _offer: function (latest) { if (!latest || compareVersions(latest.version, APP_VERSION) <= 0) return; if (safeGet(LS.updateDismissed) === latest.version) return; this.latest = latest; UI.showUpdateBadge(latest); }, dismiss: function () { if (this.latest) safeSet(LS.updateDismissed, this.latest.version); UI.hideUpdateBadge(); } }; var OpeningTimes = { _hasEditor: false, _editorAt: null, _editorMode: null, _hasWhoami: false, _whoamiAt: null, _isWhoamiOk: false, _isLogged: false, settle: function (what, isOk) { try { var now = readClock(); if (what === 'editor') { this._hasEditor = true; this._editorAt = now; this._editorMode = Editor.mode; } else { this._hasWhoami = true; this._whoamiAt = now; this._isWhoamiOk = !!isOk; }; if (this._hasEditor && this._hasWhoami) this._log(); } catch (e) { }; }, _pageArrivedAt: function () { try { var entries = global.performance.getEntriesByType('navigation'); var end = entries && entries[0] ? entries[0].responseEnd : null; return (typeof end === 'number' && isFinite(end) && end > 0) ? end : null; } catch (e) { return null; }; }, _log: function () { if (this._isLogged || typeof console === 'undefined') return; this._isLogged = true; var total = readClock(); if (total === null) return; var parts = []; var arrivedAt = this._pageArrivedAt(); if (arrivedAt !== null) parts.push('the page arrived at ' + fmtSeconds(arrivedAt)); if (frontendStartedAt !== null) parts.push('the Frontend started at ' + fmtSeconds(frontendStartedAt)); if (this._editorAt !== null) { parts.push('the ' + (this._editorMode === 'textarea' ? 'plain text editor' : 'editor') + ' was ready at ' + fmtSeconds(this._editorAt)); }; if (this._whoamiAt !== null) parts.push('whoami ' + (this._isWhoamiOk ? 'answered' : 'failed') + ' at ' + fmtSeconds(this._whoamiAt)); var list = parts.length < 3 ? parts.join(' and ') : parts.slice(0, -1).join(', ') + ', and ' + parts[parts.length - 1]; console.info('[SQL Studio] Opened in ' + fmtSeconds(total) + (list ? ': ' + list : '') + '.'); } }; function boot() { if (isRenewFrame()) { try { global.parent.SQLStudio.silentRenew.frameArrived(); } catch (e) { }; return; }; injectEmbeddedCss(); Config.init(); Settings.load(); History.load(); var root = document.getElementById('sqlstudio'); if (!root) { if (global.console) global.console.error('SQL Studio: no #sqlstudio root element found.'); return; }; QueryTabs.init(); UI.buildShell(root); Settings.apply(); Settings.followSystem(); if (Config.data.setupPending) { writeJSON(LS.setup, { stepIndex: 0, folderId: null, outcomes: {} }); stripSetupParamFromUrl(); }; Schema.init(); var restore = Session.takeRestore(); if (restore && !restore.tabs && typeof restore.sql === 'string') { restore = { activeIndex: 0, sidebarCollapsed: restore.sidebarCollapsed, tabs: [{ text: restore.sql, lastLoadedText: '', openedQuery: restore.openedQuery || null, activeRun: restore.activeRun || null, runSql: restore.runSql, runStartedAt: restore.runStartedAt, runFinishedAt: restore.runFinishedAt, results: restore.results || null, resultsRun: restore.resultsRun || null }] }; }; var savedSql = null; safeRemove(LS.sql); var lastScope = safeGet(LAST_SCOPE_KEY); var isLastScopeThisUser = !!lastScope && !!Config.data.userKey && lastScope.split('.')[1] === Config.data.userKey; var earlyTabs = (!restore && isLastScopeThisUser) ? readJSON('ss.tabs.' + lastScope, null) : null; var initialSql = ''; if (!restore && earlyTabs && earlyTabs.tabs) { QueryTabs.applySaved(earlyTabs); var earlyFirst = QueryTabs.get(0)._pendingRestoreEntry; initialSql = earlyFirst ? (earlyFirst.text || '') : ''; if (earlyFirst) QueryTabs.get(0).state.lastLoadedText = earlyFirst.lastLoadedText || initialSql; } else if (restore) { QueryTabs.applySaved(restore); var seedEntry = QueryTabs.get(0)._pendingRestoreEntry; initialSql = seedEntry ? (seedEntry.text || '') : ''; Shared.sidebarCollapsed = !!restore.sidebarCollapsed; } else if (savedSql) { initialSql = savedSql.text || ''; QueryTabs.get(0).state.lastLoadedText = initialSql; }; UI.el.sidebar.classList.toggle('is-collapsed', Shared.sidebarCollapsed); UI._syncSidebarToggleButton(); UI.renderSidebarTree(); UI.renderOpenedQueryBadge(); Runner.setState('idle'); UI.renderProblems([]); UI.renderResults(); QueryTabs.render(); Editor.init(UI.el.editorPane, initialSql).then(function () { OpeningTimes.settle('editor'); var tab0 = QueryTabs.get(0); if (Editor.mode === 'monaco') tab0.monacoModel = Editor.model; else tab0.textareaText = Editor.getValue(); tab0.pendingText = null; QueryTabs._isEditorReady = true; UI.applyInitialSplit(); if (restore || earlyTabs) { QueryTabs.applyPendingContent(); QueryTabs.finishRestore(); QueryTabs._activatePending(); }; if (!document.hasFocus()) Editor.focus(); }); var sessionBoot = Session.boot(); sessionBoot.then(function () { OpeningTimes.settle('whoami', true); }, function () { OpeningTimes.settle('whoami', false); }); sessionBoot.then(function () { UI.renderRunSummary(); }, function () { /* boot's own branch below shows the error */ }); sessionBoot.then(function () { removeLegacyStores(); History.load(); Schema.loadDataExtensionList().catch(function () { /* retried on first use, see above */ }); Snippets.load(); safeRemove(midKey('ss.myTables')); UpdateCheck.run(); var setupProgress = readJSON(LS.setup, null); var missingDataExtensions = (Session.data && Session.data.missingDataExtensions) || []; var isErrorLogMissing = missingDataExtensions.indexOf('errorLog') !== -1; if (setupProgress && typeof setupProgress.stepIndex === 'number') { var remainingSteps = (setupProgress.steps || FIRST_RUN_SETUP_STEPS).slice(setupProgress.stepIndex); if (isErrorLogMissing && remainingSteps.indexOf('errorLog') === -1) { setupProgress.steps = remainingSteps.concat(['errorLog']); setupProgress.stepIndex = 0; }; runFirstRunSetup(setupProgress); } else if (isErrorLogMissing) { runFirstRunSetup({ steps: ['folder', 'errorLog'], stepIndex: 0, folderId: null, outcomes: {}, isRepair: true }); }; var scope = userScope(); var isForeignRestore = !!restore && restore.scope !== scope; var isSameScope = !!earlyTabs && scope === lastScope; safeSet(LAST_SCOPE_KEY, scope); var savedTabs = null; if ((!restore && !isSameScope) || isForeignRestore) { savedTabs = readJSON(tabsKey(), null); if (savedTabs) QueryTabs.applySaved(savedTabs); if (earlyTabs || isForeignRestore) { var ownEntries = (savedTabs && savedTabs.tabs) || []; var replacement = []; for (var t = 0; t < QUERY_SLOT_COUNT; t++) replacement.push(ownEntries[t] || { text: '', lastLoadedText: '' }); QueryTabs.applySaved({ activeIndex: savedTabs ? savedTabs.activeIndex : 0, tabs: replacement }); }; Editor.whenReady().then(function () { QueryTabs.applyPendingContent(); QueryTabs.finishRestore(); QueryTabs._activatePending(); if (!savedTabs) QueryTabs.persist(); safeRemove(LS.sql); }); } else { Editor.whenReady().then(function () { QueryTabs.persist(); safeRemove(LS.sql); }); }; sweepTempDataExtensions(QueryTabs.protectedDeKeys((isForeignRestore ? null : restore) || savedTabs || earlyTabs)); }).catch(function (err) { if (err && err.code === 'CONFIG_OUTDATED') return; if (err && (err.code === 'SESSION_EXPIRED' || err.code === 'SESSION_INVALID')) return; Toast.show('Could not verify your session: ' + err.message, 'error'); }); setInterval(function () { if (!QueryTabs.hasRunInFlight()) return; if (isRunInFlight(state.runnerState)) UI.renderRunSummary(); QueryTabs.render(); }, 1000); ['keydown', 'pointerdown', 'wheel'].forEach(function (eventName) { document.addEventListener(eventName, function () { lastActivityAt = Date.now(); }, { capture: true, passive: true }); }); setInterval(function () { Session.maybeRenewSilently(); }, SILENT_RENEW_CHECK_MS); global.addEventListener('storage', function (ev) { var key = tabsKey(); if (!ev || !key || ev.key !== key) return; QueryTabs.adoptOtherWindowChanges(readJSON(key, null)); if (QueryTabs._hasShownOtherWindowNotice) return; QueryTabs._hasShownOtherWindowNotice = true; Toast.show('SQL Studio is open in another window too. Each tab keeps the last change made to it in either window.', 'info'); }); document.addEventListener('visibilitychange', function () { if (!document.hidden) Session.maybeRenewSilently(); }); global.addEventListener('beforeunload', function (ev) { if (QueryTabs.hasUnsavedOrRunningWork() && !Session.isRenewing) { ev.preventDefault(); ev.returnValue = ''; }; }); }; global.SQLStudio = { boot: boot, api: Api, get state() { return state; }, get runner() { return Runner; }, tabs: QueryTabs, config: Config, session: Session, silentRenew: SilentRenew, updateCheck: UpdateCheck, schema: Schema, editor: Editor, grid: Grid, history: History, snippets: Snippets, dialogs: Dialogs, settings: Settings, ui: UI }; }(window));