webspresso 0.0.5 → 0.0.7
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 +564 -0
- package/bin/webspresso.js +255 -0
- package/core/applySchema.js +49 -0
- package/core/compileSchema.js +69 -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 +29 -5
- package/src/plugin-manager.js +2 -0
- package/utils/schemaCache.js +60 -0
package/bin/webspresso.js
CHANGED
|
@@ -785,6 +785,261 @@ module.exports = {
|
|
|
785
785
|
}
|
|
786
786
|
});
|
|
787
787
|
|
|
788
|
+
// ============================================================================
|
|
789
|
+
// Database Commands
|
|
790
|
+
// ============================================================================
|
|
791
|
+
|
|
792
|
+
/**
|
|
793
|
+
* Load database configuration
|
|
794
|
+
* @param {string} [configPath] - Custom config path
|
|
795
|
+
* @returns {Object} Database config
|
|
796
|
+
*/
|
|
797
|
+
function loadDbConfig(configPath) {
|
|
798
|
+
const defaultPaths = ['webspresso.db.js', 'knexfile.js'];
|
|
799
|
+
const paths = configPath ? [configPath, ...defaultPaths] : defaultPaths;
|
|
800
|
+
|
|
801
|
+
for (const p of paths) {
|
|
802
|
+
const fullPath = path.resolve(process.cwd(), p);
|
|
803
|
+
if (fs.existsSync(fullPath)) {
|
|
804
|
+
return { config: require(fullPath), path: fullPath };
|
|
805
|
+
}
|
|
806
|
+
}
|
|
807
|
+
|
|
808
|
+
console.error('❌ Database config not found. Create webspresso.db.js or knexfile.js');
|
|
809
|
+
process.exit(1);
|
|
810
|
+
}
|
|
811
|
+
|
|
812
|
+
/**
|
|
813
|
+
* Create database instance from config
|
|
814
|
+
* @param {Object} config - Database config
|
|
815
|
+
* @param {string} [env] - Environment name
|
|
816
|
+
* @returns {Promise<Object>} Database instance
|
|
817
|
+
*/
|
|
818
|
+
async function createDbInstance(config, env) {
|
|
819
|
+
const environment = env || process.env.NODE_ENV || 'development';
|
|
820
|
+
const dbConfig = config[environment] || config;
|
|
821
|
+
|
|
822
|
+
// Dynamic import knex
|
|
823
|
+
let knex;
|
|
824
|
+
try {
|
|
825
|
+
knex = require('knex');
|
|
826
|
+
} catch {
|
|
827
|
+
console.error('❌ Knex not installed. Run: npm install knex');
|
|
828
|
+
process.exit(1);
|
|
829
|
+
}
|
|
830
|
+
|
|
831
|
+
return knex(dbConfig);
|
|
832
|
+
}
|
|
833
|
+
|
|
834
|
+
// db:migrate command
|
|
835
|
+
program
|
|
836
|
+
.command('db:migrate')
|
|
837
|
+
.description('Run pending database migrations')
|
|
838
|
+
.option('-e, --env <environment>', 'Environment (development, production)', 'development')
|
|
839
|
+
.option('-c, --config <path>', 'Path to database config file')
|
|
840
|
+
.action(async (options) => {
|
|
841
|
+
const { config, path: configPath } = loadDbConfig(options.config);
|
|
842
|
+
console.log(`\n📦 Using config: ${configPath}`);
|
|
843
|
+
console.log(` Environment: ${options.env}\n`);
|
|
844
|
+
|
|
845
|
+
const knex = await createDbInstance(config, options.env);
|
|
846
|
+
|
|
847
|
+
try {
|
|
848
|
+
const migrationConfig = config.migrations || {};
|
|
849
|
+
const [batch, migrations] = await knex.migrate.latest(migrationConfig);
|
|
850
|
+
|
|
851
|
+
if (migrations.length === 0) {
|
|
852
|
+
console.log('✅ Already up to date.\n');
|
|
853
|
+
} else {
|
|
854
|
+
console.log(`Running migrations (batch ${batch}):`);
|
|
855
|
+
for (const m of migrations) {
|
|
856
|
+
console.log(` → ${m}`);
|
|
857
|
+
}
|
|
858
|
+
console.log(`\n✅ Done. ${migrations.length} migration(s) completed.\n`);
|
|
859
|
+
}
|
|
860
|
+
} catch (err) {
|
|
861
|
+
console.error('❌ Migration failed:', err.message);
|
|
862
|
+
process.exit(1);
|
|
863
|
+
} finally {
|
|
864
|
+
await knex.destroy();
|
|
865
|
+
}
|
|
866
|
+
});
|
|
867
|
+
|
|
868
|
+
// db:rollback command
|
|
869
|
+
program
|
|
870
|
+
.command('db:rollback')
|
|
871
|
+
.description('Rollback the last batch of migrations')
|
|
872
|
+
.option('-e, --env <environment>', 'Environment (development, production)', 'development')
|
|
873
|
+
.option('-c, --config <path>', 'Path to database config file')
|
|
874
|
+
.option('-a, --all', 'Rollback all migrations')
|
|
875
|
+
.action(async (options) => {
|
|
876
|
+
const { config, path: configPath } = loadDbConfig(options.config);
|
|
877
|
+
console.log(`\n📦 Using config: ${configPath}`);
|
|
878
|
+
console.log(` Environment: ${options.env}\n`);
|
|
879
|
+
|
|
880
|
+
const knex = await createDbInstance(config, options.env);
|
|
881
|
+
|
|
882
|
+
try {
|
|
883
|
+
const migrationConfig = {
|
|
884
|
+
...(config.migrations || {}),
|
|
885
|
+
...(options.all ? { all: true } : {}),
|
|
886
|
+
};
|
|
887
|
+
|
|
888
|
+
const [batch, migrations] = await knex.migrate.rollback(migrationConfig);
|
|
889
|
+
|
|
890
|
+
if (migrations.length === 0) {
|
|
891
|
+
console.log('✅ Nothing to rollback.\n');
|
|
892
|
+
} else {
|
|
893
|
+
console.log(`Rolling back${options.all ? ' all' : ''} migrations:`);
|
|
894
|
+
for (const m of migrations) {
|
|
895
|
+
console.log(` ← ${m}`);
|
|
896
|
+
}
|
|
897
|
+
console.log(`\n✅ Done. ${migrations.length} migration(s) rolled back.\n`);
|
|
898
|
+
}
|
|
899
|
+
} catch (err) {
|
|
900
|
+
console.error('❌ Rollback failed:', err.message);
|
|
901
|
+
process.exit(1);
|
|
902
|
+
} finally {
|
|
903
|
+
await knex.destroy();
|
|
904
|
+
}
|
|
905
|
+
});
|
|
906
|
+
|
|
907
|
+
// db:status command
|
|
908
|
+
program
|
|
909
|
+
.command('db:status')
|
|
910
|
+
.description('Show migration status')
|
|
911
|
+
.option('-e, --env <environment>', 'Environment (development, production)', 'development')
|
|
912
|
+
.option('-c, --config <path>', 'Path to database config file')
|
|
913
|
+
.action(async (options) => {
|
|
914
|
+
const { config, path: configPath } = loadDbConfig(options.config);
|
|
915
|
+
console.log(`\n📦 Using config: ${configPath}`);
|
|
916
|
+
console.log(` Environment: ${options.env}\n`);
|
|
917
|
+
|
|
918
|
+
const knex = await createDbInstance(config, options.env);
|
|
919
|
+
|
|
920
|
+
try {
|
|
921
|
+
const migrationConfig = config.migrations || {};
|
|
922
|
+
const [completed, pending] = await knex.migrate.list(migrationConfig);
|
|
923
|
+
|
|
924
|
+
console.log('Migration Status');
|
|
925
|
+
console.log('================\n');
|
|
926
|
+
|
|
927
|
+
// Sort all migrations by name
|
|
928
|
+
const all = [
|
|
929
|
+
...completed.map(m => ({ name: m.name || m, completed: true })),
|
|
930
|
+
...pending.map(m => ({ name: m.name || m, completed: false })),
|
|
931
|
+
].sort((a, b) => a.name.localeCompare(b.name));
|
|
932
|
+
|
|
933
|
+
if (all.length === 0) {
|
|
934
|
+
console.log(' No migrations found.\n');
|
|
935
|
+
} else {
|
|
936
|
+
for (const m of all) {
|
|
937
|
+
const status = m.completed ? '✓' : '○';
|
|
938
|
+
const suffix = m.completed ? '' : ' (pending)';
|
|
939
|
+
console.log(` ${status} ${m.name}${suffix}`);
|
|
940
|
+
}
|
|
941
|
+
console.log(`\n Total: ${all.length} (${completed.length} completed, ${pending.length} pending)\n`);
|
|
942
|
+
}
|
|
943
|
+
} catch (err) {
|
|
944
|
+
console.error('❌ Failed to get status:', err.message);
|
|
945
|
+
process.exit(1);
|
|
946
|
+
} finally {
|
|
947
|
+
await knex.destroy();
|
|
948
|
+
}
|
|
949
|
+
});
|
|
950
|
+
|
|
951
|
+
// db:make command
|
|
952
|
+
program
|
|
953
|
+
.command('db:make <name>')
|
|
954
|
+
.description('Create a new migration file')
|
|
955
|
+
.option('-c, --config <path>', 'Path to database config file')
|
|
956
|
+
.option('-m, --model <model>', 'Generate migration from model (requires models directory)')
|
|
957
|
+
.action(async (name, options) => {
|
|
958
|
+
const { config, path: configPath } = loadDbConfig(options.config);
|
|
959
|
+
console.log(`\n📦 Using config: ${configPath}\n`);
|
|
960
|
+
|
|
961
|
+
const migrationDir = config.migrations?.directory || './migrations';
|
|
962
|
+
|
|
963
|
+
// Ensure migrations directory exists
|
|
964
|
+
if (!fs.existsSync(migrationDir)) {
|
|
965
|
+
fs.mkdirSync(migrationDir, { recursive: true });
|
|
966
|
+
console.log(`Created directory: ${migrationDir}`);
|
|
967
|
+
}
|
|
968
|
+
|
|
969
|
+
// Generate filename with timestamp
|
|
970
|
+
const now = new Date();
|
|
971
|
+
const timestamp = [
|
|
972
|
+
now.getFullYear(),
|
|
973
|
+
String(now.getMonth() + 1).padStart(2, '0'),
|
|
974
|
+
String(now.getDate()).padStart(2, '0'),
|
|
975
|
+
'_',
|
|
976
|
+
String(now.getHours()).padStart(2, '0'),
|
|
977
|
+
String(now.getMinutes()).padStart(2, '0'),
|
|
978
|
+
String(now.getSeconds()).padStart(2, '0'),
|
|
979
|
+
].join('');
|
|
980
|
+
|
|
981
|
+
const filename = `${timestamp}_${name}.js`;
|
|
982
|
+
const filepath = path.join(migrationDir, filename);
|
|
983
|
+
|
|
984
|
+
let content;
|
|
985
|
+
|
|
986
|
+
if (options.model) {
|
|
987
|
+
// Try to load model and generate migration from schema
|
|
988
|
+
const modelsDir = config.models || './models';
|
|
989
|
+
const modelPath = path.resolve(process.cwd(), modelsDir, `${options.model}.js`);
|
|
990
|
+
|
|
991
|
+
if (fs.existsSync(modelPath)) {
|
|
992
|
+
try {
|
|
993
|
+
const model = require(modelPath);
|
|
994
|
+
const { scaffoldMigration } = require('../core/orm/migrations/scaffold');
|
|
995
|
+
content = scaffoldMigration(model);
|
|
996
|
+
console.log(`Generated migration from model: ${options.model}`);
|
|
997
|
+
} catch (err) {
|
|
998
|
+
console.warn(`⚠️ Could not generate from model: ${err.message}`);
|
|
999
|
+
console.log(' Creating empty migration instead.\n');
|
|
1000
|
+
content = getDefaultMigrationContent(name);
|
|
1001
|
+
}
|
|
1002
|
+
} else {
|
|
1003
|
+
console.warn(`⚠️ Model not found: ${modelPath}`);
|
|
1004
|
+
console.log(' Creating empty migration instead.\n');
|
|
1005
|
+
content = getDefaultMigrationContent(name);
|
|
1006
|
+
}
|
|
1007
|
+
} else {
|
|
1008
|
+
content = getDefaultMigrationContent(name);
|
|
1009
|
+
}
|
|
1010
|
+
|
|
1011
|
+
fs.writeFileSync(filepath, content);
|
|
1012
|
+
console.log(`✅ Created: ${filepath}\n`);
|
|
1013
|
+
});
|
|
1014
|
+
|
|
1015
|
+
/**
|
|
1016
|
+
* Get default migration content
|
|
1017
|
+
* @param {string} name - Migration name
|
|
1018
|
+
* @returns {string}
|
|
1019
|
+
*/
|
|
1020
|
+
function getDefaultMigrationContent(name) {
|
|
1021
|
+
// Parse table name from migration name (e.g., create_users_table -> users)
|
|
1022
|
+
const match = name.match(/^create_(\w+)_table$/);
|
|
1023
|
+
const tableName = match ? match[1] : 'table_name';
|
|
1024
|
+
|
|
1025
|
+
return `/**
|
|
1026
|
+
* Migration: ${name}
|
|
1027
|
+
*/
|
|
1028
|
+
|
|
1029
|
+
exports.up = function(knex) {
|
|
1030
|
+
return knex.schema.createTable('${tableName}', (table) => {
|
|
1031
|
+
table.bigIncrements('id').primary();
|
|
1032
|
+
// Add your columns here
|
|
1033
|
+
table.timestamps(true, true);
|
|
1034
|
+
});
|
|
1035
|
+
};
|
|
1036
|
+
|
|
1037
|
+
exports.down = function(knex) {
|
|
1038
|
+
return knex.schema.dropTableIfExists('${tableName}');
|
|
1039
|
+
};
|
|
1040
|
+
`;
|
|
1041
|
+
}
|
|
1042
|
+
|
|
788
1043
|
// Parse arguments
|
|
789
1044
|
program.parse();
|
|
790
1045
|
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Schema Applicator
|
|
3
|
+
* Parses and validates request input against compiled schemas
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Apply compiled schema to request
|
|
8
|
+
* Parses body, params, and query against their respective schemas
|
|
9
|
+
* Stores validated results in req.input
|
|
10
|
+
*
|
|
11
|
+
* @param {Object} req - Express request object
|
|
12
|
+
* @param {Object|null} compiledSchema - Compiled schema from compileSchema
|
|
13
|
+
* @returns {void}
|
|
14
|
+
* @throws {ZodError} If validation fails
|
|
15
|
+
*/
|
|
16
|
+
function applySchema(req, compiledSchema) {
|
|
17
|
+
// Initialize req.input
|
|
18
|
+
req.input = {
|
|
19
|
+
body: undefined,
|
|
20
|
+
params: undefined,
|
|
21
|
+
query: undefined
|
|
22
|
+
};
|
|
23
|
+
|
|
24
|
+
// No schema means no validation
|
|
25
|
+
if (!compiledSchema) {
|
|
26
|
+
return;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
// Parse body if schema exists
|
|
30
|
+
if (compiledSchema.body) {
|
|
31
|
+
req.input.body = compiledSchema.body.parse(req.body);
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
// Parse params if schema exists
|
|
35
|
+
if (compiledSchema.params) {
|
|
36
|
+
req.input.params = compiledSchema.params.parse(req.params);
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
// Parse query if schema exists
|
|
40
|
+
if (compiledSchema.query) {
|
|
41
|
+
req.input.query = compiledSchema.query.parse(req.query);
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
module.exports = {
|
|
46
|
+
applySchema
|
|
47
|
+
};
|
|
48
|
+
|
|
49
|
+
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Schema Compiler
|
|
3
|
+
* Compiles schema definitions from API files
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
const z = require('zod');
|
|
7
|
+
const schemaCache = require('../utils/schemaCache');
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* Compile schema from an API module
|
|
11
|
+
* @param {string} filePath - Absolute file path to API module
|
|
12
|
+
* @param {Object} apiModule - The loaded API module
|
|
13
|
+
* @returns {Object|null} Compiled schema object or null if no schema
|
|
14
|
+
*/
|
|
15
|
+
function compileSchema(filePath, apiModule) {
|
|
16
|
+
// Return cached schema if exists
|
|
17
|
+
if (schemaCache.has(filePath)) {
|
|
18
|
+
return schemaCache.get(filePath);
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
// Check if module exports schema
|
|
22
|
+
const schemaFn = apiModule.schema;
|
|
23
|
+
|
|
24
|
+
// Schema is optional
|
|
25
|
+
if (schemaFn === undefined) {
|
|
26
|
+
schemaCache.set(filePath, null);
|
|
27
|
+
return null;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
// Schema must be a function
|
|
31
|
+
if (typeof schemaFn !== 'function') {
|
|
32
|
+
throw new Error(`Schema in ${filePath} must be a function`);
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
// Call schema function with { z }
|
|
36
|
+
const compiled = schemaFn({ z });
|
|
37
|
+
|
|
38
|
+
// Validate compiled schema structure
|
|
39
|
+
if (compiled !== null && typeof compiled !== 'object') {
|
|
40
|
+
throw new Error(`Schema function in ${filePath} must return an object or null`);
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
// Cache and return
|
|
44
|
+
schemaCache.set(filePath, compiled);
|
|
45
|
+
return compiled;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* Clear schema cache for a file (for hot-reload)
|
|
50
|
+
* @param {string} filePath - Absolute file path
|
|
51
|
+
*/
|
|
52
|
+
function invalidateSchema(filePath) {
|
|
53
|
+
schemaCache.del(filePath);
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* Clear all cached schemas
|
|
58
|
+
*/
|
|
59
|
+
function clearAllSchemas() {
|
|
60
|
+
schemaCache.clear();
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
module.exports = {
|
|
64
|
+
compileSchema,
|
|
65
|
+
invalidateSchema,
|
|
66
|
+
clearAllSchemas
|
|
67
|
+
};
|
|
68
|
+
|
|
69
|
+
|
|
@@ -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
|
+
|