Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 3 additions & 1 deletion lib/src/database/migration/runners/migration_runner.dart
Original file line number Diff line number Diff line change
Expand Up @@ -31,7 +31,9 @@ class MigrationRunner {
} catch (e) {
stopwatch.stop();
if (e is QueryException) {
stderr.write(e.cause);
stderr.writeln(e.cause);
} else {
stderr.writeln(e);
}
stderr.writeln(
'❌ Migration $migrationName failed ......................................\x1B[31m ${stopwatch.elapsedMilliseconds}ms FAILED\x1B[0m',
Expand Down
185 changes: 100 additions & 85 deletions lib/src/database/orm/model.dart
Original file line number Diff line number Diff line change
Expand Up @@ -712,131 +712,146 @@ abstract class Model extends QueryBuilderImpl {
return this;
}

void _clearWithRelation(_RelationQuery r) {
_withRelation = _withRelation.where((item) => item != r).toList();
}

Future<void> _eagerLoadRelation(
List<Map<String, dynamic>> models,
_RelationQuery rq,
Function(dynamic data) callBack,
) async {
String relation = rq.relation;
List<String> wr = relation.split('.');

if (_withRelation.any((r) => r.relation == relation)) {
List<String> wr = relation.split('.');
String primaryRelation = wr.first;

String primaryRelation = wr.first;
List<String> getColumns = ['*'];
final relationParts = primaryRelation.split(':');

List<String> getColumns = ['*'];
final relationParts = primaryRelation.split(':');
if (relationParts.length > 1) {
primaryRelation = relationParts.first.trim();

if (relationParts.length > 1) {
primaryRelation = relationParts.first.trim();
final columnsString = relationParts.last.trim();

final columnsString = relationParts.last.trim();

if (columnsString.isNotEmpty) {
getColumns = columnsString
.split(',')
.map((col) => col.trim())
.where((col) => col.isNotEmpty)
.toList();
}
if (columnsString.isNotEmpty) {
getColumns = columnsString
.split(',')
.map((col) => col.trim())
.where((col) => col.isNotEmpty)
.toList();
}
}

if (!_relations.containsKey(primaryRelation)) {
throw InvalidArgumentException(
'Relation $relation not found in $runtimeType',
);
}
if (!_relations.containsKey(primaryRelation)) {
throw InvalidArgumentException(
'Relation $relation not found in $runtimeType',
);
}

Relation rela = _relations[primaryRelation] as Relation;
Model qb = rela.related;
rela.parent._clearWithRelation(rq);
Relation rela = _relations[primaryRelation] as Relation;
Model qb = rela.related;

if (rq.callback != null) {
qb = rq.callback!(qb) as Model;
}
if (rq.callback != null) {
qb = rq.callback!(qb) as Model;
}

if (rela is MorphRelation) {
if (rela is MorphTo) {
Set ids = models.map((m) => m[rela.morphKey]).toSet();
qb = qb.whereIn(rela.localKey, ids.toList()) as Model;
} else {
Set ids = models.map((m) => m[rela.localKey]).toSet();

if (rela is MorphToMany || rela is MorphedByMany) {
qb =
qb
.whereIn(rela.morphKey, ids.toList())
.whereEqualTo(rela.morphType, rela.type)
.join(
rela.related.tableName,
'${rela.pivotTable}.${rela.relatedMorphKey}',
'=',
'${rela.related.tableName}.${rela.localKey}',
)
as Model;
qb.tableName = rela.pivotTable!;
} else {
qb =
qb
.whereIn(rela.morphKey, ids.toList())
.whereEqualTo(rela.morphType, rela.type)
as Model;
}
if (rela is MorphRelation) {
if (rela is MorphTo) {
Set ids = models.map((m) => m[rela.morphKey]).toSet();

// Early return if no IDs to query
if (ids.isEmpty) {
callBack(rela.match(models, [], primaryRelation));
return;
}

qb = qb.whereIn(rela.localKey, ids.toList()) as Model;
} else {
late final String getLocalKey;
if (rela is BelongsTo) {
getLocalKey =
rela.foreignKey ??
'${rela.related.runtimeType.toString()}_id'.toLowerCase();
} else {
getLocalKey = rela.localKey;
}
Set ids = models.map((m) => m[rela.localKey]).toSet();

Set ids = models.map((m) => m[getLocalKey]).toSet();
// Early return if no IDs to query
if (ids.isEmpty) {
callBack(rela.match(models, [], primaryRelation));
return;
}

if (rela is BelongsToMany) {
if (rela is MorphToMany || rela is MorphedByMany) {
qb =
qb
.whereIn(
'${rela.pivotTable}.${rela.parentPivotKey}',
ids.toList(),
)
.whereIn(rela.morphKey, ids.toList())
.whereEqualTo(rela.morphType, rela.type)
.join(
rela.pivotTable,
'${rela.pivotTable}.${rela.relatedPivotKey}',
rela.related.tableName,
'${rela.pivotTable}.${rela.relatedMorphKey}',
'=',
'${rela.related.tableName}.${rela.relatedLocalKey}',
'${rela.related.tableName}.${rela.localKey}',
)
as Model;
} else if (rela is BelongsTo) {
qb = qb.whereIn(rela.localKey, ids.toList()) as Model;
qb.tableName = rela.pivotTable!;
} else {
qb = qb.whereIn(rela.foreignKey!, ids.toList()) as Model;
qb =
qb
.whereIn(rela.morphKey, ids.toList())
.whereEqualTo(rela.morphType, rela.type)
as Model;
}
}
late final List<Map<String, dynamic>> results;
} else {
late final String getLocalKey;
if (rela is BelongsTo) {
getLocalKey =
rela.foreignKey ??
'${rela.related.runtimeType.toString()}_id'.toLowerCase();
} else {
getLocalKey = rela.localKey;
}

Set ids = models.map((m) => m[getLocalKey]).toSet();

// Early return if no IDs to query
if (ids.isEmpty) {
callBack(rela.match(models, [], primaryRelation));
return;
}

if (wr.length > 1) {
wr.removeAt(0);
results = await qb.include(wr.join('.')).get(getColumns);
if (rela is BelongsToMany) {
qb =
qb
.whereIn(
'${rela.pivotTable}.${rela.parentPivotKey}',
ids.toList(),
)
.join(
rela.pivotTable,
'${rela.pivotTable}.${rela.relatedPivotKey}',
'=',
'${rela.related.tableName}.${rela.relatedLocalKey}',
)
as Model;
} else if (rela is BelongsTo) {
qb = qb.whereIn(rela.localKey, ids.toList()) as Model;
} else {
results = await qb.get(getColumns);
qb = qb.whereIn(rela.foreignKey!, ids.toList()) as Model;
}
}

late final List<Map<String, dynamic>> results;

callBack(rela.match(models, results, primaryRelation));
if (wr.length > 1) {
wr.removeAt(0);
results = await qb.include(wr.join('.')).get(getColumns);
} else {
results = await qb.get(getColumns);
}

callBack(rela.match(models, results, primaryRelation));
}

Future<List<Map<String, dynamic>>> _loadRelations(
List<Map<String, dynamic>> result,
) async {
if (_withRelation.isNotEmpty) {
final relationsToLoad = List<_RelationQuery>.from(_withRelation);
// Create an immutable copy of relations to load and clear the original list
// This prevents concurrent modification when iterating
final relationsToLoad = List<_RelationQuery>.unmodifiable(_withRelation);
_withRelation = [];

for (_RelationQuery relation in relationsToLoad) {
await _eagerLoadRelation(result, relation, (callBackResult) {
Expand Down
44 changes: 38 additions & 6 deletions lib/src/database/query_builder/_where_clauses_builder_impl.dart
Original file line number Diff line number Diff line change
Expand Up @@ -7,15 +7,51 @@ import '_query_builder_impl.dart';
int _paramCounter = 0;

abstract mixin class WhereClausesBuilderImpl implements QueryBuilder {
/// Valid SQL comparison operators to prevent SQL injection.
/// Only these operators are allowed in where clauses.
static const Set<String> _validOperators = {
'=',
'<>',
'!=',
'<',
'>',
'<=',
'>=',
'LIKE',
'NOT LIKE',
'ILIKE', // PostgreSQL case-insensitive LIKE
'NOT ILIKE',
'REGEXP',
'NOT REGEXP',
'RLIKE', // MySQL alias for REGEXP
'SIMILAR TO', // PostgreSQL
};

set paramCounter(int paramN) {
_paramCounter = paramN;
}

/// Returns the current parameter counter value.
/// Useful for synchronizing nested queries.
int get currentParamCounter => _paramCounter;

String _nextParamName() {
_paramCounter++;
return 'p$_paramCounter';
}

/// Validates that the given operator is a valid SQL comparison operator.
/// Throws [InvalidArgumentException] if the operator is not valid.
/// This prevents SQL injection attacks through malicious operators.
void _validateOperator(String operator) {
if (!_validOperators.contains(operator.toUpperCase())) {
throw InvalidArgumentException(
'Invalid SQL operator: "$operator". '
'Allowed operators: ${_validOperators.join(", ")}',
);
}
}

@override
String buildWhereClause() {
return conditions.isNotEmpty ? " WHERE ${conditions.join(" ")}" : "";
Expand All @@ -41,6 +77,7 @@ abstract mixin class WhereClausesBuilderImpl implements QueryBuilder {
String boolean = 'and',
]) {
if (condition is String) {
_validateOperator(operator);
final paramName = _nextParamName();
bindings[paramName] = value;
_appendCondition("$condition $operator :$paramName", isOr: true);
Expand Down Expand Up @@ -284,6 +321,7 @@ abstract mixin class WhereClausesBuilderImpl implements QueryBuilder {
String boolean = 'and',
]) {
if (condition is String) {
_validateOperator(operator);
final paramName = _nextParamName();
bindings[paramName] = value;
_appendCondition(
Expand Down Expand Up @@ -974,12 +1012,6 @@ abstract mixin class WhereClausesBuilderImpl implements QueryBuilder {
String clause = not ? "NOT IN" : "IN";

if (values is List) {
if (values.isEmpty) {
throw InvalidArgumentException(
"The list of values for IN must not be empty.",
);
}

List<String> paramNames = [];
for (var i = 0; i < values.length; i++) {
final paramName = _nextParamName();
Expand Down