@papernote/ui
Version:
A modern React component library with a paper notebook aesthetic - minimal, professional, and expressive
595 lines (508 loc) • 17.6 kB
text/typescript
/**
* SQL to Natural Language Translator
* Converts technical SQL queries into user-friendly descriptions
*/
export interface QueryDescription {
summary: string;
details: string[];
technical?: string;
}
/**
* Configuration for friendly names
* Maps technical database/field names to business-friendly terms
*/
export interface FriendlyNameConfig {
tables?: Record<string, string>;
fields?: Record<string, string>;
}
/**
* Default friendly name mappings
* Can be overridden by passing custom config to translateSqlToNaturalLanguage
*/
const DEFAULT_FRIENDLY_NAMES: FriendlyNameConfig = {
tables: {
'BookingLineItems': 'Backlog Items',
'BookingLineItem': 'Backlog Item',
'Bookings': 'Bookings',
'Booking': 'Booking',
'Accounts': 'Accounts',
'Account': 'Account',
'Users': 'Users',
'User': 'User',
'Commissions': 'Commissions',
'Commission': 'Commission',
'Territories': 'Territories',
'Territory': 'Territory',
'Locations': 'Locations',
'Location': 'Location',
'AccountDivisions': 'Account Divisions',
'AccountDivision': 'Account Division',
'BillingLineItems': 'Billing Line Items',
'BillingLineItem': 'Billing Line Item',
},
fields: {
// Date fields (common across all entities)
'CreatedDate': 'Created Date',
'ModifiedDate': 'Modified Date',
'CreatedAt': 'Created Date',
'UpdatedAt': 'Updated Date',
'DeletedAt': 'Deleted Date',
// Backlog/BookingLineItem specific dates
'SchedDeliveryDate': 'Scheduled Delivery Date',
'ScheduledDeliveryDate': 'Scheduled Delivery Date',
'DaysOverdue': 'Days Overdue',
// Commission specific dates
'InvoiceDate': 'Invoice Date',
'CommissionDate': 'Commission Date',
'PaymentDate': 'Payment Date',
'CommDate': 'Commission Date',
'CommPaidDate': 'Commission Paid Date',
// Booking specific dates
'BookingDate': 'Booking Date',
'ShipDate': 'Ship Date',
'DueDate': 'Due Date',
'ExpectedCloseDate': 'Expected Close Date',
// Quantity fields (Backlog)
'QtyPending': 'Quantity Pending',
'QuantityPending': 'Quantity Pending',
'QtyOrdered': 'Quantity Ordered',
'QuantityOrdered': 'Quantity Ordered',
'QtyShipped': 'Quantity Shipped',
'QuantityShipped': 'Quantity Shipped',
// Reference numbers
'PONumber': 'Purchase Order Number',
'PurchaseOrderNumber': 'Purchase Order Number',
'InvoiceNumber': 'Invoice Number',
'OrderNumber': 'Order Number',
// People and roles
'ClientName': 'Client Name',
'Customer': 'Customer',
'CustomerName': 'Customer Name',
'AccountManager': 'Account Manager',
'SalesRep': 'Sales Representative',
'SalesRepresentative': 'Sales Representative',
'Principal': 'Principal',
'PrincipalName': 'Principal Name',
// Organizational fields
'TerritoryName': 'Territory',
'LocationName': 'Location',
'DivisionName': 'Division',
'AccountName': 'Account Name',
// Status and flags
'Status': 'Status',
'StatusCode': 'Status',
'IsActive': 'Active Status',
'IsSplit': 'Split Commission',
'IsDeleted': 'Deleted',
'ApprovalStatus': 'Approval Status',
// Financial amounts
'CommissionAmount': 'Commission Amount',
'Amount': 'Amount',
'GrossAmount': 'Gross Amount',
'NetAmount': 'Net Amount',
'TotalAmount': 'Total Amount',
'SplitPercentage': 'Split Percentage',
'Rate': 'Rate',
'Percentage': 'Percentage',
'BookingTotal': 'Booking Total',
'Commission': 'Commission',
'CommRate': 'Commission Rate',
'LineTotal': 'Line Total',
// Descriptions and notes
'Description': 'Description',
'Notes': 'Notes',
'Comments': 'Comments',
'Note': 'Note',
'ProductLine': 'Product Line',
// Split commission fields
'CustAccountManager': 'Customer Account Manager',
'CustAccountManager_1': 'Account Manager 1',
'CustAccountManager_2': 'Account Manager 2',
'CustAccountManager_3': 'Account Manager 3',
'CustAccountManager_4': 'Account Manager 4',
'CustAccountManager_5': 'Account Manager 5',
'CustAccountManager_6': 'Account Manager 6',
'CustAMSplitRate_1': 'Split Rate 1',
'CustAMSplitRate_2': 'Split Rate 2',
'CustAMSplitRate_3': 'Split Rate 3',
'CustAMSplitRate_4': 'Split Rate 4',
'CustAMSplitRate_5': 'Split Rate 5',
'CustAMSplitRate_6': 'Split Rate 6',
// Fiscal period fields
'FiscalYear': 'Fiscal Year',
'FiscalMonth': 'Fiscal Month',
// Account relationship fields
'CustomerAccount': 'Customer Account',
'PrincipalAccount': 'Principal Account',
'DistributorAccount': 'Distributor Account',
'RepresentativeUser': 'Representative User',
'Distributor': 'Distributor',
// Computed/derived fields
'Probability': 'Probability',
'IsPaid': 'Is Paid',
'Company': 'Company',
}
};
/**
* Converts a SQL query into natural language description
* @param sql - The SQL query to translate
* @param customNames - Optional custom friendly name mappings
* @param relatedData - Optional related data information with entity descriptions
* @param appliedFilters - Optional pre-processed filter information with display values
*/
export function translateSqlToNaturalLanguage(
sql: string,
customNames?: FriendlyNameConfig,
relatedData?: Array<{ entity: string; description: string; type: 'join' | 'include' | 'lookup' }>,
appliedFilters?: Array<{ key: string; label: string; value: any; displayValue?: string }>,
calculations?: Array<{ field: string; description: string; formula?: string; type: string; example?: string }>
): QueryDescription {
if (!sql || sql.trim().length === 0) {
return {
summary: 'No query information available',
details: []
};
}
// Merge custom names with defaults
const friendlyNames: FriendlyNameConfig = {
tables: { ...DEFAULT_FRIENDLY_NAMES.tables, ...customNames?.tables },
fields: { ...DEFAULT_FRIENDLY_NAMES.fields, ...customNames?.fields }
};
const normalized = sql.toLowerCase().trim();
const details: string[] = [];
// Extract main action
let summary = 'Retrieving data';
// Detect the main table/entity being queried
const fromMatch = normalized.match(/from\s+["']?(\w+)["']?/);
const tableName = fromMatch ? formatTableName(fromMatch[1], friendlyNames) : 'records';
summary = `Showing ${tableName}`;
// Parse WHERE conditions - use appliedFilters if provided, otherwise parse SQL
if (appliedFilters && appliedFilters.length > 0) {
details.push('**Filters applied:**');
appliedFilters.forEach(filter => {
const displayText = filter.displayValue || `${filter.label} = ${filter.value}`;
details.push(` • ${displayText}`);
});
} else {
const whereConditions = parseWhereClause(normalized, friendlyNames);
if (whereConditions.length > 0) {
details.push('**Filters applied:**');
whereConditions.forEach(condition => details.push(` • ${condition}`));
}
}
// Parse ORDER BY
const orderBy = parseOrderBy(normalized, friendlyNames);
if (orderBy) {
details.push(`**Sorted by:** ${orderBy}`);
}
// Parse LIMIT/TOP
const limit = parseLimit(normalized);
if (limit) {
details.push(`**Showing:** ${limit}`);
}
// Parse JOINs - use relatedData if provided, otherwise parse from SQL
if (relatedData && relatedData.length > 0) {
details.push('**Related data included:**');
relatedData.forEach(related => {
details.push(` • ${related.entity}`);
});
}
// Show calculations if provided
if (calculations && calculations.length > 0) {
details.push('**Calculated fields:**');
calculations.forEach(calc => {
let calcText = `${calc.field}: ${calc.description}`;
if (calc.formula) {
calcText += ` (${calc.formula})`;
}
if (calc.example) {
calcText += ` - Example: ${calc.example}`;
}
details.push(` • ${calcText}`);
});
}
// Parse aggregations (fallback if calculations not provided)
if (!calculations || calculations.length === 0) {
const aggregations = parseAggregations(normalized, friendlyNames);
if (aggregations.length > 0) {
details.push('**Calculations:**');
aggregations.forEach(agg => details.push(` • ${agg}`));
}
}
return {
summary,
details,
technical: sql
};
}
/**
* Format table names to be more readable
*/
function formatTableName(tableName: string, friendlyNames: FriendlyNameConfig): string {
// Check for friendly name first (case-insensitive)
if (friendlyNames.tables) {
const lowerTableName = tableName.toLowerCase();
for (const [key, value] of Object.entries(friendlyNames.tables)) {
if (key.toLowerCase() === lowerTableName) {
return value;
}
}
}
// Remove common prefixes
let formatted = tableName.replace(/^(tbl|dbo\.|public\.)/, '');
// Split camel case or pascal case
formatted = formatted.replace(/([a-z])([A-Z])/g, '$1 $2');
// Replace underscores with spaces
formatted = formatted.replace(/_/g, ' ');
// Capitalize first letter of each word
formatted = formatted.split(' ')
.map(word => word.charAt(0).toUpperCase() + word.slice(1))
.join(' ');
return formatted;
}
/**
* Parse WHERE clause conditions into natural language
*/
function parseWhereClause(sql: string, friendlyNames: FriendlyNameConfig): string[] {
const conditions: string[] = [];
// Extract WHERE clause
const whereMatch = sql.match(/where\s+(.*?)(?:order by|group by|limit|offset|$)/s);
if (!whereMatch) return conditions;
const whereClause = whereMatch[1].trim();
// Split by AND/OR (simplified - doesn't handle nested conditions perfectly)
const parts = whereClause.split(/\s+(?:and|or)\s+/i);
parts.forEach(part => {
const condition = parseCondition(part.trim(), friendlyNames);
if (condition) {
conditions.push(condition);
}
});
return conditions;
}
/**
* Parse individual condition into natural language
*/
function parseCondition(condition: string, friendlyNames: FriendlyNameConfig): string | null {
// Remove extra whitespace
condition = condition.replace(/\s+/g, ' ').trim();
// Date range patterns
if (condition.includes('>=') && condition.includes('<=')) {
const field = extractFieldName(condition, friendlyNames);
return `${field} is within a specific date range`;
}
// Greater than or equal
if (condition.includes('>=')) {
const field = extractFieldName(condition, friendlyNames);
const value = extractValue(condition, '>=');
const formattedValue = formatValueForDisplay(value);
return `${field} is on or after ${formattedValue}`;
}
// Less than or equal
if (condition.includes('<=')) {
const field = extractFieldName(condition, friendlyNames);
const value = extractValue(condition, '<=');
const formattedValue = formatValueForDisplay(value);
return `${field} is on or before ${formattedValue}`;
}
// Greater than
if (condition.includes('>')) {
const field = extractFieldName(condition, friendlyNames);
const value = extractValue(condition, '>');
return `${field} is greater than ${value}`;
}
// Less than
if (condition.includes('<')) {
const field = extractFieldName(condition, friendlyNames);
const value = extractValue(condition, '<');
return `${field} is less than ${value}`;
}
// Equality
if (condition.includes('=')) {
const field = extractFieldName(condition, friendlyNames);
const value = extractValue(condition, '=');
if (value.toLowerCase() === 'null') {
return `${field} is empty`;
}
const formattedValue = formatValueForDisplay(value);
return `${field} equals ${formattedValue}`;
}
// LIKE pattern
if (condition.includes('like')) {
const field = extractFieldName(condition, friendlyNames);
return `${field} contains specific text`;
}
// IN list
if (condition.includes('in (')) {
const field = extractFieldName(condition, friendlyNames);
return `${field} is one of several values`;
}
// IS NULL
if (condition.includes('is null')) {
const field = extractFieldName(condition, friendlyNames);
return `${field} is empty`;
}
// IS NOT NULL
if (condition.includes('is not null')) {
const field = extractFieldName(condition, friendlyNames);
return `${field} has a value`;
}
// BETWEEN
if (condition.includes('between')) {
const field = extractFieldName(condition, friendlyNames);
return `${field} is within a range`;
}
return null;
}
/**
* Extract field name from condition
*/
function extractFieldName(condition: string, friendlyNames: FriendlyNameConfig): string {
condition = condition.trim();
const match = condition.match(/^(?:["']?\w+["']?\.)?["']?(\w+)["']?/);
if (match && match[1]) {
const fieldName = match[1];
if (fieldName.length === 1) {
const dotMatch = condition.match(/\.["']?(\w+)["']?/);
if (dotMatch && dotMatch[1]) {
return formatFieldName(dotMatch[1], friendlyNames);
}
}
return formatFieldName(fieldName, friendlyNames);
}
return 'field';
}
/**
* Format value for display (convert dates, etc.)
*/
function formatValueForDisplay(value: string): string {
if (/^\d{4}-\d{2}-\d{2}/.test(value)) {
try {
const date = new Date(value);
return date.toLocaleDateString('en-US', {
year: 'numeric',
month: 'long',
day: 'numeric'
});
} catch {
return value;
}
}
if (value === 'specified value') {
return value;
}
return value;
}
/**
* Extract value from condition
*/
function extractValue(condition: string, operator: string): string {
const parts = condition.split(operator);
if (parts.length > 1) {
let value = parts[1].trim();
if (value.includes("'-infinity'") || value.includes('"-infinity"')) {
return 'the beginning of time';
}
if (value.includes("'infinity'") || value.includes('"infinity"')) {
return 'the end of time';
}
value = value.replace(/timestamptz\s+/gi, '');
value = value.replace(/^['"]|['"]$/g, '');
value = value.replace(/@\w+/g, 'specified value');
return value;
}
return 'a value';
}
/**
* Format field names to be more readable
*/
function formatFieldName(fieldName: string, friendlyNames: FriendlyNameConfig): string {
if (friendlyNames.fields) {
const lowerFieldName = fieldName.toLowerCase();
for (const [key, value] of Object.entries(friendlyNames.fields)) {
if (key.toLowerCase() === lowerFieldName) {
return value;
}
}
}
fieldName = fieldName.replace(/^\w+\./, '');
fieldName = fieldName.replace(/([a-z])([A-Z])/g, '$1 $2');
fieldName = fieldName.replace(/_/g, ' ');
return fieldName.charAt(0).toUpperCase() + fieldName.slice(1).toLowerCase();
}
/**
* Parse ORDER BY clause
*/
function parseOrderBy(sql: string, friendlyNames: FriendlyNameConfig): string | null {
const match = sql.match(/order by\s+(.*?)(?:limit|offset|$)/s);
if (!match) return null;
const orderClause = match[1].trim();
const parts = orderClause.split(',').map(p => p.trim());
const descriptions = parts.map(part => {
const desc = part.includes('desc');
let field = part.replace(/\s+(asc|desc)$/i, '').trim();
field = field.replace(/["']/g, '');
field = field.replace(/^\w+\./, '');
const fieldName = formatFieldName(field, friendlyNames);
return `${fieldName} (${desc ? 'newest first' : 'oldest first'})`;
});
return descriptions.join(', then by ');
}
/**
* Parse LIMIT/TOP clause
*/
function parseLimit(sql: string): string | null {
let match = sql.match(/limit\s+(\d+)/);
if (match) {
return `First ${match[1]} results`;
}
match = sql.match(/top\s+(\d+)/);
if (match) {
return `First ${match[1]} results`;
}
return null;
}
/**
* Parse JOIN clauses (currently unused)
*/
// function _parseJoins(sql: string, friendlyNames: FriendlyNameConfig): string[] {
// const joins: string[] = [];
// const joinMatches = sql.matchAll(/(left|right|inner|outer)?\s*join\s+["']?(\w+)["']?/gi);
//
// for (const match of joinMatches) {
// const _joinType = match[1] ? match[1].toLowerCase() : 'inner';
// const tableName = formatTableName(match[2], friendlyNames);
// joins.push(tableName);
// }
//
// return joins;
// }
/**
* Parse aggregation functions
*/
function parseAggregations(sql: string, friendlyNames: FriendlyNameConfig): string[] {
const aggregations: string[] = [];
if (sql.includes('count(')) {
aggregations.push('Counting records');
}
const sumMatches = sql.matchAll(/sum\(["']?(\w+)["']?\)/gi);
for (const match of sumMatches) {
const field = formatFieldName(match[1], friendlyNames);
aggregations.push(`Totaling ${field}`);
}
const avgMatches = sql.matchAll(/avg\(["']?(\w+)["']?\)/gi);
for (const match of avgMatches) {
const field = formatFieldName(match[1], friendlyNames);
aggregations.push(`Averaging ${field}`);
}
const maxMatches = sql.matchAll(/max\(["']?(\w+)["']?\)/gi);
for (const match of maxMatches) {
const field = formatFieldName(match[1], friendlyNames);
aggregations.push(`Finding maximum ${field}`);
}
const minMatches = sql.matchAll(/min\(["']?(\w+)["']?\)/gi);
for (const match of minMatches) {
const field = formatFieldName(match[1], friendlyNames);
aggregations.push(`Finding minimum ${field}`);
}
return aggregations;
}