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
40 changes: 40 additions & 0 deletions .changeset/10016-binding-limit-not-authored.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
---
'@object-ui/core': minor
'@object-ui/react': minor
---

A `dataSource` binding's own `limit` that the contract refuses is now treated as **not
authored**: the row cap falls through to the named saved view's usable cap, and only when
that is absent too to the consumer's own default (objectui#10016).

**Behaviour change.** `composeElementDataSource` resolved the cap as
`config.limit ?? savedViewLimit(view)`. objectui#9928 put a positivity check on the view's
operand only, so a binding `limit` of `0`, `-10`, `25.5` or a non-number went through
unchecked:

- through `ViewDataProvider.resolveElementDataSource` it reached `DataFetcher.fetchRecords`
verbatim, and nothing said so;
- through `ElementDataSourceGate` it was written over everything, a usable component cap
included, and the consuming block then dropped it and drew its own default, so the read
went wider than either the view or the component asked for.

Both operands now pass the same check, and a refused cap from either is not authored. The
chain is: a usable binding cap, else a usable view cap, else none. This is the rule
objectui#10009 set one layer up (a value the contract refuses is not authored, so the other
source wins), applied to the two operands of one resolver. On the renderer path the view's
cap that takes the binding's place is a baseline like any other view-sourced value, so a
usable component cap still wins over it. A usable binding `limit` still beats both, exactly
as before.

**Diagnostics.** `elementDataSourceRefusedLimitMessage` takes two optional trailing
parameters, the binding and the operand (`'view'`, the default, or `'binding'`). The binding
operand has its own sentence, naming the binding, so a binding refusal and a view refusal
are told apart when both fire. `ViewDataProvider` and `ElementDataSourceGate` report it once
per declaration on the existing `console.warn` channel; the gate reports it only for a block
that reads a row cap. Called with three arguments the builder answers exactly as before.

**Fixed with it.** `ViewDataProvider` reported a saved view's refused cap even when the
binding's own usable `limit` was the cap actually used, and that warning's claim that the
fetch falls back to a default was false. The view's refusal is now reported only when the
binding's `limit` is absent or refused. The builder applies that condition itself, so both
callers share one copy of it.
18 changes: 16 additions & 2 deletions packages/core/src/data-scope/ViewDataProvider.ts
Original file line number Diff line number Diff line change
Expand Up @@ -362,8 +362,22 @@ export class ViewDataProvider {
// channel those sites use. ⛔ Not an `error`: the refusal is FAIL-SOFT and
// the records still load, so blanking the result would be a worse outcome
// than the defect.
const refusedLimit = elementDataSourceRefusedLimitMessage(view, config.view, config.object);
if (refusedLimit) console.warn(refusedLimit);
//
// Both operands of the chain are asked (objectui#10016): a refused binding
// `limit` is not authored and yields to the view's cap, so it is reported
// in its own words. The builder is also handed the binding for the VIEW's
// sentence, because that sentence says the fetch falls back to a default,
// which is false when the binding's own usable cap is what gets used.
for (const operand of ['binding', 'view'] as const) {
const refusedLimit = elementDataSourceRefusedLimitMessage(
view,
config.view,
config.object,
config,
operand,
);
if (refusedLimit) console.warn(refusedLimit);
}

const fields = Array.isArray(composed.columns)
? composed.columns.filter((c): c is string => typeof c === 'string' && !!c)
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,248 @@
/**
* ObjectUI
* Copyright (c) 2024-present ObjectStack Inc.
*
* This source code is licensed under the MIT license found in the
* LICENSE file in the root directory of this source tree.
*
* objectui#10016 — the BINDING's own `limit`, the other operand of the row-cap
* chain, gets the positivity check objectui#9928 gave the saved view's.
*
* Maintainer ruling, option A: a binding `limit` the contract refuses is NOT
* AUTHORED. It yields exactly as an absent one does, to the view's usable cap
* and then to the consumer's own default. One rule for both operands, the rule
* objectui#10009 set one layer up (a refused value is not authored, the other
* source wins).
*
* The file is the ruling's truth table, binding {absent, usable, refused} ×
* view {no cap, usable, refused}, on the pure composer and on
* `ViewDataProvider`, the caller with no renderer and no guard of its own. The
* renderer caller, `ElementDataSourceGate`, pins the same rows in its own
* suite.
*
* Only the rows with a refused binding may change what is RESOLVED. Elsewhere
* only the warnings may change, and only in one row: a usable binding cap over
* a refused view cap. There the view's warning said the fetch falls back to a
* default, which was false because the binding's cap is what gets used. That
* is the defect carried on the card's thread (comment 5814132982).
*
* ⚠️ Every CONTROL row passes both BEFORE and AFTER the change. A change that
* stopped honouring any binding cap would pass the refused rows and fail them.
*/

import { describe, it, expect, vi, afterEach } from 'vitest';
import {
composeElementDataSource,
elementDataSourceRefusedLimitMessage,
} from '../element-data-source';
import { ViewDataProvider, type DataFetcher } from '../ViewDataProvider';

/**
* What the contract refuses for a binding `limit`. The string is a value the
* binding's `??` used to pass through verbatim, so it is refused here too.
*/
const REFUSED: readonly unknown[] = [0, -10, 25.5, '20'];

/** The view operand's three states. */
const VIEW_NO_CAP = { label: 'Hot' };
const VIEW_USABLE = { pagination: { pageSize: 25 } };
const VIEW_REFUSED = { pagination: { pageSize: 0 } };

const bound = (limit?: unknown) =>
({ object: 'account', view: 'hot', ...(limit === undefined ? {} : { limit }) }) as {
object: string;
view: string;
limit?: number;
};

describe('objectui#10016 — a refused binding `limit` is not authored', () => {
describe('the composed `limit` — the truth table', () => {
it.each(REFUSED)('binding %s + view with no cap ⇒ no cap (the consumer default)', (bad) => {
const composed = composeElementDataSource(bound(bad), VIEW_NO_CAP);
expect(composed.limit).toBeUndefined();
expect('limit' in composed).toBe(false);
});

it.each(REFUSED)('binding %s + usable view cap ⇒ the view’s cap', (bad) => {
expect(composeElementDataSource(bound(bad), VIEW_USABLE).limit).toBe(25);
});

it.each(REFUSED)('binding %s + refused view cap ⇒ no cap', (bad) => {
const composed = composeElementDataSource(bound(bad), VIEW_REFUSED);
expect(composed.limit).toBeUndefined();
expect('limit' in composed).toBe(false);
});

it.each(REFUSED)('binding %s and no view at all ⇒ no cap', (bad) => {
const composed = composeElementDataSource({ object: 'account', limit: bad as number });
expect(composed.limit).toBeUndefined();
});

it('binding 0 + legacy flat view `limit` ⇒ the view’s cap (the view’s second carrier)', () => {
expect(composeElementDataSource(bound(0), { limit: 9 }).limit).toBe(9);
});

// ---------------------------------------------------------------- CONTROLS
it('CONTROL — usable binding + view with no cap ⇒ the binding’s cap', () => {
expect(composeElementDataSource(bound(3), VIEW_NO_CAP).limit).toBe(3);
});

it('CONTROL — usable binding + usable view cap ⇒ the binding’s cap', () => {
expect(composeElementDataSource(bound(3), VIEW_USABLE).limit).toBe(3);
});

it('CONTROL — usable binding + refused view cap ⇒ the binding’s cap', () => {
expect(composeElementDataSource(bound(3), VIEW_REFUSED).limit).toBe(3);
});

it('CONTROL — no binding cap: no view cap, a usable one, a refused one', () => {
expect(composeElementDataSource(bound(), VIEW_NO_CAP).limit).toBeUndefined();
expect(composeElementDataSource(bound(), VIEW_USABLE).limit).toBe(25);
expect(composeElementDataSource(bound(), VIEW_REFUSED).limit).toBeUndefined();
});

it('CONTROL — a `null` binding `limit` stays what `??` made it: absent', () => {
expect(composeElementDataSource(bound(null), VIEW_USABLE).limit).toBe(25);
});
});

describe('the message — each operand in its own words', () => {
it.each(REFUSED)('the binding operand names the binding, the object and the refused %s', (bad) => {
const msg = elementDataSourceRefusedLimitMessage(VIEW_USABLE, 'hot', 'account', bound(bad), 'binding');
expect(msg).not.toBeNull();
expect(msg).toContain('binding on account');
expect(msg).toContain(typeof bad === 'string' ? JSON.stringify(bad) : String(bad));
expect(msg).toContain('positive integer');
});

it('the binding operand speaks whatever the view carries', () => {
for (const view of [VIEW_NO_CAP, VIEW_USABLE, VIEW_REFUSED, undefined]) {
expect(elementDataSourceRefusedLimitMessage(view, 'hot', 'account', bound(0), 'binding')).not.toBeNull();
}
});

it('both refused ⇒ two messages, and they are told apart by the operand they name', () => {
const binding = elementDataSourceRefusedLimitMessage(VIEW_REFUSED, 'hot', 'account', bound(0), 'binding');
const view = elementDataSourceRefusedLimitMessage(VIEW_REFUSED, 'hot', 'account', bound(0), 'view');
expect(binding).not.toBeNull();
expect(view).not.toBeNull();
expect(binding).not.toBe(view);
expect(binding).toContain('binding on account');
expect(view).toContain('saved view "hot" on account');
expect(view).not.toContain('binding on account');
});

it('a usable binding cap silences the VIEW’s refusal, which changed nothing (the carried defect)', () => {
expect(elementDataSourceRefusedLimitMessage(VIEW_REFUSED, 'hot', 'account', bound(3))).toBeNull();
expect(elementDataSourceRefusedLimitMessage(VIEW_REFUSED, 'hot', 'account', bound(3), 'view')).toBeNull();
});

it('a refused binding cap does NOT silence the view’s refusal: neither supplied a cap', () => {
expect(elementDataSourceRefusedLimitMessage(VIEW_REFUSED, 'hot', 'account', bound(0))).not.toBeNull();
});

// -------------------------------------------------------- SILENCE CONTROLS
it('SILENCE — the binding operand says nothing for an absent, `null` or usable `limit`', () => {
for (const limit of [undefined, null, 3]) {
expect(elementDataSourceRefusedLimitMessage(VIEW_REFUSED, 'hot', 'account', bound(limit), 'binding'))
.toBeNull();
}
expect(elementDataSourceRefusedLimitMessage(VIEW_REFUSED, 'hot', 'account', undefined, 'binding')).toBeNull();
});

it('SILENCE CONTROL — the view operand reads as before when no binding is passed', () => {
expect(elementDataSourceRefusedLimitMessage(VIEW_REFUSED, 'hot', 'account')).not.toBeNull();
expect(elementDataSourceRefusedLimitMessage(VIEW_USABLE, 'hot', 'account')).toBeNull();
expect(elementDataSourceRefusedLimitMessage(VIEW_REFUSED, 'hot', 'account', bound())).not.toBeNull();
});
});

describe('ViewDataProvider — the caller with no renderer and no guard of its own', () => {
afterEach(() => {
vi.restoreAllMocks();
});

/** Resolve one row: what reached the fetcher, and what was said. */
const row = async (limit: unknown, viewConfig: Record<string, unknown> | null) => {
const warn = vi.spyOn(console, 'warn').mockImplementation(() => {});
const fetchRecords = vi.fn<DataFetcher['fetchRecords']>(async () => ({ records: [], total: 0 }));
const provider = new ViewDataProvider();
provider.setFetcher({
fetchRecords,
fetchViews: async () => ({ hot: viewConfig ?? {} }),
});
const config = viewConfig === null
? { object: 'account', ...(limit === undefined ? {} : { limit }) }
: bound(limit);
await provider.resolveElementDataSource(config as { object: string; limit?: number });
const said = warn.mock.calls.map((c) => String(c[0]));
return { limit: fetchRecords.mock.calls[0]?.[1]?.limit, said };
};

const bindingSentence = (bad: unknown) =>
elementDataSourceRefusedLimitMessage(null, 'hot', 'account', { limit: bad }, 'binding');
const viewSentence = elementDataSourceRefusedLimitMessage(VIEW_REFUSED, 'hot', 'account');

it.each(REFUSED)('binding %s + usable view cap ⇒ `limit` 25 and ONE binding warning', async (bad) => {
const { limit, said } = await row(bad, VIEW_USABLE);
expect(limit).toBe(25);
expect(said).toEqual([bindingSentence(bad)]);
});

it.each(REFUSED)('binding %s + view with no cap ⇒ no `limit` and ONE binding warning', async (bad) => {
const { limit, said } = await row(bad, VIEW_NO_CAP);
expect(limit).toBeUndefined();
expect(said).toEqual([bindingSentence(bad)]);
});

it('binding 0 and no view named ⇒ no `limit` and ONE binding warning', async () => {
const { limit, said } = await row(0, null);
expect(limit).toBeUndefined();
expect(said).toHaveLength(1);
expect(said[0]).toContain('binding on account');
});

it('binding 0 + refused view cap ⇒ no `limit`, and one warning per refused operand', async () => {
const { limit, said } = await row(0, VIEW_REFUSED);
expect(limit).toBeUndefined();
expect(said).toEqual([bindingSentence(0), viewSentence]);
});

it('binding 3 + refused view cap ⇒ `limit` 3 and NO view warning (the carried defect)', async () => {
const { limit, said } = await row(3, VIEW_REFUSED);
expect(limit).toBe(3);
expect(said).toEqual([]);
});

// ---------------------------------------------------------------- CONTROLS
it('CONTROL — no binding cap + refused view cap ⇒ no `limit` and the view warning, as before', async () => {
const { limit, said } = await row(undefined, VIEW_REFUSED);
expect(limit).toBeUndefined();
expect(said).toEqual([viewSentence]);
});

it('CONTROL — binding 3 + usable view cap ⇒ `limit` 3, and nothing said', async () => {
const { limit, said } = await row(3, VIEW_USABLE);
expect(limit).toBe(3);
expect(said).toEqual([]);
});

it('CONTROL — binding 3 + view with no cap ⇒ `limit` 3, and nothing said', async () => {
const { limit, said } = await row(3, VIEW_NO_CAP);
expect(limit).toBe(3);
expect(said).toEqual([]);
});

it('CONTROL — no binding cap + usable view cap ⇒ `limit` 25, and nothing said', async () => {
const { limit, said } = await row(undefined, VIEW_USABLE);
expect(limit).toBe(25);
expect(said).toEqual([]);
});

it('CONTROL — no binding cap + view with no cap ⇒ no `limit`, and nothing said', async () => {
const { limit, said } = await row(undefined, VIEW_NO_CAP);
expect(limit).toBeUndefined();
expect(said).toEqual([]);
});
});
});
Original file line number Diff line number Diff line change
Expand Up @@ -24,13 +24,13 @@
* is deliberate: a change that simply stopped lowering any view cap at all
* would satisfy the refusals and fail the controls.
*
* ⚠️ KNOWN GAP, deliberately not pinned here: the BINDING's own `limit` — the
* other operand of `config.limit ?? savedViewLimit(view)` — is still admitted
* unchecked, so `dataSource: { object, limit: 0 }` still reaches the fetcher as
* `0`. That carrier raises a PRECEDENCE question this card does not own (does a
* refused binding cap suppress the view's legitimate one?), and it is reported
* rather than answered here. Nothing in this file asserts the current answer,
* so the card that settles it will not have to edit a pin that endorsed it.
* The BINDING's own `limit`, the other operand of the chain, was a known gap
* when this file was written, and it was deliberately left unpinned here
* because it raised a precedence question this card did not own. objectui#10016
* settled it (a refused binding cap is not authored and yields to the view's),
* and its truth table lives in
* `element-data-source.bindingLimitNotAuthored-10016.test.ts`. Nothing in this
* file asserted the old answer, so nothing here had to be rewritten.
*/

import { describe, it, expect, vi, afterEach } from 'vitest';
Expand Down
Loading
Loading