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
Original file line number Diff line number Diff line change
Expand Up @@ -22,4 +22,76 @@ describe('DbTableRowViewComponent', () => {
it('should create', () => {
expect(component).toBeTruthy();
});

describe('getForeignKeyValue', () => {
const baseRow = {
foreignKeys: {
user_id: {
column_name: 'user_id',
constraint_name: 'fk_user',
referenced_column_name: 'id',
referenced_table_name: 'users',
},
},
};

it('returns null when record field is null', () => {
component.selectedRow = { ...baseRow, record: { user_id: null } } as any;
expect(component.getForeignKeyValue('user_id')).toBeNull();
});

it('returns null when record field is undefined', () => {
component.selectedRow = { ...baseRow, record: {} } as any;
expect(component.getForeignKeyValue('user_id')).toBeNull();
});

it('returns identity column value when FK object has one', () => {
component.selectedRow = {
...baseRow,
record: { user_id: { id: 42, name: 'alice' } },
} as any;
expect(component.getForeignKeyValue('user_id')).toBe('alice');
});

it('returns primitive FK value as-is (including 0)', () => {
component.selectedRow = { ...baseRow, record: { user_id: 0 } } as any;
expect(component.getForeignKeyValue('user_id')).toBe(0);
});

it('returns primitive FK value as-is (including empty string)', () => {
component.selectedRow = { ...baseRow, record: { user_id: '' } } as any;
expect(component.getForeignKeyValue('user_id')).toBe('');
});
});

describe('getForeignKeyQueryParams', () => {
const baseRow = {
foreignKeys: {
user_id: {
column_name: 'user_id',
constraint_name: 'fk_user',
referenced_column_name: 'id',
referenced_table_name: 'users',
},
},
};

it('returns {} when record field is null', () => {
component.selectedRow = { ...baseRow, record: { user_id: null } } as any;
expect(component.getForeignKeyQueryParams('user_id')).toEqual({});
});

it('returns referenced column param when FK is an object', () => {
component.selectedRow = {
...baseRow,
record: { user_id: { id: 42, name: 'alice' } },
} as any;
expect(component.getForeignKeyQueryParams('user_id')).toEqual({ id: 42 });
Comment on lines +84 to +89
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

Cover object FKs whose referenced value is null.

This positive object case still misses the null-reference branch. With the current implementation in frontend/src/app/components/dashboard/db-table-view/db-table-row-view/db-table-row-view.component.ts:272-277, { user_id: { id: null, name: 'alice' } } returns { id: null }, which can still generate the broken query this PR is meant to prevent.

Suggested regression and guard
 it('returns referenced column param when FK is an object', () => {
 	component.selectedRow = {
 		...baseRow,
 		record: { user_id: { id: 42, name: 'alice' } },
 	} as any;
 	expect(component.getForeignKeyQueryParams('user_id')).toEqual({ id: 42 });
 });
+
+it('returns {} when FK object referenced column is null', () => {
+	component.selectedRow = {
+		...baseRow,
+		record: { user_id: { id: null, name: 'alice' } },
+	} as any;
+	expect(component.getForeignKeyQueryParams('user_id')).toEqual({});
+});

Implementation-side guard:

 const cell = this.selectedRow?.record?.[field];
 const referencedColumnName = this.selectedRow?.foreignKeys?.[field]?.referenced_column_name;
 if (cell == null || !referencedColumnName) return {};
-return { [referencedColumnName]: typeof cell === 'object' ? cell[referencedColumnName] : cell };
+const value = typeof cell === 'object' ? cell[referencedColumnName] : cell;
+if (value == null) return {};
+return { [referencedColumnName]: value };
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
it('returns referenced column param when FK is an object', () => {
component.selectedRow = {
...baseRow,
record: { user_id: { id: 42, name: 'alice' } },
} as any;
expect(component.getForeignKeyQueryParams('user_id')).toEqual({ id: 42 });
it('returns referenced column param when FK is an object', () => {
component.selectedRow = {
...baseRow,
record: { user_id: { id: 42, name: 'alice' } },
} as any;
expect(component.getForeignKeyQueryParams('user_id')).toEqual({ id: 42 });
});
it('returns {} when FK object referenced column is null', () => {
component.selectedRow = {
...baseRow,
record: { user_id: { id: null, name: 'alice' } },
} as any;
expect(component.getForeignKeyQueryParams('user_id')).toEqual({});
});
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In
`@frontend/src/app/components/dashboard/db-table-view/db-table-row-view/db-table-row-view.component.spec.ts`
around lines 84 - 89, The current getForeignKeyQueryParams implementation
returns an object like { id: null } for object FKs whose referenced id is null;
change getForeignKeyQueryParams to guard against null/undefined referenced
values (e.g., if selectedRow.record[field] is an object but its referenced key
value is null/undefined then return null/undefined instead of an object) so no
broken query is generated, and add a unit test in
db-table-row-view.component.spec.ts mirroring the existing positive case but
with record: { user_id: { id: null, name: 'alice' } } and assert that
getForeignKeyQueryParams('user_id') returns null/undefined (matching your chosen
sentinel).

});

it('returns referenced column param when FK is a primitive', () => {
component.selectedRow = { ...baseRow, record: { user_id: 7 } } as any;
expect(component.getForeignKeyQueryParams('user_id')).toEqual({ id: 7 });
});
});
});
Original file line number Diff line number Diff line change
Expand Up @@ -257,33 +257,23 @@ export class DbTableRowViewComponent implements OnInit, OnDestroy {
}

getForeignKeyValue(field: string) {
if (this.selectedRow && typeof this.selectedRow.record[field] === 'object') {
const identityColumnName = Object.keys(this.selectedRow.record[field]).find(
(key) => key !== this.selectedRow.foreignKeys[field].referenced_column_name,
);
const cell = this.selectedRow?.record?.[field];
if (cell == null) return null;
if (typeof cell === 'object') {
const referencedColumnName = this.selectedRow.foreignKeys[field].referenced_column_name;
if (identityColumnName) {
return this.selectedRow.record[field][identityColumnName];
}
if (referencedColumnName) {
return this.selectedRow.record[field][referencedColumnName];
}
return this.selectedRow.record[field] || '';
const identityColumnName = Object.keys(cell).find((key) => key !== referencedColumnName);
if (identityColumnName) return cell[identityColumnName];
if (referencedColumnName && cell[referencedColumnName] != null) return cell[referencedColumnName];
return null;
}
return this.selectedRow.record[field] || '';
return cell;
}

getForeignKeyQueryParams(field: string) {
if (this.selectedRow) {
const referencedColumnName = this.selectedRow.foreignKeys[field]?.referenced_column_name;

if (typeof this.selectedRow.record[field] === 'object') {
return { [referencedColumnName]: this.selectedRow.record[field][referencedColumnName] };
} else {
return { [referencedColumnName]: this.selectedRow.record[field] };
}
}
return {};
const cell = this.selectedRow?.record?.[field];
const referencedColumnName = this.selectedRow?.foreignKeys?.[field]?.referenced_column_name;
if (cell == null || !referencedColumnName) return {};
return { [referencedColumnName]: typeof cell === 'object' ? cell[referencedColumnName] : cell };
}

isWidget(columnName: string) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -241,4 +241,31 @@ describe('DbTableViewComponent', () => {
const value = component.getCellValue(foreignKey, cell);
expect(value).toEqual('John');
});

it('should return null (not throw) when foreign key cell is null', () => {
const foreignKey = {
autocomplete_columns: ['FirstName'],
column_name: 'CustomerId',
column_default: null,
constraint_name: 'Orders_ibfk_2',
referenced_column_name: 'Id',
referenced_table_name: 'Customers',
};

expect(component.getCellValue(foreignKey, null)).toBeNull();
expect(component.getCellValue(foreignKey, undefined)).toBeNull();
});

it('should not throw in isForeignKeySelected when record is null', () => {
const foreignKey = {
autocomplete_columns: ['FirstName'],
column_name: 'CustomerId',
column_default: null,
constraint_name: 'Orders_ibfk_2',
referenced_column_name: 'Id',
referenced_table_name: 'Customers',
};

expect(component.isForeignKeySelected(null, foreignKey)).toBe(false);
});
});
Original file line number Diff line number Diff line change
Expand Up @@ -417,6 +417,7 @@ export class DbTableViewComponent implements OnInit, OnChanges {
}

getCellValue(foreignKey: TableForeignKey, cell) {
if (cell == null) return null;
const identityColumnName = Object.keys(cell).find((key) => key !== foreignKey.referenced_column_name);
if (identityColumnName) {
return cell[identityColumnName];
Expand Down Expand Up @@ -680,6 +681,7 @@ export class DbTableViewComponent implements OnInit, OnChanges {
}

isForeignKeySelected(record, foreignKey: TableForeignKey) {
if (record == null) return false;
const primaryKeyValue = record[foreignKey.referenced_column_name];

if (this.selectedRowType === 'foreignKey' && this.selectedRow && this.selectedRow.record !== null) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -125,6 +125,7 @@ <h3>
required: tableRowRequiredValues[value],
readonly: (!canEditRow() && pageAction !== 'dub') || pageMode === 'view',
disabled: isReadonlyField(value),
structure: tableRowStructure[value],
widgetStructure: tableWidgets[value],
relations: tableTypes[value] === 'foreign key' ? getRelations(value) : undefined,
rowPrimaryKey: keyAttributesFromURL
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -52,3 +52,8 @@
margin-left: auto;
margin-top: 12px;
}

.foreign-key__null-option {
font-style: italic;
color: var(--mat-sys-on-surface-variant, rgba(0, 0, 0, 0.6));
}
Original file line number Diff line number Diff line change
@@ -1,5 +1,3 @@
<!-- <pre>{{ value() }}</pre> -->

<div class="foreign-key">
<mat-form-field class="full-width" appearance="outline">
<mat-label>{{normalizedLabel()}}</mat-label>
Expand All @@ -14,7 +12,7 @@
<mat-autocomplete autoActiveFirstOption #auto="matAutocomplete" (optionSelected)="updateRelatedLink($event)">
@for (suggestion of suggestions(); track suggestion.fieldValue) {
<mat-option
[ngClass]="{'disabled': suggestion.displayString === 'No matches'}"
[ngClass]="{'disabled': suggestion.displayString === 'No matches', 'foreign-key__null-option': suggestion.isNullOption}"
[value]="suggestion.displayString">
{{suggestion.displayString}}
</mat-option>
Expand All @@ -24,19 +22,17 @@
<a routerLink="/dashboard/{{connectionID}}/{{relations().referenced_table_name}}/settings" class="hint-link">here</a>
</mat-hint>
</mat-form-field>
<a routerLink="/dashboard/{{connectionID}}/{{relations().referenced_table_name}}/entry"
[queryParams]="currentFieldQueryParams"
target="_blank"
class="foreign-key__link">
<mat-icon
class="foreign-key__link-icon"
matTooltip="Show related record"
matTooltipPosition="above">
open_in_new
</mat-icon>
</a>
@if (currentFieldValue != null) {
<a routerLink="/dashboard/{{connectionID}}/{{relations().referenced_table_name}}/entry"
[queryParams]="currentFieldQueryParams"
target="_blank"
class="foreign-key__link">
<mat-icon
class="foreign-key__link-icon"
matTooltip="Show related record"
matTooltipPosition="above">
open_in_new
</mat-icon>
</a>
}
</div>

<!-- <pre>{{ relations() | json }}</pre> -->
<!-- <pre>{{ suggestions() | json}}</pre> -->
<!-- <p>foreign-key component</p> -->
Original file line number Diff line number Diff line change
Expand Up @@ -438,4 +438,116 @@ describe('ForeignKeyEditComponent', () => {
},
]);
});

describe('nullable column', () => {
const nullableStructure = {
column_name: 'userId',
column_default: null,
data_type: 'integer',
isExcluded: false,
isSearched: false,
auto_increment: false,
allow_null: true,
character_maximum_length: null,
};

it('appends a "— empty" null option when structure.allow_null is true', async () => {
vi.spyOn(tablesService, 'fetchTable').mockReturnValue(of(usersTableNetwork));

component.connectionID = '12345678';
fixture.componentRef.setInput('value', '');
fixture.componentRef.setInput('structure', nullableStructure);

await component.ngOnInit();
fixture.detectChanges();

expect(component.allowsNull()).toBe(true);
const suggestions = component.suggestions();
expect(suggestions).toHaveLength(4);
expect(suggestions[suggestions.length - 1]).toEqual({
displayString: '— empty',
fieldValue: null,
isNullOption: true,
});
});

it('appends a null option when widget_params.allow_null is true', async () => {
vi.spyOn(tablesService, 'fetchTable').mockReturnValue(of(usersTableNetwork));

component.connectionID = '12345678';
fixture.componentRef.setInput('value', '');
fixture.componentRef.setInput('widgetStructure', {
field_name: 'userId',
widget_type: 'Foreign_key',
widget_params: { ...fakeRelations, allow_null: true },
name: '',
description: '',
});

await component.ngOnInit();
fixture.detectChanges();

expect(component.allowsNull()).toBe(true);
const suggestions = component.suggestions();
expect(suggestions[suggestions.length - 1].isNullOption).toBe(true);
});

it('does NOT append a null option when the column is not nullable', async () => {
vi.spyOn(tablesService, 'fetchTable').mockReturnValue(of(usersTableNetwork));

component.connectionID = '12345678';
fixture.componentRef.setInput('value', '');
fixture.componentRef.setInput('structure', { ...nullableStructure, allow_null: false });

await component.ngOnInit();
fixture.detectChanges();

expect(component.allowsNull()).toBe(false);
expect(component.suggestions().some((s) => s.isNullOption)).toBe(false);
});

it('keeps the null option present when search returns no rows', async () => {
fixture.componentRef.setInput('structure', nullableStructure);
component.connectionID = '12345678';

vi.spyOn(tablesService, 'fetchTable').mockReturnValue(of({ rows: [] }));
component.currentDisplayedString = 'nomatches';
await component.fetchSuggestions();

expect(component.suggestions()).toEqual([
{ displayString: 'No field starts with "nomatches" in foreign entity.' },
{ displayString: '— empty', fieldValue: null, isNullOption: true },
]);
});

it('emits null and clears the related link when the null option is selected', () => {
const emitSpy = vi.spyOn(component.onFieldChange, 'emit');
component.suggestions.set([
{ displayString: '— empty', fieldValue: null, isNullOption: true },
{ displayString: 'Alex | Taylor', primaryKeys: { id: 33 }, fieldValue: 33 },
]);
component.currentFieldQueryParams = { id: 33 };
component.currentFieldValue = 33;

component.updateRelatedLink({ option: { value: '— empty' } } as any);

expect(component.currentFieldValue).toBeNull();
expect(component.currentFieldQueryParams).toBeUndefined();
expect(emitSpy).toHaveBeenCalledWith(null);
});

it('fetchSuggestions emits null when the current display string matches the null option', async () => {
const emitSpy = vi.spyOn(component.onFieldChange, 'emit');
component.suggestions.set([
{ displayString: '— empty', fieldValue: null, isNullOption: true },
{ displayString: 'Alex | Taylor', primaryKeys: { id: 33 }, fieldValue: 33 },
]);
component.currentDisplayedString = '— empty';

await component.fetchSuggestions();

expect(component.currentFieldValue).toBeNull();
expect(emitSpy).toHaveBeenCalledWith(null);
});
});
});
Loading
Loading