@sprig-and-prose/sprig-universe 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/PHILOSOPHY.md +201 -0
- package/README.md +168 -0
- package/REFERENCE.md +355 -0
- package/biome.json +24 -0
- package/package.json +30 -0
- package/repositories/sprig-repository-github/index.js +29 -0
- package/src/ast.js +257 -0
- package/src/cli.js +1510 -0
- package/src/graph.js +950 -0
- package/src/index.js +46 -0
- package/src/ir.js +121 -0
- package/src/parser.js +1656 -0
- package/src/scanner.js +255 -0
- package/src/scene-manifest.js +856 -0
- package/src/util/span.js +46 -0
- package/src/util/text.js +126 -0
- package/src/validator.js +862 -0
- package/src/validators/mysql/connection.js +154 -0
- package/src/validators/mysql/schema.js +209 -0
- package/src/validators/mysql/type-compat.js +219 -0
- package/src/validators/mysql/validator.js +332 -0
- package/test/fixtures/amaranthine-mini.prose +53 -0
- package/test/fixtures/conflicting-universes-a.prose +8 -0
- package/test/fixtures/conflicting-universes-b.prose +8 -0
- package/test/fixtures/duplicate-names.prose +20 -0
- package/test/fixtures/first-line-aware.prose +32 -0
- package/test/fixtures/indented-describe.prose +18 -0
- package/test/fixtures/multi-file-universe-a.prose +15 -0
- package/test/fixtures/multi-file-universe-b.prose +15 -0
- package/test/fixtures/multi-file-universe-conflict-desc.prose +12 -0
- package/test/fixtures/multi-file-universe-conflict-title.prose +4 -0
- package/test/fixtures/multi-file-universe-with-title.prose +10 -0
- package/test/fixtures/named-document.prose +17 -0
- package/test/fixtures/named-duplicate.prose +22 -0
- package/test/fixtures/named-reference.prose +17 -0
- package/test/fixtures/relates-errors.prose +38 -0
- package/test/fixtures/relates-tier1.prose +14 -0
- package/test/fixtures/relates-tier2.prose +16 -0
- package/test/fixtures/relates-tier3.prose +21 -0
- package/test/fixtures/sprig-meta-mini.prose +62 -0
- package/test/fixtures/unresolved-relates.prose +15 -0
- package/test/fixtures/using-in-references.prose +35 -0
- package/test/fixtures/using-unknown.prose +8 -0
- package/test/universe-basic.test.js +804 -0
- package/tsconfig.json +15 -0
|
@@ -0,0 +1,154 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @fileoverview MySQL connection validation and management
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
import mysql from 'mysql2/promise';
|
|
6
|
+
import { clearColumnMetadataCache } from './schema.js';
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* Validates connection configuration exists and has required fields
|
|
10
|
+
* @param {string} connectionName - Name of the connection
|
|
11
|
+
* @param {Object} connections - Connections config from sprig.config.json
|
|
12
|
+
* @returns {{ valid: boolean, config?: Object, error?: string }} Validation result
|
|
13
|
+
*/
|
|
14
|
+
export function validateConnectionConfig(connectionName, connections) {
|
|
15
|
+
if (!connections || typeof connections !== 'object') {
|
|
16
|
+
return {
|
|
17
|
+
valid: false,
|
|
18
|
+
error: `No connections configured in sprig.config.json`,
|
|
19
|
+
};
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
const config = connections[connectionName];
|
|
23
|
+
if (!config) {
|
|
24
|
+
return {
|
|
25
|
+
valid: false,
|
|
26
|
+
error: `Connection '${connectionName}' not found in connections config`,
|
|
27
|
+
};
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
if (config.provider !== 'mysql') {
|
|
31
|
+
return {
|
|
32
|
+
valid: false,
|
|
33
|
+
error: `Connection '${connectionName}' has provider '${config.provider}', expected 'mysql'`,
|
|
34
|
+
};
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
const requiredFields = ['host', 'port', 'database', 'user'];
|
|
38
|
+
const missingFields = requiredFields.filter((field) => !(field in config));
|
|
39
|
+
|
|
40
|
+
if (missingFields.length > 0) {
|
|
41
|
+
return {
|
|
42
|
+
valid: false,
|
|
43
|
+
error: `Connection '${connectionName}' missing required fields: ${missingFields.join(', ')}`,
|
|
44
|
+
};
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
return {
|
|
48
|
+
valid: true,
|
|
49
|
+
config,
|
|
50
|
+
};
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* Creates a MySQL connection from config
|
|
55
|
+
* @param {Object} config - MySQL connection config
|
|
56
|
+
* @returns {Promise<mysql.Connection>} MySQL connection
|
|
57
|
+
*/
|
|
58
|
+
export async function createConnection(config) {
|
|
59
|
+
const connectionConfig = {
|
|
60
|
+
host: config.host,
|
|
61
|
+
port: config.port,
|
|
62
|
+
database: config.database,
|
|
63
|
+
user: config.user,
|
|
64
|
+
password: config.password,
|
|
65
|
+
// Enable multiple statements if needed
|
|
66
|
+
multipleStatements: false,
|
|
67
|
+
};
|
|
68
|
+
|
|
69
|
+
try {
|
|
70
|
+
const connection = await mysql.createConnection(connectionConfig);
|
|
71
|
+
return connection;
|
|
72
|
+
} catch (error) {
|
|
73
|
+
// Never include password in error messages
|
|
74
|
+
const connectionInfo = `${config.host}:${config.port}/${config.database} (user: ${config.user})`;
|
|
75
|
+
throw new Error(
|
|
76
|
+
`Failed to connect to MySQL at ${connectionInfo}: ${error.code || error.message}`,
|
|
77
|
+
);
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/**
|
|
82
|
+
* Tests database connection and verifies information_schema access
|
|
83
|
+
* @param {mysql.Connection} connection - MySQL connection
|
|
84
|
+
* @param {Object} config - Connection config (for error messages)
|
|
85
|
+
* @returns {Promise<{ success: boolean, error?: string, databaseName?: string }>} Test result
|
|
86
|
+
*/
|
|
87
|
+
export async function testConnection(connection, config) {
|
|
88
|
+
try {
|
|
89
|
+
// Test basic connectivity with SELECT DATABASE()
|
|
90
|
+
const [rows] = await connection.execute('SELECT DATABASE() as db');
|
|
91
|
+
const databaseName = rows[0]?.db;
|
|
92
|
+
|
|
93
|
+
// Verify information_schema access
|
|
94
|
+
const [schemaRows] = await connection.execute(
|
|
95
|
+
'SELECT 1 FROM information_schema.tables LIMIT 1',
|
|
96
|
+
);
|
|
97
|
+
|
|
98
|
+
if (schemaRows.length === 0) {
|
|
99
|
+
return {
|
|
100
|
+
success: false,
|
|
101
|
+
error: 'Cannot access information_schema (permissions issue)',
|
|
102
|
+
};
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
return {
|
|
106
|
+
success: true,
|
|
107
|
+
databaseName,
|
|
108
|
+
};
|
|
109
|
+
} catch (error) {
|
|
110
|
+
const connectionInfo = `${config.host}:${config.port}/${config.database} (user: ${config.user})`;
|
|
111
|
+
return {
|
|
112
|
+
success: false,
|
|
113
|
+
error: `Connection test failed: ${error.code || error.message}`,
|
|
114
|
+
};
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
/**
|
|
119
|
+
* Connection cache for reuse during validation
|
|
120
|
+
*/
|
|
121
|
+
const connectionCache = new Map();
|
|
122
|
+
|
|
123
|
+
/**
|
|
124
|
+
* Gets or creates a cached connection
|
|
125
|
+
* @param {string} connectionName - Connection name
|
|
126
|
+
* @param {Object} config - Connection config
|
|
127
|
+
* @returns {Promise<mysql.Connection>} MySQL connection
|
|
128
|
+
*/
|
|
129
|
+
export async function getCachedConnection(connectionName, config) {
|
|
130
|
+
if (connectionCache.has(connectionName)) {
|
|
131
|
+
return connectionCache.get(connectionName);
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
const connection = await createConnection(config);
|
|
135
|
+
connectionCache.set(connectionName, connection);
|
|
136
|
+
return connection;
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
/**
|
|
140
|
+
* Closes all cached connections
|
|
141
|
+
*/
|
|
142
|
+
export async function closeAllConnections() {
|
|
143
|
+
for (const [name, connection] of connectionCache.entries()) {
|
|
144
|
+
try {
|
|
145
|
+
await connection.end();
|
|
146
|
+
} catch (error) {
|
|
147
|
+
// Ignore errors during cleanup
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
connectionCache.clear();
|
|
151
|
+
// Also clear column metadata cache
|
|
152
|
+
clearColumnMetadataCache();
|
|
153
|
+
}
|
|
154
|
+
|
|
@@ -0,0 +1,209 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @fileoverview MySQL schema introspection using information_schema
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Column metadata cache for reuse during validation
|
|
7
|
+
* Key format: `${connectionName}|${database}|${table}`
|
|
8
|
+
*/
|
|
9
|
+
const columnMetadataCache = new Map();
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* Checks if a table exists in the database
|
|
13
|
+
* @param {import('mysql2/promise').Connection} connection - MySQL connection
|
|
14
|
+
* @param {string} schema - Database schema name
|
|
15
|
+
* @param {string} table - Table name
|
|
16
|
+
* @returns {Promise<boolean>} True if table exists
|
|
17
|
+
*/
|
|
18
|
+
export async function checkTableExists(connection, schema, table) {
|
|
19
|
+
try {
|
|
20
|
+
// First, try to get the actual database name from the connection
|
|
21
|
+
// This handles case sensitivity issues
|
|
22
|
+
let actualSchema = schema;
|
|
23
|
+
try {
|
|
24
|
+
const [dbRows] = await connection.execute('SELECT DATABASE() as db');
|
|
25
|
+
if (dbRows.length > 0 && dbRows[0].db) {
|
|
26
|
+
actualSchema = dbRows[0].db;
|
|
27
|
+
}
|
|
28
|
+
} catch {
|
|
29
|
+
// If DATABASE() fails, fall back to provided schema
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
const [rows] = await connection.execute(
|
|
33
|
+
'SELECT 1 FROM information_schema.tables WHERE table_schema = ? AND table_name = ? LIMIT 1',
|
|
34
|
+
[actualSchema, table],
|
|
35
|
+
);
|
|
36
|
+
return rows.length > 0;
|
|
37
|
+
} catch (error) {
|
|
38
|
+
throw new Error(`Failed to check table existence: ${error.message}`);
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* @typedef {Object} ColumnMetadata
|
|
44
|
+
* @property {string} column_name
|
|
45
|
+
* @property {string} data_type - MySQL data type (e.g., 'int', 'varchar', 'enum')
|
|
46
|
+
* @property {string} column_type - Full column type (e.g., "int(11)", "enum('a','b')")
|
|
47
|
+
* @property {string} is_nullable - 'YES' or 'NO'
|
|
48
|
+
* @property {number|null} character_maximum_length
|
|
49
|
+
* @property {number|null} numeric_precision
|
|
50
|
+
* @property {number|null} numeric_scale
|
|
51
|
+
*/
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* Gets column metadata for a table
|
|
55
|
+
* @param {import('mysql2/promise').Connection} connection - MySQL connection
|
|
56
|
+
* @param {string} schema - Database schema name
|
|
57
|
+
* @param {string} table - Table name
|
|
58
|
+
* @param {string} connectionName - Connection name for caching
|
|
59
|
+
* @returns {Promise<Map<string, ColumnMetadata>>} Map of column name to metadata
|
|
60
|
+
*/
|
|
61
|
+
export async function getColumnMetadata(connection, schema, table, connectionName) {
|
|
62
|
+
try {
|
|
63
|
+
// First, try to get the actual database name from the connection
|
|
64
|
+
// This handles case sensitivity issues
|
|
65
|
+
let actualSchema = schema;
|
|
66
|
+
try {
|
|
67
|
+
const [dbRows] = await connection.execute('SELECT DATABASE() as db');
|
|
68
|
+
if (dbRows.length > 0 && dbRows[0].db) {
|
|
69
|
+
actualSchema = dbRows[0].db;
|
|
70
|
+
}
|
|
71
|
+
} catch {
|
|
72
|
+
// If DATABASE() fails, fall back to provided schema
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
// Check cache first
|
|
76
|
+
const cacheKey = `${connectionName}|${actualSchema}|${table}`;
|
|
77
|
+
if (columnMetadataCache.has(cacheKey)) {
|
|
78
|
+
return columnMetadataCache.get(cacheKey);
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
const [rows] = await connection.execute(
|
|
82
|
+
`SELECT
|
|
83
|
+
column_name,
|
|
84
|
+
data_type,
|
|
85
|
+
column_type,
|
|
86
|
+
is_nullable,
|
|
87
|
+
character_maximum_length,
|
|
88
|
+
numeric_precision,
|
|
89
|
+
numeric_scale
|
|
90
|
+
FROM information_schema.columns
|
|
91
|
+
WHERE table_schema = ? AND table_name = ?
|
|
92
|
+
ORDER BY ordinal_position`,
|
|
93
|
+
[actualSchema, table],
|
|
94
|
+
);
|
|
95
|
+
|
|
96
|
+
// Create a case-insensitive map by storing both original and lowercase keys
|
|
97
|
+
const columnMap = new Map();
|
|
98
|
+
const lowerToOriginal = new Map(); // Track original case for each lowercase name
|
|
99
|
+
|
|
100
|
+
for (const row of rows) {
|
|
101
|
+
// MySQL returns column names in uppercase (COLUMN_NAME) or lowercase (column_name)
|
|
102
|
+
// depending on version/config, so handle both
|
|
103
|
+
const originalName = row.column_name || row.COLUMN_NAME;
|
|
104
|
+
|
|
105
|
+
// Skip rows with missing column_name
|
|
106
|
+
if (!originalName || typeof originalName !== 'string') {
|
|
107
|
+
continue;
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
const lowerName = originalName.toLowerCase();
|
|
111
|
+
|
|
112
|
+
// Store metadata with original column name
|
|
113
|
+
// Handle both uppercase and lowercase column names from MySQL
|
|
114
|
+
const metadata = {
|
|
115
|
+
column_name: originalName,
|
|
116
|
+
data_type: row.data_type || row.DATA_TYPE,
|
|
117
|
+
column_type: row.column_type || row.COLUMN_TYPE,
|
|
118
|
+
is_nullable: row.is_nullable || row.IS_NULLABLE,
|
|
119
|
+
character_maximum_length: row.character_maximum_length || row.CHARACTER_MAXIMUM_LENGTH,
|
|
120
|
+
numeric_precision: row.numeric_precision || row.NUMERIC_PRECISION,
|
|
121
|
+
numeric_scale: row.numeric_scale || row.NUMERIC_SCALE,
|
|
122
|
+
};
|
|
123
|
+
|
|
124
|
+
// Store with original case as primary key
|
|
125
|
+
columnMap.set(originalName, metadata);
|
|
126
|
+
|
|
127
|
+
// Track lowercase mapping for case-insensitive lookup
|
|
128
|
+
if (!lowerToOriginal.has(lowerName)) {
|
|
129
|
+
lowerToOriginal.set(lowerName, originalName);
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
// Store the lowercase mapping on the map for later use
|
|
134
|
+
columnMap._lowerToOriginal = lowerToOriginal;
|
|
135
|
+
|
|
136
|
+
// Cache the result
|
|
137
|
+
columnMetadataCache.set(cacheKey, columnMap);
|
|
138
|
+
|
|
139
|
+
return columnMap;
|
|
140
|
+
} catch (error) {
|
|
141
|
+
throw new Error(`Failed to get column metadata: ${error.message}`);
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
/**
|
|
146
|
+
* Clears the column metadata cache
|
|
147
|
+
* Should be called after validation is complete
|
|
148
|
+
*/
|
|
149
|
+
export function clearColumnMetadataCache() {
|
|
150
|
+
columnMetadataCache.clear();
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
/**
|
|
154
|
+
* Parses ENUM values from column_type string
|
|
155
|
+
* @param {string} columnType - Column type string like "enum('player','playerpaperdoll')"
|
|
156
|
+
* @returns {string[]|null} Array of enum values or null if not an enum
|
|
157
|
+
*/
|
|
158
|
+
export function parseEnumValues(columnType) {
|
|
159
|
+
if (!columnType || typeof columnType !== 'string') {
|
|
160
|
+
return null;
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
const enumMatch = columnType.match(/^enum\s*\((.+)\)$/i);
|
|
164
|
+
if (!enumMatch) {
|
|
165
|
+
return null;
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
const valuesStr = enumMatch[1];
|
|
169
|
+
// Parse quoted values: 'value1','value2' or "value1","value2"
|
|
170
|
+
const values = [];
|
|
171
|
+
let current = '';
|
|
172
|
+
let inQuotes = false;
|
|
173
|
+
let quoteChar = null;
|
|
174
|
+
|
|
175
|
+
for (let i = 0; i < valuesStr.length; i++) {
|
|
176
|
+
const char = valuesStr[i];
|
|
177
|
+
|
|
178
|
+
if (!inQuotes && (char === "'" || char === '"')) {
|
|
179
|
+
inQuotes = true;
|
|
180
|
+
quoteChar = char;
|
|
181
|
+
} else if (inQuotes && char === quoteChar) {
|
|
182
|
+
// Check if it's escaped
|
|
183
|
+
if (i + 1 < valuesStr.length && valuesStr[i + 1] === quoteChar) {
|
|
184
|
+
current += char;
|
|
185
|
+
i++; // Skip next quote
|
|
186
|
+
} else {
|
|
187
|
+
inQuotes = false;
|
|
188
|
+
quoteChar = null;
|
|
189
|
+
if (current) {
|
|
190
|
+
values.push(current);
|
|
191
|
+
current = '';
|
|
192
|
+
}
|
|
193
|
+
}
|
|
194
|
+
} else if (inQuotes) {
|
|
195
|
+
current += char;
|
|
196
|
+
} else if (char === ',' || char === ' ') {
|
|
197
|
+
// Skip commas and spaces outside quotes
|
|
198
|
+
continue;
|
|
199
|
+
}
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
// Handle last value if no trailing comma
|
|
203
|
+
if (current) {
|
|
204
|
+
values.push(current);
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
return values.length > 0 ? values : null;
|
|
208
|
+
}
|
|
209
|
+
|
|
@@ -0,0 +1,219 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @fileoverview Type compatibility checking between Sprig types and MySQL column types
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
import { parseEnumValues } from './schema.js';
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* @typedef {Object} TypeCompatibilityResult
|
|
9
|
+
* @property {boolean} compatible - Whether types are compatible
|
|
10
|
+
* @property {'error'|'warning'|'info'} severity - Severity if incompatible
|
|
11
|
+
* @property {string} message - Human-readable message
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* Checks if a Sprig integer type is compatible with MySQL column type
|
|
16
|
+
* @param {Object} columnMeta - Column metadata
|
|
17
|
+
* @returns {TypeCompatibilityResult}
|
|
18
|
+
*/
|
|
19
|
+
function checkIntegerCompatibility(columnMeta) {
|
|
20
|
+
const integerTypes = ['tinyint', 'smallint', 'mediumint', 'int', 'bigint'];
|
|
21
|
+
const dataType = columnMeta.data_type.toLowerCase();
|
|
22
|
+
|
|
23
|
+
if (integerTypes.includes(dataType)) {
|
|
24
|
+
return { compatible: true, severity: 'info', message: '' };
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
// Check for decimal/numeric with scale 0
|
|
28
|
+
if (dataType === 'decimal' || dataType === 'numeric') {
|
|
29
|
+
if (columnMeta.numeric_scale === 0) {
|
|
30
|
+
return {
|
|
31
|
+
compatible: true,
|
|
32
|
+
severity: 'warning',
|
|
33
|
+
message: `Column is ${dataType} with scale 0 (acceptable for integer)`,
|
|
34
|
+
};
|
|
35
|
+
}
|
|
36
|
+
return {
|
|
37
|
+
compatible: false,
|
|
38
|
+
severity: 'error',
|
|
39
|
+
message: `Column is ${dataType} with scale ${columnMeta.numeric_scale} (expected integer type)`,
|
|
40
|
+
};
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
return {
|
|
44
|
+
compatible: false,
|
|
45
|
+
severity: 'error',
|
|
46
|
+
message: `Column type '${columnMeta.data_type}' is not compatible with integer (expected: tinyint, smallint, mediumint, int, bigint)`,
|
|
47
|
+
};
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* Checks if a Sprig string oneOf type is compatible with MySQL column type
|
|
52
|
+
* @param {Object} fieldType - Sprig field type AST (oneOf with string values)
|
|
53
|
+
* @param {Object} columnMeta - Column metadata
|
|
54
|
+
* @returns {TypeCompatibilityResult}
|
|
55
|
+
*/
|
|
56
|
+
function checkStringOneOfCompatibility(fieldType, columnMeta) {
|
|
57
|
+
const dataType = columnMeta.data_type.toLowerCase();
|
|
58
|
+
const stringTypes = ['varchar', 'char', 'text', 'tinytext', 'mediumtext', 'longtext'];
|
|
59
|
+
|
|
60
|
+
// Best case: ENUM type
|
|
61
|
+
if (dataType === 'enum') {
|
|
62
|
+
const enumValues = parseEnumValues(columnMeta.column_type);
|
|
63
|
+
if (enumValues) {
|
|
64
|
+
// Check if all field values are in enum
|
|
65
|
+
const missingValues = fieldType.values.filter(
|
|
66
|
+
(val) => !enumValues.includes(String(val)),
|
|
67
|
+
);
|
|
68
|
+
if (missingValues.length === 0) {
|
|
69
|
+
return {
|
|
70
|
+
compatible: true,
|
|
71
|
+
severity: 'info',
|
|
72
|
+
message: `ENUM contains all required values`,
|
|
73
|
+
};
|
|
74
|
+
}
|
|
75
|
+
return {
|
|
76
|
+
compatible: true,
|
|
77
|
+
severity: 'warning',
|
|
78
|
+
message: `ENUM missing values: ${missingValues.join(', ')}`,
|
|
79
|
+
};
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
// Acceptable: string types
|
|
84
|
+
if (stringTypes.includes(dataType)) {
|
|
85
|
+
return {
|
|
86
|
+
compatible: true,
|
|
87
|
+
severity: 'info',
|
|
88
|
+
message: `Column is ${dataType} (acceptable for string one-of)`,
|
|
89
|
+
};
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
return {
|
|
93
|
+
compatible: false,
|
|
94
|
+
severity: 'error',
|
|
95
|
+
message: `Column type '${columnMeta.data_type}' is not compatible with string one-of (expected: enum, varchar, char, or text types)`,
|
|
96
|
+
};
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
/**
|
|
100
|
+
* Checks if a Sprig numeric oneOf type is compatible with MySQL column type
|
|
101
|
+
* @param {Object} fieldType - Sprig field type AST (oneOf with numeric values)
|
|
102
|
+
* @param {Object} columnMeta - Column metadata
|
|
103
|
+
* @returns {TypeCompatibilityResult}
|
|
104
|
+
*/
|
|
105
|
+
function checkNumericOneOfCompatibility(fieldType, columnMeta) {
|
|
106
|
+
const dataType = columnMeta.data_type.toLowerCase();
|
|
107
|
+
const integerTypes = ['tinyint', 'smallint', 'mediumint', 'int', 'bigint'];
|
|
108
|
+
const stringTypes = ['varchar', 'char', 'text'];
|
|
109
|
+
|
|
110
|
+
// Best case: integer types
|
|
111
|
+
if (integerTypes.includes(dataType)) {
|
|
112
|
+
return { compatible: true, severity: 'info', message: '' };
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
// Acceptable but warn: ENUM
|
|
116
|
+
if (dataType === 'enum') {
|
|
117
|
+
return {
|
|
118
|
+
compatible: true,
|
|
119
|
+
severity: 'warning',
|
|
120
|
+
message: `Column is ENUM (acceptable but numeric one-of stored as enum)`,
|
|
121
|
+
};
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
// Warn: string types (numeric enum stored as string)
|
|
125
|
+
if (stringTypes.includes(dataType)) {
|
|
126
|
+
return {
|
|
127
|
+
compatible: true,
|
|
128
|
+
severity: 'warning',
|
|
129
|
+
message: `Column is ${dataType} but prose expects numeric one-of (numeric enum stored as string)`,
|
|
130
|
+
};
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
return {
|
|
134
|
+
compatible: false,
|
|
135
|
+
severity: 'error',
|
|
136
|
+
message: `Column type '${columnMeta.data_type}' is not compatible with numeric one-of (expected: integer types)`,
|
|
137
|
+
};
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
/**
|
|
141
|
+
* Checks type compatibility between Sprig field type and MySQL column
|
|
142
|
+
* @param {Object} fieldType - Sprig field type AST
|
|
143
|
+
* @param {Object} columnMeta - MySQL column metadata
|
|
144
|
+
* @returns {TypeCompatibilityResult}
|
|
145
|
+
*/
|
|
146
|
+
export function checkTypeCompatibility(fieldType, columnMeta) {
|
|
147
|
+
if (!fieldType || !columnMeta) {
|
|
148
|
+
return {
|
|
149
|
+
compatible: false,
|
|
150
|
+
severity: 'error',
|
|
151
|
+
message: 'Missing field type or column metadata',
|
|
152
|
+
};
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
// Handle optional wrapper
|
|
156
|
+
if (fieldType.kind === 'optional') {
|
|
157
|
+
const innerResult = checkTypeCompatibility(fieldType.of, columnMeta);
|
|
158
|
+
// Keep compatibility but preserve severity
|
|
159
|
+
return innerResult;
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
// Handle primitive types
|
|
163
|
+
if (fieldType.kind === 'primitive') {
|
|
164
|
+
if (fieldType.name === 'integer') {
|
|
165
|
+
return checkIntegerCompatibility(columnMeta);
|
|
166
|
+
}
|
|
167
|
+
if (fieldType.name === 'string') {
|
|
168
|
+
const stringTypes = ['varchar', 'char', 'text', 'tinytext', 'mediumtext', 'longtext'];
|
|
169
|
+
const dataType = columnMeta.data_type.toLowerCase();
|
|
170
|
+
if (stringTypes.includes(dataType)) {
|
|
171
|
+
return { compatible: true, severity: 'info', message: '' };
|
|
172
|
+
}
|
|
173
|
+
return {
|
|
174
|
+
compatible: false,
|
|
175
|
+
severity: 'error',
|
|
176
|
+
message: `Column type '${columnMeta.data_type}' is not compatible with string (expected: varchar, char, or text types)`,
|
|
177
|
+
};
|
|
178
|
+
}
|
|
179
|
+
if (fieldType.name === 'float' || fieldType.name === 'double' || fieldType.name === 'number') {
|
|
180
|
+
const floatTypes = ['float', 'double', 'decimal', 'numeric'];
|
|
181
|
+
const dataType = columnMeta.data_type.toLowerCase();
|
|
182
|
+
if (floatTypes.includes(dataType)) {
|
|
183
|
+
return { compatible: true, severity: 'info', message: '' };
|
|
184
|
+
}
|
|
185
|
+
return {
|
|
186
|
+
compatible: false,
|
|
187
|
+
severity: 'error',
|
|
188
|
+
message: `Column type '${columnMeta.data_type}' is not compatible with ${fieldType.name} (expected: float, double, decimal, or numeric)`,
|
|
189
|
+
};
|
|
190
|
+
}
|
|
191
|
+
// Unknown primitive - skip validation
|
|
192
|
+
return { compatible: true, severity: 'info', message: '' };
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
// Handle oneOf types
|
|
196
|
+
if (fieldType.kind === 'oneOf') {
|
|
197
|
+
if (fieldType.valueType === 'string') {
|
|
198
|
+
return checkStringOneOfCompatibility(fieldType, columnMeta);
|
|
199
|
+
}
|
|
200
|
+
if (fieldType.valueType === 'number') {
|
|
201
|
+
return checkNumericOneOfCompatibility(fieldType, columnMeta);
|
|
202
|
+
}
|
|
203
|
+
// Unknown value type
|
|
204
|
+
return { compatible: true, severity: 'info', message: '' };
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
// Handle nested objects - skip for v1
|
|
208
|
+
if (fieldType.kind === 'object' || fieldType.kind === 'reference') {
|
|
209
|
+
return {
|
|
210
|
+
compatible: true,
|
|
211
|
+
severity: 'warning',
|
|
212
|
+
message: 'Nested object not validated for mysql yet',
|
|
213
|
+
};
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
// Unknown type kind - skip validation
|
|
217
|
+
return { compatible: true, severity: 'info', message: '' };
|
|
218
|
+
}
|
|
219
|
+
|