webspresso 0.0.6 → 0.0.8
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/README.md +506 -2
- package/bin/webspresso.js +357 -10
- package/core/applySchema.js +1 -0
- package/core/compileSchema.js +1 -0
- package/core/orm/eager-loader.js +232 -0
- package/core/orm/index.js +148 -0
- package/core/orm/migrations/index.js +205 -0
- package/core/orm/migrations/scaffold.js +312 -0
- package/core/orm/model.js +178 -0
- package/core/orm/query-builder.js +430 -0
- package/core/orm/repository.js +346 -0
- package/core/orm/schema-helpers.js +416 -0
- package/core/orm/scopes.js +183 -0
- package/core/orm/seeder.js +585 -0
- package/core/orm/transaction.js +69 -0
- package/core/orm/types.js +237 -0
- package/core/orm/utils.js +127 -0
- package/index.js +13 -1
- package/package.json +24 -3
- package/src/plugin-manager.js +1 -0
- package/utils/schemaCache.js +1 -0
package/bin/webspresso.js
CHANGED
|
@@ -18,25 +18,113 @@ program
|
|
|
18
18
|
|
|
19
19
|
// New project command
|
|
20
20
|
program
|
|
21
|
-
.command('new
|
|
21
|
+
.command('new [project-name]')
|
|
22
22
|
.description('Create a new Webspresso project')
|
|
23
23
|
.option('-t, --template <template>', 'Template to use (minimal, full)', 'minimal')
|
|
24
24
|
.option('--no-tailwind', 'Skip Tailwind CSS setup')
|
|
25
25
|
.option('-i, --install', 'Auto install dependencies and build CSS')
|
|
26
|
-
.action(async (
|
|
26
|
+
.action(async (projectNameArg, options) => {
|
|
27
27
|
const useTailwind = options.tailwind !== false;
|
|
28
28
|
const autoInstall = options.install === true;
|
|
29
|
-
const projectPath = path.resolve(projectName);
|
|
30
29
|
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
30
|
+
let projectName;
|
|
31
|
+
let projectPath;
|
|
32
|
+
let useCurrentDir = false;
|
|
33
|
+
|
|
34
|
+
if (!projectNameArg) {
|
|
35
|
+
// No project name provided - ask if they want to use current directory
|
|
36
|
+
const currentDirName = path.basename(process.cwd());
|
|
37
|
+
const currentDirFiles = fs.readdirSync(process.cwd());
|
|
38
|
+
const hasExistingFiles = currentDirFiles.some(f => !f.startsWith('.'));
|
|
39
|
+
|
|
40
|
+
const { useCurrent } = await inquirer.prompt([
|
|
41
|
+
{
|
|
42
|
+
type: 'confirm',
|
|
43
|
+
name: 'useCurrent',
|
|
44
|
+
message: `Install in current directory (${currentDirName})?`,
|
|
45
|
+
default: true
|
|
46
|
+
}
|
|
47
|
+
]);
|
|
48
|
+
|
|
49
|
+
if (useCurrent) {
|
|
50
|
+
useCurrentDir = true;
|
|
51
|
+
projectPath = process.cwd();
|
|
52
|
+
|
|
53
|
+
// Check for existing Webspresso files
|
|
54
|
+
if (fs.existsSync(path.join(projectPath, 'server.js')) ||
|
|
55
|
+
fs.existsSync(path.join(projectPath, 'pages'))) {
|
|
56
|
+
console.error('❌ Current directory already contains a Webspresso project!');
|
|
57
|
+
process.exit(1);
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
// Warn if there are existing files
|
|
61
|
+
if (hasExistingFiles) {
|
|
62
|
+
const { continueAnyway } = await inquirer.prompt([
|
|
63
|
+
{
|
|
64
|
+
type: 'confirm',
|
|
65
|
+
name: 'continueAnyway',
|
|
66
|
+
message: '⚠️ Current directory is not empty. Continue anyway?',
|
|
67
|
+
default: false
|
|
68
|
+
}
|
|
69
|
+
]);
|
|
70
|
+
|
|
71
|
+
if (!continueAnyway) {
|
|
72
|
+
console.log('Cancelled.');
|
|
73
|
+
process.exit(0);
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
// Ask for project name (for package.json)
|
|
78
|
+
const { name } = await inquirer.prompt([
|
|
79
|
+
{
|
|
80
|
+
type: 'input',
|
|
81
|
+
name: 'name',
|
|
82
|
+
message: 'Project name:',
|
|
83
|
+
default: currentDirName,
|
|
84
|
+
validate: (input) => {
|
|
85
|
+
if (!input.trim()) return 'Project name is required';
|
|
86
|
+
if (!/^[a-z0-9-_]+$/i.test(input)) return 'Project name can only contain letters, numbers, hyphens, and underscores';
|
|
87
|
+
return true;
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
]);
|
|
91
|
+
|
|
92
|
+
projectName = name;
|
|
93
|
+
} else {
|
|
94
|
+
// Ask for directory name
|
|
95
|
+
const { dirName } = await inquirer.prompt([
|
|
96
|
+
{
|
|
97
|
+
type: 'input',
|
|
98
|
+
name: 'dirName',
|
|
99
|
+
message: 'Project directory name:',
|
|
100
|
+
validate: (input) => {
|
|
101
|
+
if (!input.trim()) return 'Directory name is required';
|
|
102
|
+
if (!/^[a-z0-9-_]+$/i.test(input)) return 'Directory name can only contain letters, numbers, hyphens, and underscores';
|
|
103
|
+
if (fs.existsSync(path.resolve(input))) return `Directory ${input} already exists!`;
|
|
104
|
+
return true;
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
]);
|
|
108
|
+
|
|
109
|
+
projectName = dirName;
|
|
110
|
+
projectPath = path.resolve(dirName);
|
|
111
|
+
}
|
|
112
|
+
} else {
|
|
113
|
+
projectName = projectNameArg;
|
|
114
|
+
projectPath = path.resolve(projectNameArg);
|
|
115
|
+
|
|
116
|
+
if (fs.existsSync(projectPath)) {
|
|
117
|
+
console.error(`❌ Directory ${projectName} already exists!`);
|
|
118
|
+
process.exit(1);
|
|
119
|
+
}
|
|
34
120
|
}
|
|
35
121
|
|
|
36
122
|
console.log(`\n🚀 Creating new Webspresso project: ${projectName}\n`);
|
|
37
123
|
|
|
38
|
-
// Create directory structure
|
|
39
|
-
|
|
124
|
+
// Create directory structure (skip root if using current dir)
|
|
125
|
+
if (!useCurrentDir) {
|
|
126
|
+
fs.mkdirSync(projectPath, { recursive: true });
|
|
127
|
+
}
|
|
40
128
|
fs.mkdirSync(path.join(projectPath, 'pages'), { recursive: true });
|
|
41
129
|
fs.mkdirSync(path.join(projectPath, 'pages', 'locales'), { recursive: true });
|
|
42
130
|
fs.mkdirSync(path.join(projectPath, 'views'), { recursive: true });
|
|
@@ -361,7 +449,9 @@ module.exports = {
|
|
|
361
449
|
|
|
362
450
|
console.log('\n✅ Project ready!\n');
|
|
363
451
|
console.log('Start developing:');
|
|
364
|
-
|
|
452
|
+
if (!useCurrentDir) {
|
|
453
|
+
console.log(` cd ${projectName}`);
|
|
454
|
+
}
|
|
365
455
|
console.log(' npm run dev\n');
|
|
366
456
|
} catch (err) {
|
|
367
457
|
console.error('❌ Installation failed:', err.message);
|
|
@@ -370,7 +460,9 @@ module.exports = {
|
|
|
370
460
|
} else {
|
|
371
461
|
console.log('\n✅ Project created successfully!\n');
|
|
372
462
|
console.log('Next steps:');
|
|
373
|
-
|
|
463
|
+
if (!useCurrentDir) {
|
|
464
|
+
console.log(` cd ${projectName}`);
|
|
465
|
+
}
|
|
374
466
|
console.log(' npm install');
|
|
375
467
|
if (useTailwind) {
|
|
376
468
|
console.log(' npm run build:css');
|
|
@@ -785,6 +877,261 @@ module.exports = {
|
|
|
785
877
|
}
|
|
786
878
|
});
|
|
787
879
|
|
|
880
|
+
// ============================================================================
|
|
881
|
+
// Database Commands
|
|
882
|
+
// ============================================================================
|
|
883
|
+
|
|
884
|
+
/**
|
|
885
|
+
* Load database configuration
|
|
886
|
+
* @param {string} [configPath] - Custom config path
|
|
887
|
+
* @returns {Object} Database config
|
|
888
|
+
*/
|
|
889
|
+
function loadDbConfig(configPath) {
|
|
890
|
+
const defaultPaths = ['webspresso.db.js', 'knexfile.js'];
|
|
891
|
+
const paths = configPath ? [configPath, ...defaultPaths] : defaultPaths;
|
|
892
|
+
|
|
893
|
+
for (const p of paths) {
|
|
894
|
+
const fullPath = path.resolve(process.cwd(), p);
|
|
895
|
+
if (fs.existsSync(fullPath)) {
|
|
896
|
+
return { config: require(fullPath), path: fullPath };
|
|
897
|
+
}
|
|
898
|
+
}
|
|
899
|
+
|
|
900
|
+
console.error('❌ Database config not found. Create webspresso.db.js or knexfile.js');
|
|
901
|
+
process.exit(1);
|
|
902
|
+
}
|
|
903
|
+
|
|
904
|
+
/**
|
|
905
|
+
* Create database instance from config
|
|
906
|
+
* @param {Object} config - Database config
|
|
907
|
+
* @param {string} [env] - Environment name
|
|
908
|
+
* @returns {Promise<Object>} Database instance
|
|
909
|
+
*/
|
|
910
|
+
async function createDbInstance(config, env) {
|
|
911
|
+
const environment = env || process.env.NODE_ENV || 'development';
|
|
912
|
+
const dbConfig = config[environment] || config;
|
|
913
|
+
|
|
914
|
+
// Dynamic import knex
|
|
915
|
+
let knex;
|
|
916
|
+
try {
|
|
917
|
+
knex = require('knex');
|
|
918
|
+
} catch {
|
|
919
|
+
console.error('❌ Knex not installed. Run: npm install knex');
|
|
920
|
+
process.exit(1);
|
|
921
|
+
}
|
|
922
|
+
|
|
923
|
+
return knex(dbConfig);
|
|
924
|
+
}
|
|
925
|
+
|
|
926
|
+
// db:migrate command
|
|
927
|
+
program
|
|
928
|
+
.command('db:migrate')
|
|
929
|
+
.description('Run pending database migrations')
|
|
930
|
+
.option('-e, --env <environment>', 'Environment (development, production)', 'development')
|
|
931
|
+
.option('-c, --config <path>', 'Path to database config file')
|
|
932
|
+
.action(async (options) => {
|
|
933
|
+
const { config, path: configPath } = loadDbConfig(options.config);
|
|
934
|
+
console.log(`\n📦 Using config: ${configPath}`);
|
|
935
|
+
console.log(` Environment: ${options.env}\n`);
|
|
936
|
+
|
|
937
|
+
const knex = await createDbInstance(config, options.env);
|
|
938
|
+
|
|
939
|
+
try {
|
|
940
|
+
const migrationConfig = config.migrations || {};
|
|
941
|
+
const [batch, migrations] = await knex.migrate.latest(migrationConfig);
|
|
942
|
+
|
|
943
|
+
if (migrations.length === 0) {
|
|
944
|
+
console.log('✅ Already up to date.\n');
|
|
945
|
+
} else {
|
|
946
|
+
console.log(`Running migrations (batch ${batch}):`);
|
|
947
|
+
for (const m of migrations) {
|
|
948
|
+
console.log(` → ${m}`);
|
|
949
|
+
}
|
|
950
|
+
console.log(`\n✅ Done. ${migrations.length} migration(s) completed.\n`);
|
|
951
|
+
}
|
|
952
|
+
} catch (err) {
|
|
953
|
+
console.error('❌ Migration failed:', err.message);
|
|
954
|
+
process.exit(1);
|
|
955
|
+
} finally {
|
|
956
|
+
await knex.destroy();
|
|
957
|
+
}
|
|
958
|
+
});
|
|
959
|
+
|
|
960
|
+
// db:rollback command
|
|
961
|
+
program
|
|
962
|
+
.command('db:rollback')
|
|
963
|
+
.description('Rollback the last batch of migrations')
|
|
964
|
+
.option('-e, --env <environment>', 'Environment (development, production)', 'development')
|
|
965
|
+
.option('-c, --config <path>', 'Path to database config file')
|
|
966
|
+
.option('-a, --all', 'Rollback all migrations')
|
|
967
|
+
.action(async (options) => {
|
|
968
|
+
const { config, path: configPath } = loadDbConfig(options.config);
|
|
969
|
+
console.log(`\n📦 Using config: ${configPath}`);
|
|
970
|
+
console.log(` Environment: ${options.env}\n`);
|
|
971
|
+
|
|
972
|
+
const knex = await createDbInstance(config, options.env);
|
|
973
|
+
|
|
974
|
+
try {
|
|
975
|
+
const migrationConfig = {
|
|
976
|
+
...(config.migrations || {}),
|
|
977
|
+
...(options.all ? { all: true } : {}),
|
|
978
|
+
};
|
|
979
|
+
|
|
980
|
+
const [batch, migrations] = await knex.migrate.rollback(migrationConfig);
|
|
981
|
+
|
|
982
|
+
if (migrations.length === 0) {
|
|
983
|
+
console.log('✅ Nothing to rollback.\n');
|
|
984
|
+
} else {
|
|
985
|
+
console.log(`Rolling back${options.all ? ' all' : ''} migrations:`);
|
|
986
|
+
for (const m of migrations) {
|
|
987
|
+
console.log(` ← ${m}`);
|
|
988
|
+
}
|
|
989
|
+
console.log(`\n✅ Done. ${migrations.length} migration(s) rolled back.\n`);
|
|
990
|
+
}
|
|
991
|
+
} catch (err) {
|
|
992
|
+
console.error('❌ Rollback failed:', err.message);
|
|
993
|
+
process.exit(1);
|
|
994
|
+
} finally {
|
|
995
|
+
await knex.destroy();
|
|
996
|
+
}
|
|
997
|
+
});
|
|
998
|
+
|
|
999
|
+
// db:status command
|
|
1000
|
+
program
|
|
1001
|
+
.command('db:status')
|
|
1002
|
+
.description('Show migration status')
|
|
1003
|
+
.option('-e, --env <environment>', 'Environment (development, production)', 'development')
|
|
1004
|
+
.option('-c, --config <path>', 'Path to database config file')
|
|
1005
|
+
.action(async (options) => {
|
|
1006
|
+
const { config, path: configPath } = loadDbConfig(options.config);
|
|
1007
|
+
console.log(`\n📦 Using config: ${configPath}`);
|
|
1008
|
+
console.log(` Environment: ${options.env}\n`);
|
|
1009
|
+
|
|
1010
|
+
const knex = await createDbInstance(config, options.env);
|
|
1011
|
+
|
|
1012
|
+
try {
|
|
1013
|
+
const migrationConfig = config.migrations || {};
|
|
1014
|
+
const [completed, pending] = await knex.migrate.list(migrationConfig);
|
|
1015
|
+
|
|
1016
|
+
console.log('Migration Status');
|
|
1017
|
+
console.log('================\n');
|
|
1018
|
+
|
|
1019
|
+
// Sort all migrations by name
|
|
1020
|
+
const all = [
|
|
1021
|
+
...completed.map(m => ({ name: m.name || m, completed: true })),
|
|
1022
|
+
...pending.map(m => ({ name: m.name || m, completed: false })),
|
|
1023
|
+
].sort((a, b) => a.name.localeCompare(b.name));
|
|
1024
|
+
|
|
1025
|
+
if (all.length === 0) {
|
|
1026
|
+
console.log(' No migrations found.\n');
|
|
1027
|
+
} else {
|
|
1028
|
+
for (const m of all) {
|
|
1029
|
+
const status = m.completed ? '✓' : '○';
|
|
1030
|
+
const suffix = m.completed ? '' : ' (pending)';
|
|
1031
|
+
console.log(` ${status} ${m.name}${suffix}`);
|
|
1032
|
+
}
|
|
1033
|
+
console.log(`\n Total: ${all.length} (${completed.length} completed, ${pending.length} pending)\n`);
|
|
1034
|
+
}
|
|
1035
|
+
} catch (err) {
|
|
1036
|
+
console.error('❌ Failed to get status:', err.message);
|
|
1037
|
+
process.exit(1);
|
|
1038
|
+
} finally {
|
|
1039
|
+
await knex.destroy();
|
|
1040
|
+
}
|
|
1041
|
+
});
|
|
1042
|
+
|
|
1043
|
+
// db:make command
|
|
1044
|
+
program
|
|
1045
|
+
.command('db:make <name>')
|
|
1046
|
+
.description('Create a new migration file')
|
|
1047
|
+
.option('-c, --config <path>', 'Path to database config file')
|
|
1048
|
+
.option('-m, --model <model>', 'Generate migration from model (requires models directory)')
|
|
1049
|
+
.action(async (name, options) => {
|
|
1050
|
+
const { config, path: configPath } = loadDbConfig(options.config);
|
|
1051
|
+
console.log(`\n📦 Using config: ${configPath}\n`);
|
|
1052
|
+
|
|
1053
|
+
const migrationDir = config.migrations?.directory || './migrations';
|
|
1054
|
+
|
|
1055
|
+
// Ensure migrations directory exists
|
|
1056
|
+
if (!fs.existsSync(migrationDir)) {
|
|
1057
|
+
fs.mkdirSync(migrationDir, { recursive: true });
|
|
1058
|
+
console.log(`Created directory: ${migrationDir}`);
|
|
1059
|
+
}
|
|
1060
|
+
|
|
1061
|
+
// Generate filename with timestamp
|
|
1062
|
+
const now = new Date();
|
|
1063
|
+
const timestamp = [
|
|
1064
|
+
now.getFullYear(),
|
|
1065
|
+
String(now.getMonth() + 1).padStart(2, '0'),
|
|
1066
|
+
String(now.getDate()).padStart(2, '0'),
|
|
1067
|
+
'_',
|
|
1068
|
+
String(now.getHours()).padStart(2, '0'),
|
|
1069
|
+
String(now.getMinutes()).padStart(2, '0'),
|
|
1070
|
+
String(now.getSeconds()).padStart(2, '0'),
|
|
1071
|
+
].join('');
|
|
1072
|
+
|
|
1073
|
+
const filename = `${timestamp}_${name}.js`;
|
|
1074
|
+
const filepath = path.join(migrationDir, filename);
|
|
1075
|
+
|
|
1076
|
+
let content;
|
|
1077
|
+
|
|
1078
|
+
if (options.model) {
|
|
1079
|
+
// Try to load model and generate migration from schema
|
|
1080
|
+
const modelsDir = config.models || './models';
|
|
1081
|
+
const modelPath = path.resolve(process.cwd(), modelsDir, `${options.model}.js`);
|
|
1082
|
+
|
|
1083
|
+
if (fs.existsSync(modelPath)) {
|
|
1084
|
+
try {
|
|
1085
|
+
const model = require(modelPath);
|
|
1086
|
+
const { scaffoldMigration } = require('../core/orm/migrations/scaffold');
|
|
1087
|
+
content = scaffoldMigration(model);
|
|
1088
|
+
console.log(`Generated migration from model: ${options.model}`);
|
|
1089
|
+
} catch (err) {
|
|
1090
|
+
console.warn(`⚠️ Could not generate from model: ${err.message}`);
|
|
1091
|
+
console.log(' Creating empty migration instead.\n');
|
|
1092
|
+
content = getDefaultMigrationContent(name);
|
|
1093
|
+
}
|
|
1094
|
+
} else {
|
|
1095
|
+
console.warn(`⚠️ Model not found: ${modelPath}`);
|
|
1096
|
+
console.log(' Creating empty migration instead.\n');
|
|
1097
|
+
content = getDefaultMigrationContent(name);
|
|
1098
|
+
}
|
|
1099
|
+
} else {
|
|
1100
|
+
content = getDefaultMigrationContent(name);
|
|
1101
|
+
}
|
|
1102
|
+
|
|
1103
|
+
fs.writeFileSync(filepath, content);
|
|
1104
|
+
console.log(`✅ Created: ${filepath}\n`);
|
|
1105
|
+
});
|
|
1106
|
+
|
|
1107
|
+
/**
|
|
1108
|
+
* Get default migration content
|
|
1109
|
+
* @param {string} name - Migration name
|
|
1110
|
+
* @returns {string}
|
|
1111
|
+
*/
|
|
1112
|
+
function getDefaultMigrationContent(name) {
|
|
1113
|
+
// Parse table name from migration name (e.g., create_users_table -> users)
|
|
1114
|
+
const match = name.match(/^create_(\w+)_table$/);
|
|
1115
|
+
const tableName = match ? match[1] : 'table_name';
|
|
1116
|
+
|
|
1117
|
+
return `/**
|
|
1118
|
+
* Migration: ${name}
|
|
1119
|
+
*/
|
|
1120
|
+
|
|
1121
|
+
exports.up = function(knex) {
|
|
1122
|
+
return knex.schema.createTable('${tableName}', (table) => {
|
|
1123
|
+
table.bigIncrements('id').primary();
|
|
1124
|
+
// Add your columns here
|
|
1125
|
+
table.timestamps(true, true);
|
|
1126
|
+
});
|
|
1127
|
+
};
|
|
1128
|
+
|
|
1129
|
+
exports.down = function(knex) {
|
|
1130
|
+
return knex.schema.dropTableIfExists('${tableName}');
|
|
1131
|
+
};
|
|
1132
|
+
`;
|
|
1133
|
+
}
|
|
1134
|
+
|
|
788
1135
|
// Parse arguments
|
|
789
1136
|
program.parse();
|
|
790
1137
|
|
package/core/applySchema.js
CHANGED
package/core/compileSchema.js
CHANGED
|
@@ -0,0 +1,232 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Webspresso ORM - Eager Loader
|
|
3
|
+
* Batch loading algorithm for relations
|
|
4
|
+
* @module core/orm/eager-loader
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
const { resolveRelationModel, getRelationKeys } = require('./model');
|
|
8
|
+
const { applyScopes, createScopeContext } = require('./scopes');
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* Load relations for a set of records using batch queries
|
|
12
|
+
* @param {Object[]} records - Records to load relations for
|
|
13
|
+
* @param {string[]} relationNames - Names of relations to load
|
|
14
|
+
* @param {import('./types').ModelDefinition} model - Model definition
|
|
15
|
+
* @param {import('knex').Knex|import('knex').Knex.Transaction} knex - Knex instance
|
|
16
|
+
* @param {import('./types').ScopeContext} [scopeContext] - Scope context
|
|
17
|
+
* @returns {Promise<Object[]>} Records with relations attached
|
|
18
|
+
*/
|
|
19
|
+
async function loadRelations(records, relationNames, model, knex, scopeContext) {
|
|
20
|
+
if (!records.length || !relationNames.length) {
|
|
21
|
+
return records;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
const context = scopeContext || createScopeContext();
|
|
25
|
+
|
|
26
|
+
// Process each relation
|
|
27
|
+
for (const relationName of relationNames) {
|
|
28
|
+
const relation = model.relations[relationName];
|
|
29
|
+
if (!relation) {
|
|
30
|
+
console.warn(`Relation "${relationName}" not found on model "${model.name}"`);
|
|
31
|
+
continue;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
const { localKey, foreignKey, relatedModel } = getRelationKeys(model, relationName);
|
|
35
|
+
|
|
36
|
+
switch (relation.type) {
|
|
37
|
+
case 'belongsTo':
|
|
38
|
+
await loadBelongsTo(records, relationName, localKey, foreignKey, relatedModel, knex, context);
|
|
39
|
+
break;
|
|
40
|
+
case 'hasMany':
|
|
41
|
+
await loadHasMany(records, relationName, localKey, foreignKey, relatedModel, knex, context);
|
|
42
|
+
break;
|
|
43
|
+
case 'hasOne':
|
|
44
|
+
await loadHasOne(records, relationName, localKey, foreignKey, relatedModel, knex, context);
|
|
45
|
+
break;
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
return records;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* Load belongsTo relation
|
|
54
|
+
* Parent record has foreign key pointing to related record's primary key
|
|
55
|
+
* Example: User belongsTo Company (user.company_id -> company.id)
|
|
56
|
+
*
|
|
57
|
+
* @param {Object[]} records - Parent records
|
|
58
|
+
* @param {string} relationName - Relation name
|
|
59
|
+
* @param {string} localKey - Local primary key (typically 'id')
|
|
60
|
+
* @param {string} foreignKey - Foreign key on parent (e.g., 'company_id')
|
|
61
|
+
* @param {import('./types').ModelDefinition} relatedModel - Related model
|
|
62
|
+
* @param {import('knex').Knex} knex - Knex instance
|
|
63
|
+
* @param {import('./types').ScopeContext} scopeContext - Scope context
|
|
64
|
+
*/
|
|
65
|
+
async function loadBelongsTo(records, relationName, localKey, foreignKey, relatedModel, knex, scopeContext) {
|
|
66
|
+
// Collect unique foreign key values
|
|
67
|
+
const foreignKeyValues = [...new Set(
|
|
68
|
+
records
|
|
69
|
+
.map(r => r[foreignKey])
|
|
70
|
+
.filter(v => v !== null && v !== undefined)
|
|
71
|
+
)];
|
|
72
|
+
|
|
73
|
+
if (foreignKeyValues.length === 0) {
|
|
74
|
+
// No foreign keys, set all relations to null
|
|
75
|
+
for (const record of records) {
|
|
76
|
+
record[relationName] = null;
|
|
77
|
+
}
|
|
78
|
+
return;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
// Build query for related records
|
|
82
|
+
let qb = knex(relatedModel.table).whereIn(relatedModel.primaryKey, foreignKeyValues);
|
|
83
|
+
|
|
84
|
+
// Apply scopes to related model
|
|
85
|
+
qb = applyScopes(qb, scopeContext, relatedModel);
|
|
86
|
+
|
|
87
|
+
const relatedRecords = await qb;
|
|
88
|
+
|
|
89
|
+
// Index related records by primary key
|
|
90
|
+
const relatedMap = new Map();
|
|
91
|
+
for (const related of relatedRecords) {
|
|
92
|
+
relatedMap.set(related[relatedModel.primaryKey], related);
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
// Attach related records to parent records
|
|
96
|
+
for (const record of records) {
|
|
97
|
+
const fkValue = record[foreignKey];
|
|
98
|
+
record[relationName] = fkValue !== null && fkValue !== undefined
|
|
99
|
+
? relatedMap.get(fkValue) || null
|
|
100
|
+
: null;
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
/**
|
|
105
|
+
* Load hasMany relation
|
|
106
|
+
* Related records have foreign key pointing to parent record's primary key
|
|
107
|
+
* Example: User hasMany Posts (post.user_id -> user.id)
|
|
108
|
+
*
|
|
109
|
+
* @param {Object[]} records - Parent records
|
|
110
|
+
* @param {string} relationName - Relation name
|
|
111
|
+
* @param {string} localKey - Local primary key (typically 'id')
|
|
112
|
+
* @param {string} foreignKey - Foreign key on related records (e.g., 'user_id')
|
|
113
|
+
* @param {import('./types').ModelDefinition} relatedModel - Related model
|
|
114
|
+
* @param {import('knex').Knex} knex - Knex instance
|
|
115
|
+
* @param {import('./types').ScopeContext} scopeContext - Scope context
|
|
116
|
+
*/
|
|
117
|
+
async function loadHasMany(records, relationName, localKey, foreignKey, relatedModel, knex, scopeContext) {
|
|
118
|
+
// Collect unique primary key values from parent records
|
|
119
|
+
const primaryKeyValues = [...new Set(
|
|
120
|
+
records
|
|
121
|
+
.map(r => r[localKey])
|
|
122
|
+
.filter(v => v !== null && v !== undefined)
|
|
123
|
+
)];
|
|
124
|
+
|
|
125
|
+
if (primaryKeyValues.length === 0) {
|
|
126
|
+
// No primary keys, set all relations to empty arrays
|
|
127
|
+
for (const record of records) {
|
|
128
|
+
record[relationName] = [];
|
|
129
|
+
}
|
|
130
|
+
return;
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
// Build query for related records
|
|
134
|
+
let qb = knex(relatedModel.table).whereIn(foreignKey, primaryKeyValues);
|
|
135
|
+
|
|
136
|
+
// Apply scopes to related model
|
|
137
|
+
qb = applyScopes(qb, scopeContext, relatedModel);
|
|
138
|
+
|
|
139
|
+
const relatedRecords = await qb;
|
|
140
|
+
|
|
141
|
+
// Group related records by foreign key
|
|
142
|
+
const relatedGroups = new Map();
|
|
143
|
+
for (const related of relatedRecords) {
|
|
144
|
+
const fkValue = related[foreignKey];
|
|
145
|
+
if (!relatedGroups.has(fkValue)) {
|
|
146
|
+
relatedGroups.set(fkValue, []);
|
|
147
|
+
}
|
|
148
|
+
relatedGroups.get(fkValue).push(related);
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
// Attach related records to parent records
|
|
152
|
+
for (const record of records) {
|
|
153
|
+
const pkValue = record[localKey];
|
|
154
|
+
record[relationName] = relatedGroups.get(pkValue) || [];
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
/**
|
|
159
|
+
* Load hasOne relation
|
|
160
|
+
* Related record has foreign key pointing to parent record's primary key
|
|
161
|
+
* Returns single related record (or null)
|
|
162
|
+
*
|
|
163
|
+
* @param {Object[]} records - Parent records
|
|
164
|
+
* @param {string} relationName - Relation name
|
|
165
|
+
* @param {string} localKey - Local primary key (typically 'id')
|
|
166
|
+
* @param {string} foreignKey - Foreign key on related record
|
|
167
|
+
* @param {import('./types').ModelDefinition} relatedModel - Related model
|
|
168
|
+
* @param {import('knex').Knex} knex - Knex instance
|
|
169
|
+
* @param {import('./types').ScopeContext} scopeContext - Scope context
|
|
170
|
+
*/
|
|
171
|
+
async function loadHasOne(records, relationName, localKey, foreignKey, relatedModel, knex, scopeContext) {
|
|
172
|
+
// Collect unique primary key values from parent records
|
|
173
|
+
const primaryKeyValues = [...new Set(
|
|
174
|
+
records
|
|
175
|
+
.map(r => r[localKey])
|
|
176
|
+
.filter(v => v !== null && v !== undefined)
|
|
177
|
+
)];
|
|
178
|
+
|
|
179
|
+
if (primaryKeyValues.length === 0) {
|
|
180
|
+
// No primary keys, set all relations to null
|
|
181
|
+
for (const record of records) {
|
|
182
|
+
record[relationName] = null;
|
|
183
|
+
}
|
|
184
|
+
return;
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
// Build query for related records
|
|
188
|
+
let qb = knex(relatedModel.table).whereIn(foreignKey, primaryKeyValues);
|
|
189
|
+
|
|
190
|
+
// Apply scopes to related model
|
|
191
|
+
qb = applyScopes(qb, scopeContext, relatedModel);
|
|
192
|
+
|
|
193
|
+
const relatedRecords = await qb;
|
|
194
|
+
|
|
195
|
+
// Index related records by foreign key (first occurrence wins for hasOne)
|
|
196
|
+
const relatedMap = new Map();
|
|
197
|
+
for (const related of relatedRecords) {
|
|
198
|
+
const fkValue = related[foreignKey];
|
|
199
|
+
if (!relatedMap.has(fkValue)) {
|
|
200
|
+
relatedMap.set(fkValue, related);
|
|
201
|
+
}
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
// Attach related records to parent records
|
|
205
|
+
for (const record of records) {
|
|
206
|
+
const pkValue = record[localKey];
|
|
207
|
+
record[relationName] = relatedMap.get(pkValue) || null;
|
|
208
|
+
}
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
/**
|
|
212
|
+
* Load a single relation for a single record
|
|
213
|
+
* @param {Object} record - Record to load relation for
|
|
214
|
+
* @param {string} relationName - Name of relation to load
|
|
215
|
+
* @param {import('./types').ModelDefinition} model - Model definition
|
|
216
|
+
* @param {import('knex').Knex} knex - Knex instance
|
|
217
|
+
* @param {import('./types').ScopeContext} [scopeContext] - Scope context
|
|
218
|
+
* @returns {Promise<Object>} Record with relation attached
|
|
219
|
+
*/
|
|
220
|
+
async function loadRelation(record, relationName, model, knex, scopeContext) {
|
|
221
|
+
const result = await loadRelations([record], [relationName], model, knex, scopeContext);
|
|
222
|
+
return result[0];
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
module.exports = {
|
|
226
|
+
loadRelations,
|
|
227
|
+
loadRelation,
|
|
228
|
+
loadBelongsTo,
|
|
229
|
+
loadHasMany,
|
|
230
|
+
loadHasOne,
|
|
231
|
+
};
|
|
232
|
+
|