@@ -5597,6 +5597,52 @@ export class ObjectStackProtocolImplementation implements
55975597 * narrowing it cost no caller anything; nothing equivalent has been measured
55985598 * for the projection axis, and this sentence is not a licence to assume it.
55995599 *
5600+ * [#7532] The DOTTED leg, which this gate used to pass on its head
5601+ * segment. `f.split('.')[0]` is what let `fields=['name','account.name']`
5602+ * through: `account` IS a field, so the entry cleared the unknown-name
5603+ * check above and reached the driver as a projection column. Measured at
5604+ * that commit on a REAL `SqlDriver` (better-sqlite3), against the same
5605+ * object the card reports:
5606+ *
5607+ * ```
5608+ * no projection -> account amount created_at id name status updated_at
5609+ * fields ['name'] -> name (a plain name narrows)
5610+ * fields ['name','account.name'] -> account amount created_at id name status updated_at
5611+ * fields ['account.name'] -> account amount created_at id name status updated_at
5612+ * ```
5613+ *
5614+ * The dotted rows are BYTE-IDENTICAL to no projection at all — the exact
5615+ * "asked for less, received more" this axis' first paragraph describes,
5616+ * reached by a different route. Knex renders `"account"."name"` against a
5617+ * table that was never joined, sqlite answers `no such column`, and
5618+ * `SqlDriver`'s #3821 recovery ladder retries `select('*')` because rows
5619+ * matter more than the projection. That ladder is a DRIVER-side tolerance
5620+ * for internal callers and is deliberately left alone here (filed
5621+ * separately as defence-in-depth); refusing at this ingress is what stops a
5622+ * request from reaching it carrying a projection no driver can apply.
5623+ *
5624+ * It also settles the card's second complaint: an unknown PLAIN column was
5625+ * a 400 while an unknown DOTTED one was a 200 with every field, so one
5626+ * mistake got opposite verdicts on one endpoint depending on spelling.
5627+ *
5628+ * The governing precedent is #5918 on the analytics MEASURES axis, which
5629+ * faced this exact shape and ruled the same way: refuse the dotted member
5630+ * loudly, naming the caller's original spelling, *because there is no
5631+ * correct answer to converge on*. That is the distinction from #5739, where
5632+ * refusing would have rejected queries that already compiled correctly.
5633+ * Here — as there — nothing resolved these paths, so both the typo
5634+ * (`titel.name`) and the genuine traversal intent (`account.name`) eat this
5635+ * 400: the two are not separable at this door, and the alternative is the
5636+ * over-return above.
5637+ *
5638+ * NOT a removal of a working feature — nothing resolved these paths. The
5639+ * spec's `fields` description, `query-syntax.mdx`, `data/query.mdx` and the
5640+ * `query.joins` / nested-select retirement prescriptions all still offer a
5641+ * dotted `fields` path as the way to read one related column; every one of
5642+ * them describes behaviour no driver implements. Aligning that prose with
5643+ * `expand` is spec/docs surface with its own blast radius and is called out
5644+ * on the PR rather than smuggled in here.
5645+ *
56005646 * [#4196] It also owns the projection's SHAPE, which is a different
56015647 * question from its names and is answered first — see below.
56025648 */
@@ -5620,9 +5666,15 @@ export class ObjectStackProtocolImplementation implements
56205666 ? ' The nested-select object form `{ field, fields, alias }` was removed in '
56215667 + '@objectstack/spec 17 (#4196) — no engine or driver ever read it.'
56225668 : '')
5623- + " Select related records with `expand` (`expand=owner` / `{ expand: { owner: "
5624- + "{ object: 'user', fields: ['name'] } } }`), or name one related column with a "
5625- + 'dotted path (`select=owner.name`).',
5669+ // [#7532] The dotted-path half of this prescription is GONE.
5670+ // It pointed at a spelling this same gate now refuses — and
5671+ // before that refusal it pointed at a spelling no driver
5672+ // resolves, which answered with every field. Naming it here
5673+ // sent the author from one refusal straight into the widening
5674+ // defect, the same dead end #6924 removed from the SORT axis'
5675+ // hint. `expand` is the one door for related data on this axis.
5676+ + " Select related records with `expand` (`expand=owner`, or `{ expand: { owner: "
5677+ + "{ object: 'user', fields: ['name'] } } }` to choose its columns).",
56265678 );
56275679 err.code = 'INVALID_FIELD';
56285680 err.status = 400;
@@ -5632,24 +5684,68 @@ export class ObjectStackProtocolImplementation implements
56325684 }
56335685 const gate = this.resolveQueryFields(object);
56345686 if (!gate) return;
5635- const unknown = (fields as string[]).filter((f) => !gate.known.has(f.split('.')[0]));
5636- if (unknown.length === 0) return;
5637- const first = unknown[0];
5638- const err: any = new Error(
5639- `Unknown field '${first}' on object '${object}'`
5640- + (unknown.length > 1 ? ` (also: ${unknown.slice(1).join(', ')})` : '')
5641- + `. '${param}' chooses which fields to return; dropping an unknown one silently `
5642- + 'answered a NARROWER projection with a WIDER one — a projection naming no known '
5643- + 'field fell all the way back to every field.'
5644- + suggestFieldName(first, gate.declared),
5687+ const names = fields as string[];
5688+ const unknown = names.filter((f) => !gate.known.has(f.split('.')[0]));
5689+ if (unknown.length > 0) {
5690+ const first = unknown[0];
5691+ const unknownErr: any = new Error(
5692+ `Unknown field '${first}' on object '${object}'`
5693+ + (unknown.length > 1 ? ` (also: ${unknown.slice(1).join(', ')})` : '')
5694+ + `. '${param}' chooses which fields to return; dropping an unknown one silently `
5695+ + 'answered a NARROWER projection with a WIDER one — a projection naming no known '
5696+ + 'field fell all the way back to every field.'
5697+ + suggestFieldName(first, gate.declared),
5698+ );
5699+ unknownErr.code = 'INVALID_FIELD';
5700+ unknownErr.status = 400;
5701+ unknownErr.field = first;
5702+ unknownErr.fields = unknown;
5703+ unknownErr.object = object;
5704+ unknownErr.param = param;
5705+ throw unknownErr;
5706+ }
5707+ // [#7532] The DOTTED verdict — the leg the head-segment check above
5708+ // does not cover, and the one that made this axis fail in the very
5709+ // direction its own docblock warns about.
5710+ //
5711+ // Ordered `unknown` > `dotted`, the same precedence
5712+ // {@link assertSortFieldsExist} applies, so the two axes agree about
5713+ // which complaint a caller hears first when an entry is both.
5714+ //
5715+ // It sits AFTER the `gate` early-return for the same reason the sort
5716+ // axis' dotted verdict does: the relation-vs-not split below reads
5717+ // `gate.fields`, and a registry-less host has no field map to read.
5718+ const dotted = names.filter((f) => f.includes('.'));
5719+ if (dotted.length === 0) return;
5720+ const first = dotted[0];
5721+ const head = first.split('.')[0];
5722+ const headDef: any = gate.fields[head];
5723+ const crossesRelation = headDef != null && REFERENCE_VALUE_TYPES.has(headDef.type);
5724+ const dottedErr: any = new Error(
5725+ (crossesRelation
5726+ ? `Field '${first}' on object '${object}' follows the relationship '${head}' into `
5727+ + `another object — '${param}' reaches only columns of '${object}' itself`
5728+ : `Field '${first}' on object '${object}' is a dotted path — '${param}' reaches only `
5729+ + `whole columns of '${object}', not values inside them`)
5730+ + (dotted.length > 1 ? ` (also: ${dotted.slice(1).join(', ')})` : '')
5731+ + '. No driver resolves it: the path reaches the driver as a column name, matches no '
5732+ + 'column, and the projection falls back to EVERY field — a narrower request answered '
5733+ + 'with a wider response, which is the same failure the unknown-name refusal above '
5734+ + 'exists to stop.'
5735+ + (crossesRelation
5736+ ? ` Read the related record with 'expand' (\`expand=${head}\`, or `
5737+ + `\`{ expand: { ${head}: { object: '<target>', fields: ['<column>'] } } }\` to `
5738+ + `choose its columns), or denormalise the value onto '${object}' (a stored `
5739+ + 'field, written when the source changes) and name that.'
5740+ : ` Name the whole column ('${head}') and read into its value in the caller.`),
56455741 );
5646- err .code = 'INVALID_FIELD';
5647- err .status = 400;
5648- err .field = first;
5649- err .fields = unknown ;
5650- err .object = object;
5651- err .param = param;
5652- throw err ;
5742+ dottedErr .code = 'INVALID_FIELD';
5743+ dottedErr .status = 400;
5744+ dottedErr .field = first;
5745+ dottedErr .fields = dotted ;
5746+ dottedErr .object = object;
5747+ dottedErr .param = param;
5748+ throw dottedErr ;
56535749 }
56545750
56555751 /**
0 commit comments