Skip to content
Open
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
14 changes: 14 additions & 0 deletions .changeset/query-oftype-unknown-typo.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
---
"@ifc-lite/query": major
---

**Breaking:** `IfcQuery.ofType()` now throws for a type string that is not an IFC entity name, instead of silently querying the `Unknown` bucket.

`ofType()` maps each type string through `IfcTypeEnumFromString`, which falls back to `IfcTypeEnum.Unknown` for any name it does not recognize. A typo — `ofType('IfcWal')` — therefore returned every entity whose type the store could not classify: neither the caller's walls nor an empty result, but some other, unrelated set of entities. `ofType()` now rejects such a string with an error naming it.

What still works unchanged:

- **Standard IFC types that this build's enum table does not map.** `TYPE_STRING_TO_ENUM` (`@ifc-lite/data`) is a curated subset of IFC, so standard buildingSMART types such as `IfcChiller`, `IfcActuator`, `IfcElectricAppliance`, `IfcBuildingSystem` and `IfcAudioVisualAppliance` also resolve to `Unknown`. These are **not** rejected — they keep falling through to the `Unknown` bucket exactly as before, which is the only representation this build has for them and which answers the query correctly in a file whose unclassified entities are of that type. The check is keyed on `IFC_ENTITY_NAMES`, the full IFC4X3 entity-name table, not on whether the enum table happened to have a row.
- **The `Unknown` bucket itself**, still reachable by passing the literal string `'Unknown'`.

What breaks: a call passing a name that is not an IFC entity name at all — a typo, or a genuine vendor-specific type name — previously returned an `EntityQuery` over the `Unknown` bucket and now throws. Callers relying on a vendor-specific name to reach unclassified entities must pass `'Unknown'` instead. Hence the major bump: this is a behaviour change on a published SDK export, not a bug fix that is invisible to correct callers.
44 changes: 42 additions & 2 deletions packages/query/src/ifc-query.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,12 @@
*/

import type { IfcDataStore } from '@ifc-lite/parser';
import { IfcTypeEnumFromString, type SpatialHierarchy } from '@ifc-lite/data';
import {
IFC_ENTITY_NAMES,
IfcTypeEnum,
IfcTypeEnumFromString,
type SpatialHierarchy,
} from '@ifc-lite/data';
import { EntityQuery } from './entity-query.js';
import { EntityNode } from './entity-node.js';
import { DuckDBIntegration, type SQLResult } from './duckdb-integration.js';
Expand Down Expand Up @@ -82,7 +87,42 @@ export class IfcQuery {
}

ofType(...types: string[]): EntityQuery {
const typeEnums = types.map(t => IfcTypeEnumFromString(t));
// `IfcTypeEnumFromString` falls back to `IfcTypeEnum.Unknown` for any name
// it does not recognize. That fallback conflates two very different cases:
//
// 1. A typo (`ofType('IfcWal')`). `IfcWal` is not an IFC entity name at
// all, so the caller can only have meant `IfcWall`. Left unchecked the
// query silently returns the Unknown bucket - every entity the store
// itself could not classify - which is neither the caller's wall nor
// an empty result, but some other, unrelated set of entities.
//
// 2. A real IFC entity name that `TYPE_STRING_TO_ENUM` (data/types.ts)
// simply has no entry for. That table is a curated subset, so standard
// IFC4/IFC4X3 types such as `IfcChiller`, `IfcActuator` or
// `IfcBuildingSystem` map to `Unknown` too. For those the Unknown
// bucket is the only representation available and querying it is the
// documented, working behaviour - a file whose sole unclassified
// entities are chillers really does answer `ofType('IfcChiller')`
// correctly this way.
//
// Only case 1 is rejected. `IFC_ENTITY_NAMES` (the ~880-entry IFC4X3
// entity-name table in @ifc-lite/data) is the oracle for "is this a real
// IFC entity name", so case 2 keeps falling through to Unknown unchanged.
// A genuine query for the Unknown bucket is still made by passing the
// literal string `'Unknown'`.
const typeEnums = types.map(t => {
const typeEnum = IfcTypeEnumFromString(t);
if (typeEnum === IfcTypeEnum.Unknown) {
const upper = t.trim().toUpperCase();
if (upper !== 'UNKNOWN' && IFC_ENTITY_NAMES[upper] === undefined) {
throw new Error(
`ofType(): "${t}" is not an IFC entity name - check the spelling. ` +
`To query entities whose type could not be classified, pass 'Unknown'.`
);
}
}
return typeEnum;
});
return new EntityQuery(this.store, typeEnums);
}

Expand Down
101 changes: 101 additions & 0 deletions packages/query/test/oftype-unknown-type.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,101 @@
/* This Source Code Form is subject to the terms of the Mozilla Public
* License, v. 2.0. If a copy of the MPL was not distributed with this
* file, You can obtain one at https://mozilla.org/MPL/2.0/. */

/**
* `IfcQuery.ofType()` maps a type string through `IfcTypeEnumFromString`,
* which falls back to `IfcTypeEnum.Unknown` for any name it does not
* recognize. That single fallback covers two different situations, and only
* one of them is a caller error:
*
* - `'IfcWal'` is not an IFC entity name at all, so the caller meant
* `'IfcWall'`. Silently answering with the Unknown bucket - every entity
* the store could not classify - returns some other, unrelated set of
* entities. `ofType()` rejects this.
*
* - `'IfcChiller'` IS a standard IFC4 entity name; `TYPE_STRING_TO_ENUM`
* (packages/data/src/types.ts) is a curated subset that has no row for it,
* so it maps to Unknown as well. The Unknown bucket is the only
* representation this build has for such an entity, and querying it is the
* correct, pre-existing behaviour. `ofType()` must NOT reject these - the
* discriminator is `IFC_ENTITY_NAMES`, not the enum table.
*
* See `ifc-query.ts`.
*/

import { describe, it, expect } from 'vitest';
import { createMockStore } from './mock-store.js';
import { IfcQuery } from '../src/ifc-query.js';

/**
* Standard buildingSMART entity names that `TYPE_STRING_TO_ENUM` has no entry
* for. Each maps to `IfcTypeEnum.Unknown`, so a rule keyed on "did this map to
* Unknown?" alone would wrongly reject all five.
*/
const STANDARD_BUT_UNMAPPED = [
'IfcChiller',
'IfcActuator',
'IfcElectricAppliance',
'IfcBuildingSystem',
'IfcAudioVisualAppliance',
] as const;

function storeWithUnclassified(unclassifiedType: string) {
return createMockStore({
entities: [
{ expressId: 10, type: 'IFCWALL', globalId: 'g10', name: 'Real Wall' },
{
expressId: 20,
type: unclassifiedType.trim().toUpperCase(),
globalId: 'g20',
name: 'Unclassified',
},
],
});
}

describe('ofType() rejects a type string that is not an IFC entity name', () => {
it('throws on a typo rather than silently matching the Unknown bucket', () => {
const query = new IfcQuery(storeWithUnclassified('IFCCHILLER') as any);
// Caller made a typo: 'IfcWal' instead of 'IfcWall'.
expect(() => query.ofType('IfcWal')).toThrow(/is not an IFC entity name/);
});

it('throws on a name that is not in the IFC schema at all', () => {
const query = new IfcQuery(storeWithUnclassified('IFCCHILLER') as any);
expect(() => query.ofType('IFCPROPRIETARYVENDORTHING')).toThrow(
/is not an IFC entity name/,
);
});

it('rejects a bad name even when a good one is passed alongside it', () => {
const query = new IfcQuery(storeWithUnclassified('IFCCHILLER') as any);
expect(() => query.ofType('IfcWall', 'IfcWal')).toThrow(/is not an IFC entity name/);
});

it('still allows an explicit query for the Unknown bucket itself', async () => {
const query = new IfcQuery(storeWithUnclassified('IFCPROPRIETARYVENDORTHING') as any);
const ids = await query.ofType('Unknown').ids();
expect(ids).toEqual([20]);
});
});

describe('ofType() accepts standard IFC types the enum table does not map', () => {
for (const typeName of STANDARD_BUT_UNMAPPED) {
it(`${typeName} does not throw and still reaches the Unknown bucket`, async () => {
const query = new IfcQuery(storeWithUnclassified(typeName) as any);
expect(() => query.ofType(typeName)).not.toThrow();
// The store's only unclassified entity is the one of this very type, so
// the Unknown bucket answers the query correctly - as it did before the
// guard existed. Entity 10 (a mapped IfcWall) must not leak in.
const ids = await query.ofType(typeName).ids();
expect(ids).toEqual([20]);
});
}

it('accepts a standard unmapped type in any casing, with surrounding space', () => {
const query = new IfcQuery(storeWithUnclassified('IfcChiller') as any);
expect(() => query.ofType('IFCCHILLER')).not.toThrow();
expect(() => query.ofType(' ifcchiller ')).not.toThrow();
});
});
Loading