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.
Files changed (2) hide show
  1. package/cli/ilana.js +289 -1
  2. 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
  };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "ilana-orm",
3
- "version": "1.0.16",
3
+ "version": "1.0.17",
4
4
  "description": "A fully-featured, Eloquent-style ORM for Node.js with TypeScript support",
5
5
  "main": "index.js",
6
6
  "types": "index.d.ts",