ilana-orm 1.0.16 → 1.0.17
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/cli/ilana.js +289 -1
- package/package.json +1 -1
package/cli/ilana.js
CHANGED
|
@@ -749,6 +749,271 @@ module.exports = ${className}Cast;
|
|
|
749
749
|
}
|
|
750
750
|
|
|
751
751
|
|
|
752
|
+
// ─── Type Generator ───────────────────────────────────────────────────────────
|
|
753
|
+
|
|
754
|
+
const CAST_TYPE_MAP = {
|
|
755
|
+
string: 'string', number: 'number', float: 'number',
|
|
756
|
+
boolean: 'boolean', date: 'Date',
|
|
757
|
+
json: 'Record<string, any>', object: 'Record<string, any>', array: 'any[]',
|
|
758
|
+
};
|
|
759
|
+
|
|
760
|
+
// Knex column builder method → { tsType, nullable default }
|
|
761
|
+
const KNEX_COLUMN_MAP = {
|
|
762
|
+
increments: { type: 'number', nullable: false },
|
|
763
|
+
bigIncrements: { type: 'number', nullable: false },
|
|
764
|
+
integer: { type: 'number', nullable: true },
|
|
765
|
+
bigInteger: { type: 'number', nullable: true },
|
|
766
|
+
tinyint: { type: 'number', nullable: true },
|
|
767
|
+
smallint: { type: 'number', nullable: true },
|
|
768
|
+
mediumint: { type: 'number', nullable: true },
|
|
769
|
+
float: { type: 'number', nullable: true },
|
|
770
|
+
double: { type: 'number', nullable: true },
|
|
771
|
+
decimal: { type: 'number', nullable: true },
|
|
772
|
+
string: { type: 'string', nullable: true },
|
|
773
|
+
text: { type: 'string', nullable: true },
|
|
774
|
+
mediumtext: { type: 'string', nullable: true },
|
|
775
|
+
longtext: { type: 'string', nullable: true },
|
|
776
|
+
char: { type: 'string', nullable: true },
|
|
777
|
+
uuid: { type: 'string', nullable: true },
|
|
778
|
+
enum: { type: 'string', nullable: true },
|
|
779
|
+
set: { type: 'string', nullable: true },
|
|
780
|
+
boolean: { type: 'boolean', nullable: true },
|
|
781
|
+
date: { type: 'Date', nullable: true },
|
|
782
|
+
datetime: { type: 'Date', nullable: true },
|
|
783
|
+
timestamp: { type: 'Date', nullable: true },
|
|
784
|
+
time: { type: 'string', nullable: true },
|
|
785
|
+
json: { type: 'Record<string, any>', nullable: true },
|
|
786
|
+
jsonb: { type: 'Record<string, any>', nullable: true },
|
|
787
|
+
binary: { type: 'Buffer', nullable: true },
|
|
788
|
+
};
|
|
789
|
+
|
|
790
|
+
const RELATION_RETURN_MAP = {
|
|
791
|
+
hasOne: (r) => `${r} | null`,
|
|
792
|
+
hasMany: (r) => `${r}[]`,
|
|
793
|
+
belongsTo: (r) => `${r} | null`,
|
|
794
|
+
belongsToMany: (r) => `${r}[]`,
|
|
795
|
+
hasManyThrough: (r) => `${r}[]`,
|
|
796
|
+
morphTo: () => 'any',
|
|
797
|
+
morphOne: () => 'any | null',
|
|
798
|
+
morphMany: () => 'any[]',
|
|
799
|
+
};
|
|
800
|
+
|
|
801
|
+
// Parse migration files to extract column definitions for a given table name
|
|
802
|
+
function parseMigrationsForTable(tableName, migrationsDir) {
|
|
803
|
+
if (!fs.existsSync(migrationsDir)) return {};
|
|
804
|
+
|
|
805
|
+
const columns = {};
|
|
806
|
+
const files = fs.readdirSync(migrationsDir)
|
|
807
|
+
.filter(f => f.endsWith('.js') || f.endsWith('.mjs') || f.endsWith('.ts'))
|
|
808
|
+
.sort(); // oldest first so later migrations can override
|
|
809
|
+
|
|
810
|
+
for (const file of files) {
|
|
811
|
+
const content = fs.readFileSync(path.join(migrationsDir, file), 'utf8');
|
|
812
|
+
|
|
813
|
+
// Find createTable / table blocks for this table
|
|
814
|
+
const tableBlockRegex = new RegExp(
|
|
815
|
+
`(?:createTable|table)\\s*\\(\\s*['"]${tableName}['"]\\s*,\\s*(?:async\\s*)?(?:\\(\\s*)?(\\w+)\\s*(?:\\))?\\s*(?:=>|{)[\\s\\S]*?(?=\\}\\s*\\)|\\}\\s*;)`,
|
|
816
|
+
'g'
|
|
817
|
+
);
|
|
818
|
+
|
|
819
|
+
let blockMatch;
|
|
820
|
+
while ((blockMatch = tableBlockRegex.exec(content)) !== null) {
|
|
821
|
+
const block = blockMatch[0];
|
|
822
|
+
const alias = blockMatch[1] || 'table';
|
|
823
|
+
|
|
824
|
+
// Match column definitions: table.string('col'), table.integer('col').nullable(), etc.
|
|
825
|
+
const colRegex = new RegExp(
|
|
826
|
+
`${alias}\\.(\\w+)\\s*\\(\\s*['"]([^'"]+)['"](?:[^)]*)?\\)([^;\\n]*)`,
|
|
827
|
+
'g'
|
|
828
|
+
);
|
|
829
|
+
|
|
830
|
+
let colMatch;
|
|
831
|
+
while ((colMatch = colRegex.exec(block)) !== null) {
|
|
832
|
+
const [, method, colName, rest] = colMatch;
|
|
833
|
+
const knexDef = KNEX_COLUMN_MAP[method];
|
|
834
|
+
if (!knexDef) continue;
|
|
835
|
+
|
|
836
|
+
const isNullable = /\.nullable\(\)/.test(rest);
|
|
837
|
+
const isNotNullable = /\.notNullable\(\)/.test(rest);
|
|
838
|
+
const nullable = isNullable ? true : isNotNullable ? false : knexDef.nullable;
|
|
839
|
+
|
|
840
|
+
columns[colName] = { type: knexDef.type, nullable };
|
|
841
|
+
}
|
|
842
|
+
}
|
|
843
|
+
}
|
|
844
|
+
|
|
845
|
+
return columns;
|
|
846
|
+
}
|
|
847
|
+
|
|
848
|
+
function parseModelFile(content, fileName) {
|
|
849
|
+
const classMatch = content.match(/class\s+(\w+)\s+extends/);
|
|
850
|
+
const className = classMatch?.[1] || toPascalCase(path.basename(fileName, path.extname(fileName)));
|
|
851
|
+
|
|
852
|
+
const tableMatch = content.match(/static\s+table\s*=\s*['"]([^'"]+)['"]/);
|
|
853
|
+
const table = tableMatch?.[1] || pluralize(toSnakeCase(className));
|
|
854
|
+
|
|
855
|
+
const fillableMatch = content.match(/(?:static\s+)?fillable\s*=\s*\[([^\]]*)\]/s);
|
|
856
|
+
const fillable = fillableMatch
|
|
857
|
+
? [...fillableMatch[1].matchAll(/['"]([^'"]+)['"]/g)].map(m => m[1])
|
|
858
|
+
: [];
|
|
859
|
+
|
|
860
|
+
const castsMatch = content.match(/(?:static\s+)?casts\s*=\s*\{([^}]*)\}/s);
|
|
861
|
+
const casts = {};
|
|
862
|
+
if (castsMatch) {
|
|
863
|
+
for (const [, key, val] of castsMatch[1].matchAll(/['"]?(\w+)['"]?\s*:\s*['"](\w+)['"]/g)) {
|
|
864
|
+
casts[key] = val;
|
|
865
|
+
}
|
|
866
|
+
}
|
|
867
|
+
|
|
868
|
+
const pkMatch = content.match(/static\s+primaryKey\s*=\s*['"]([^'"]+)['"]/);
|
|
869
|
+
const primaryKey = pkMatch?.[1] || 'id';
|
|
870
|
+
|
|
871
|
+
const keyTypeMatch = content.match(/static\s+keyType\s*=\s*['"]([^'"]+)['"]/);
|
|
872
|
+
const keyType = (keyTypeMatch?.[1] === 'string') ? 'string' : 'number';
|
|
873
|
+
|
|
874
|
+
const timestampsMatch = content.match(/static\s+timestamps\s*=\s*(true|false)/);
|
|
875
|
+
const timestamps = timestampsMatch?.[1] !== 'false';
|
|
876
|
+
|
|
877
|
+
const softDeletesMatch = content.match(/static\s+softDeletes\s*=\s*(true|false)/);
|
|
878
|
+
const softDeletes = softDeletesMatch?.[1] === 'true';
|
|
879
|
+
|
|
880
|
+
const createdAtCol = content.match(/static\s+createdAt\s*=\s*['"]([^'"]+)['"]/)?.[1] || 'created_at';
|
|
881
|
+
const updatedAtCol = content.match(/static\s+updatedAt\s*=\s*['"]([^'"]+)['"]/)?.[1] || 'updated_at';
|
|
882
|
+
const deletedAtCol = content.match(/static\s+deletedAt\s*=\s*['"]([^'"]+)['"]/)?.[1] || 'deleted_at';
|
|
883
|
+
|
|
884
|
+
const relations = [];
|
|
885
|
+
const relRegex = /(\w+)\s*\(\s*\)\s*\{[\s\S]*?return\s+this\.(hasOne|hasMany|belongsTo|belongsToMany|hasManyThrough|morphTo|morphOne|morphMany)\s*\(\s*['"]?(\w*?)['"]?[,)]/g;
|
|
886
|
+
let m;
|
|
887
|
+
while ((m = relRegex.exec(content)) !== null) {
|
|
888
|
+
const [, methodName, relationType, relatedModel] = m;
|
|
889
|
+
if (methodName !== 'constructor') {
|
|
890
|
+
relations.push({ methodName, relationType, relatedModel: relatedModel || 'any' });
|
|
891
|
+
}
|
|
892
|
+
}
|
|
893
|
+
|
|
894
|
+
return { className, table, fillable, casts, primaryKey, keyType, timestamps, softDeletes, createdAtCol, updatedAtCol, deletedAtCol, relations };
|
|
895
|
+
}
|
|
896
|
+
|
|
897
|
+
function generateModelTypes(model, migrationColumns = {}) {
|
|
898
|
+
const { className, fillable, casts, primaryKey, keyType, timestamps, softDeletes, createdAtCol, updatedAtCol, deletedAtCol, relations } = model;
|
|
899
|
+
|
|
900
|
+
const relatedModels = [...new Set(
|
|
901
|
+
relations.map(r => r.relatedModel).filter(r => r && r !== 'any')
|
|
902
|
+
)];
|
|
903
|
+
|
|
904
|
+
const fields = [];
|
|
905
|
+
|
|
906
|
+
// Primary key — always non-nullable
|
|
907
|
+
fields.push(` ${primaryKey}: ${keyType};`);
|
|
908
|
+
|
|
909
|
+
// All columns known from migrations, minus pk and timestamp cols (handled separately)
|
|
910
|
+
const tsColNames = [primaryKey, createdAtCol, updatedAtCol, deletedAtCol];
|
|
911
|
+
const allCols = new Set([...fillable, ...Object.keys(migrationColumns)]);
|
|
912
|
+
|
|
913
|
+
for (const col of allCols) {
|
|
914
|
+
if (tsColNames.includes(col)) continue;
|
|
915
|
+
|
|
916
|
+
// Cast takes priority over migration inference
|
|
917
|
+
if (casts[col]) {
|
|
918
|
+
const tsType = CAST_TYPE_MAP[casts[col]] || 'any';
|
|
919
|
+
fields.push(` ${col}?: ${tsType};`);
|
|
920
|
+
continue;
|
|
921
|
+
}
|
|
922
|
+
|
|
923
|
+
const migCol = migrationColumns[col];
|
|
924
|
+
if (migCol) {
|
|
925
|
+
const tsType = migCol.nullable ? `${migCol.type} | null` : migCol.type;
|
|
926
|
+
fields.push(` ${col}?: ${tsType};`);
|
|
927
|
+
} else {
|
|
928
|
+
fields.push(` ${col}?: any;`);
|
|
929
|
+
}
|
|
930
|
+
}
|
|
931
|
+
|
|
932
|
+
if (timestamps) {
|
|
933
|
+
fields.push(` ${createdAtCol}?: Date;`);
|
|
934
|
+
fields.push(` ${updatedAtCol}?: Date;`);
|
|
935
|
+
}
|
|
936
|
+
|
|
937
|
+
if (softDeletes) {
|
|
938
|
+
fields.push(` ${deletedAtCol}?: Date | null;`);
|
|
939
|
+
}
|
|
940
|
+
|
|
941
|
+
for (const { methodName, relationType, relatedModel } of relations) {
|
|
942
|
+
const returnType = RELATION_RETURN_MAP[relationType]?.(relatedModel) || 'any';
|
|
943
|
+
fields.push(` ${methodName}?: ${returnType};`);
|
|
944
|
+
}
|
|
945
|
+
|
|
946
|
+
const relationMethods = relations.map(({ methodName, relationType }) => {
|
|
947
|
+
const map = { hasOne: 'HasOne', hasMany: 'HasMany', belongsTo: 'BelongsTo', belongsToMany: 'BelongsToMany', hasManyThrough: 'HasManyThrough', morphTo: 'MorphTo', morphOne: 'MorphOne', morphMany: 'MorphMany' };
|
|
948
|
+
return ` ${methodName}(): ${map[relationType] || 'any'};`;
|
|
949
|
+
});
|
|
950
|
+
|
|
951
|
+
const usedRelTypes = [...new Set(relations.map(r => {
|
|
952
|
+
const map = { hasOne: 'HasOne', hasMany: 'HasMany', belongsTo: 'BelongsTo', belongsToMany: 'BelongsToMany', hasManyThrough: 'HasManyThrough', morphTo: 'MorphTo', morphOne: 'MorphOne', morphMany: 'MorphMany' };
|
|
953
|
+
return map[r.relationType];
|
|
954
|
+
}).filter(Boolean))];
|
|
955
|
+
|
|
956
|
+
const coreImports = usedRelTypes.length
|
|
957
|
+
? `import { Model, ${usedRelTypes.join(', ')} } from 'ilana-orm';`
|
|
958
|
+
: `import { Model } from 'ilana-orm';`;
|
|
959
|
+
|
|
960
|
+
return `// Auto-generated by \`npx ilana types\` — do not edit manually
|
|
961
|
+
${coreImports}
|
|
962
|
+
${relatedModels.length ? `import type { ${relatedModels.join(', ')} } from './index';\n` : ''}
|
|
963
|
+
export interface ${className}Attributes {
|
|
964
|
+
${fields.join('\n')}
|
|
965
|
+
}
|
|
966
|
+
|
|
967
|
+
declare class ${className} extends Model<${className}Attributes> {
|
|
968
|
+
${relationMethods.length ? relationMethods.join('\n') + '\n' : ''}
|
|
969
|
+
}
|
|
970
|
+
|
|
971
|
+
export default ${className};
|
|
972
|
+
`;
|
|
973
|
+
}
|
|
974
|
+
|
|
975
|
+
async function generateTypes(outDir) {
|
|
976
|
+
// Only meaningful in TypeScript projects
|
|
977
|
+
if (!isTypeScriptProject()) return;
|
|
978
|
+
|
|
979
|
+
const structure = getProjectStructure();
|
|
980
|
+
const modelsDir = path.join(process.cwd(), structure.modelsDir);
|
|
981
|
+
const migrationsDir = path.join(process.cwd(), structure.databaseDir, 'migrations');
|
|
982
|
+
|
|
983
|
+
if (!fs.existsSync(modelsDir)) return;
|
|
984
|
+
|
|
985
|
+
const modelFiles = fs.readdirSync(modelsDir)
|
|
986
|
+
.filter(f => f.endsWith('.js') || f.endsWith('.ts'))
|
|
987
|
+
.filter(f => !f.endsWith('.d.ts'));
|
|
988
|
+
|
|
989
|
+
if (modelFiles.length === 0) return;
|
|
990
|
+
|
|
991
|
+
const outputDir = path.join(process.cwd(), outDir);
|
|
992
|
+
if (!fs.existsSync(outputDir)) fs.mkdirSync(outputDir, { recursive: true });
|
|
993
|
+
|
|
994
|
+
const classNames = [];
|
|
995
|
+
|
|
996
|
+
for (const file of modelFiles) {
|
|
997
|
+
const content = fs.readFileSync(path.join(modelsDir, file), 'utf8');
|
|
998
|
+
const model = parseModelFile(content, file);
|
|
999
|
+
const migrationColumns = parseMigrationsForTable(model.table, migrationsDir);
|
|
1000
|
+
const dts = generateModelTypes(model, migrationColumns);
|
|
1001
|
+
const outFile = path.join(outputDir, `${model.className}.d.ts`);
|
|
1002
|
+
fs.writeFileSync(outFile, dts);
|
|
1003
|
+
console.log(` Generated: ${path.relative(process.cwd(), outFile)}`);
|
|
1004
|
+
classNames.push(model.className);
|
|
1005
|
+
}
|
|
1006
|
+
|
|
1007
|
+
const indexContent = classNames
|
|
1008
|
+
.map(n => `export { default as ${n}, type ${n}Attributes } from './${n}';`)
|
|
1009
|
+
.join('\n') + '\n';
|
|
1010
|
+
fs.writeFileSync(path.join(outputDir, 'index.d.ts'), `// Auto-generated by \`npx ilana types\` — do not edit manually\n${indexContent}`);
|
|
1011
|
+
console.log(` Generated: ${path.relative(process.cwd(), path.join(outputDir, 'index.d.ts'))}`);
|
|
1012
|
+
console.log(`\n✓ ${classNames.length} model type${classNames.length !== 1 ? 's' : ''} generated in ${outDir}/`);
|
|
1013
|
+
}
|
|
1014
|
+
|
|
1015
|
+
// ─── Commands ─────────────────────────────────────────────────────────────────
|
|
1016
|
+
|
|
752
1017
|
const commands = {
|
|
753
1018
|
async setup() {
|
|
754
1019
|
console.log('Setting up Ilana ORM...');
|
|
@@ -920,6 +1185,7 @@ DB_TIMEZONE=UTC
|
|
|
920
1185
|
}
|
|
921
1186
|
|
|
922
1187
|
generateModel(name, options);
|
|
1188
|
+
await generateTypes('types');
|
|
923
1189
|
},
|
|
924
1190
|
|
|
925
1191
|
async migrate(...args) {
|
|
@@ -953,6 +1219,7 @@ DB_TIMEZONE=UTC
|
|
|
953
1219
|
}
|
|
954
1220
|
|
|
955
1221
|
await runner.migrate(connection, onlyFile, toFile);
|
|
1222
|
+
await generateTypes('types');
|
|
956
1223
|
process.exit(0);
|
|
957
1224
|
},
|
|
958
1225
|
|
|
@@ -979,6 +1246,7 @@ DB_TIMEZONE=UTC
|
|
|
979
1246
|
await commands.seed();
|
|
980
1247
|
}
|
|
981
1248
|
|
|
1249
|
+
await generateTypes('types');
|
|
982
1250
|
process.exit(0);
|
|
983
1251
|
},
|
|
984
1252
|
|
|
@@ -1043,6 +1311,7 @@ DB_TIMEZONE=UTC
|
|
|
1043
1311
|
await initializeDatabase();
|
|
1044
1312
|
const runner = new MigrationRunner();
|
|
1045
1313
|
await runner.refresh(connection);
|
|
1314
|
+
await generateTypes('types');
|
|
1046
1315
|
process.exit(0);
|
|
1047
1316
|
},
|
|
1048
1317
|
|
|
@@ -1166,6 +1435,20 @@ DB_TIMEZONE=UTC
|
|
|
1166
1435
|
}
|
|
1167
1436
|
},
|
|
1168
1437
|
|
|
1438
|
+
async types(...args) {
|
|
1439
|
+
if (!isTypeScriptProject()) {
|
|
1440
|
+
console.log('Skipping type generation — not a TypeScript project.');
|
|
1441
|
+
return;
|
|
1442
|
+
}
|
|
1443
|
+
let outDir = 'types';
|
|
1444
|
+
for (const arg of args) {
|
|
1445
|
+
if (arg.startsWith('--out=')) outDir = arg.split('=')[1];
|
|
1446
|
+
else if (!arg.startsWith('--')) outDir = arg;
|
|
1447
|
+
}
|
|
1448
|
+
console.log('Generating model types...\n');
|
|
1449
|
+
await generateTypes(outDir);
|
|
1450
|
+
},
|
|
1451
|
+
|
|
1169
1452
|
async 'make:cast'(name) {
|
|
1170
1453
|
if (!name) {
|
|
1171
1454
|
console.error('Cast name is required');
|
|
@@ -1220,7 +1503,10 @@ Available commands:
|
|
|
1220
1503
|
seed [name] Run database seeders
|
|
1221
1504
|
db:seed [name] Alias for seed command
|
|
1222
1505
|
db:wipe [connection] Drop all tables
|
|
1223
|
-
|
|
1506
|
+
|
|
1507
|
+
types [--out=dir] Generate TypeScript types for all models
|
|
1508
|
+
Default output: types/
|
|
1509
|
+
|
|
1224
1510
|
help Show this help message
|
|
1225
1511
|
|
|
1226
1512
|
Examples:
|
|
@@ -1239,6 +1525,8 @@ Examples:
|
|
|
1239
1525
|
ilana migrate:fresh --seed
|
|
1240
1526
|
ilana seed UserSeeder
|
|
1241
1527
|
ilana db:wipe
|
|
1528
|
+
ilana types
|
|
1529
|
+
ilana types --out=src/types
|
|
1242
1530
|
`);
|
|
1243
1531
|
}
|
|
1244
1532
|
};
|