Skip to content

Commit b9a7d0b

Browse files
Merge pull request #2 from apify/claude/safepushdata-placeholder-values-m9ncfy
change: simplify placeholder strategy to only use non-controversial empty values
2 parents 19bbdd3 + fd698a8 commit b9a7d0b

3 files changed

Lines changed: 123 additions & 97 deletions

File tree

README.md

Lines changed: 22 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -47,25 +47,30 @@ every AJV error per item:
4747
### Placeholder defaults
4848

4949
When a constraint fires on a path we placeholder'd ourselves, the wrapper
50-
picks a value that should satisfy it:
51-
52-
| AJV keyword | Placeholder value |
53-
| ------------------------------------------------------- | ------------------------------- |
54-
| `type: string` | `''` |
55-
| `type: integer` / `number` | `0` |
56-
| `type: boolean` | `false` |
57-
| `type: array` | `[]` |
58-
| `type: object` | `{}` |
59-
| `type: null` | `null` |
60-
| `minLength: N` / `maxLength` | `'_'.repeat(N)` / `''` |
61-
| `minimum: N` / `maximum` | `N` |
62-
| `exclusiveMinimum: N` / `exclusiveMaximum` | `N + 1` / `N - 1` |
63-
| `enum` | First allowed value |
64-
| `format: email` / `uri` / `date` / `date-time` / `uuid` | a static valid example for each |
65-
| Anything else (`pattern`, custom formats…) | Item is dropped. |
50+
picks a value that should satisfy it. We deliberately only fill in the four
51+
**empty** values below — they're unambiguously empty and can't be mistaken
52+
for real data:
53+
54+
| AJV keyword | Placeholder value |
55+
| -------------- | ----------------- |
56+
| `type: string` | `''` |
57+
| `type: array` | `[]` |
58+
| `type: object` | `{}` |
59+
| `type: null` | `null` |
60+
| Anything else | Item is dropped. |
61+
62+
When a field allows **multiple types** (e.g. `['string', 'null']`), the
63+
wrapper always picks `null` — it's the cleanest placeholder because it
64+
commits to no concrete value at all.
65+
66+
Everything else (`enum`, `format`, `minLength`, numeric bounds, `type:
67+
integer` / `number` / `boolean`, …) is **not** placeholdered: a made-up
68+
email, a first-enum-value, or a fabricated number would silently poison the
69+
customer's dataset with plausible-looking junk, so the item is dropped
70+
instead.
6671

6772
The retry loop chases one layer of errors per round
68-
(`required``type``minLength`push) until either the push succeeds
73+
(`required``type` → push) until either the push succeeds
6974
or `maxAttempts` (default 5) is hit.
7075

7176
## Options

src/safePushData.ts

Lines changed: 46 additions & 68 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,13 @@
11
// safePushData: parse the Apify dataset schema-validation error, repair the
22
// offending items (strip bad fields, placeholder missing required ones), and
33
// retry the push.
4+
//
5+
// NOTE: instead of recursively healing the data one error-round at a time, we
6+
// could parse the Actor's `dataset_schema.json` up front and fix every item in
7+
// a single pass (we'd know each field's expected type / constraints without
8+
// waiting for the API to report them). That would avoid the multi-round retry
9+
// loop, but requires heavier code (locating + loading the schema, resolving
10+
// $refs, walking the schema tree). Something to consider in the future.
411

512
const SCHEMA_ERROR_TYPE = 'schema-validation-error';
613

@@ -234,77 +241,48 @@ function cleanItemFields<T>(item: T, validationErrors: ValidationError[], placeh
234241
}
235242

236243
// Pick a value that will satisfy `err.keyword` on a placeholder field.
237-
// Returns ok:false when we don't have a sensible default (e.g. `pattern`,
238-
// custom formats); the caller drops the item in that case.
244+
// Returns ok:false when we don't have a sensible default; the caller drops
245+
// the item in that case.
246+
//
247+
// We deliberately only placeholder the four "empty" values — `''`, `[]`, `{}`,
248+
// and `null`. These are unambiguously empty and can't be mistaken for real
249+
// data. We do NOT fabricate values for `enum`, `format`, `minLength`, numeric
250+
// bounds, etc.: a made-up email, a first-enum-value, or a `'_'.repeat(N)`
251+
// string all silently poison the customer's dataset with plausible-looking
252+
// junk. Better to drop the item than to lie about its contents. As a result
253+
// the only keyword we handle is `type` (only for those four target types) —
254+
// everything else falls through to ok:false and the item is dropped.
239255
function placeholderFor(err: ValidationError): { ok: true; value: unknown } | { ok: false } {
240256
const params = err.params ?? {};
241-
switch (err.keyword) {
242-
case 'type': {
243-
// params.type is the expected type as a string, or array of strings.
244-
const t = Array.isArray(params.type) ? params.type[0] : params.type;
245-
switch (t) {
246-
case 'string':
247-
return { ok: true, value: '' };
248-
case 'integer':
249-
case 'number':
250-
return { ok: true, value: 0 };
251-
case 'boolean':
252-
return { ok: true, value: false };
253-
case 'array':
254-
return { ok: true, value: [] };
255-
case 'object':
256-
return { ok: true, value: {} };
257-
case 'null':
258-
return { ok: true, value: null };
259-
default:
260-
break;
261-
}
262-
return { ok: false };
263-
}
264-
case 'minLength': {
265-
const limit = Number(params.limit) || 1;
266-
return { ok: true, value: '_'.repeat(limit) };
267-
}
268-
case 'maxLength':
269-
return { ok: true, value: '' };
270-
case 'minimum':
271-
case 'exclusiveMinimum': {
272-
const limit = Number(params.limit);
273-
if (!Number.isFinite(limit)) return { ok: false };
274-
return { ok: true, value: err.keyword === 'exclusiveMinimum' ? limit + 1 : limit };
275-
}
276-
case 'maximum':
277-
case 'exclusiveMaximum': {
278-
const limit = Number(params.limit);
279-
if (!Number.isFinite(limit)) return { ok: false };
280-
return { ok: true, value: err.keyword === 'exclusiveMaximum' ? limit - 1 : limit };
281-
}
282-
case 'enum': {
283-
const allowed = params.allowedValues;
284-
if (Array.isArray(allowed) && allowed.length > 0) return { ok: true, value: allowed[0] };
285-
return { ok: false };
286-
}
287-
case 'format': {
288-
switch (params.format) {
289-
case 'email':
290-
return { ok: true, value: 'placeholder@example.com' };
291-
case 'uri':
292-
case 'uri-reference':
293-
case 'url':
294-
return { ok: true, value: 'about:blank' };
295-
case 'date':
296-
return { ok: true, value: '1970-01-01' };
297-
case 'date-time':
298-
return { ok: true, value: '1970-01-01T00:00:00Z' };
299-
case 'uuid':
300-
return { ok: true, value: '00000000-0000-0000-0000-000000000000' };
301-
default:
302-
break;
303-
}
304-
return { ok: false };
257+
if (err.keyword !== 'type') return { ok: false };
258+
259+
// params.type is the expected type as a string, or an array of strings
260+
// when the field allows multiple types (e.g. `['string', 'null']`).
261+
const types = Array.isArray(params.type) ? params.type : [params.type];
262+
263+
// Union type that permits null: prefer null. It's the cleanest possible
264+
// placeholder — it commits to no concrete value at all — so whenever the
265+
// schema allows it, that's what we use.
266+
if (types.length > 1 && types.includes('null')) {
267+
return { ok: true, value: null };
268+
}
269+
270+
// Otherwise take the first allowed type we have an "empty" default for.
271+
// integer / number / boolean are intentionally absent: 0 / false read as
272+
// real data, so a field of only those types is dropped instead.
273+
for (const t of types) {
274+
switch (t) {
275+
case 'null':
276+
return { ok: true, value: null };
277+
case 'string':
278+
return { ok: true, value: '' };
279+
case 'array':
280+
return { ok: true, value: [] };
281+
case 'object':
282+
return { ok: true, value: {} };
283+
default:
284+
break;
305285
}
306-
default:
307-
break;
308286
}
309287
return { ok: false };
310288
}

test/safePushData.test.ts

Lines changed: 55 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -125,8 +125,11 @@ test('placeholders a missing required field, then satisfies the type', async ()
125125
assert.deepEqual(calls[calls.length - 1][0], { age: 30, name: '' });
126126
});
127127

128-
test('chases required -> type -> minLength on the same placeholder field', async () => {
128+
test('drops item when a placeholder field carries a minLength it cannot satisfy', async () => {
129129
// Schema: { required: ['name'], properties: { name: { type: 'string', minLength: 3 } } }
130+
// We placeholder name to '' (type: string), but we no longer fabricate a
131+
// `'_'.repeat(N)` string for minLength — a made-up string is customer-data
132+
// poison — so the item is dropped instead.
130133
const validate = (item: Item): ValidationError[] | null => {
131134
const errors: ValidationError[] = [];
132135
if (item?.name === undefined) {
@@ -158,14 +161,17 @@ test('chases required -> type -> minLength on the same placeholder field', async
158161
}
159162
return null;
160163
};
161-
const { pushFn, calls } = makeMockPush(validate);
164+
const { pushFn } = makeMockPush(validate);
162165
const res = await safePushData(pushFn, { age: 30 }, { maxAttempts: 10 });
163-
assert.equal(res.pushed, 1);
164-
assert.equal(res.attempts, 4); // required, type, minLength, success
165-
assert.equal((calls[calls.length - 1][0].name as string).length, 3);
166+
assert.equal(res.pushed, 0);
167+
assert.equal(res.dropped.length, 1);
168+
assert.deepEqual(res.dropped[0].item, { age: 30 });
166169
});
167170

168-
test('placeholder for enum picks the first allowed value', async () => {
171+
test('drops item on enum constraint instead of fabricating the first allowed value', async () => {
172+
// We used to placeholder an enum field with its first allowed value; that
173+
// silently injects a plausible-but-wrong value into the dataset, so we now
174+
// drop the item instead.
169175
const validate = (item: Item): ValidationError[] | null => {
170176
if (item?.role === undefined) {
171177
return [
@@ -200,13 +206,15 @@ test('placeholder for enum picks the first allowed value', async () => {
200206
}
201207
return null;
202208
};
203-
const { pushFn, calls } = makeMockPush(validate);
204-
const res = await safePushData(pushFn, { name: 'x' });
205-
assert.equal(res.pushed, 1);
206-
assert.equal(calls[calls.length - 1][0].role, 'admin');
209+
const { pushFn } = makeMockPush(validate);
210+
const res = await safePushData(pushFn, { name: 'x' }, { maxAttempts: 10 });
211+
assert.equal(res.pushed, 0);
212+
assert.equal(res.dropped.length, 1);
207213
});
208214

209-
test('placeholder for format=email', async () => {
215+
test('drops item on format=email instead of fabricating a fake address', async () => {
216+
// A made-up `placeholder@example.com` is exactly the kind of junk we no
217+
// longer inject; the item is dropped instead.
210218
const validate = (item: Item): ValidationError[] | null => {
211219
if (item?.email === undefined) {
212220
return [
@@ -240,10 +248,45 @@ test('placeholder for format=email', async () => {
240248
}
241249
return null;
242250
};
251+
const { pushFn } = makeMockPush(validate);
252+
const res = await safePushData(pushFn, { name: 'x' }, { maxAttempts: 10 });
253+
assert.equal(res.pushed, 0);
254+
assert.equal(res.dropped.length, 1);
255+
});
256+
257+
test('required field with a union type that allows null is placeholder-filled with null', async () => {
258+
// Schema: { required: ['note'], properties: { note: { type: ['string', 'null'] } } }
259+
// The initial `required` placeholder sets note = null; because the field
260+
// allows null, the follow-up type error reports both allowed types and we
261+
// keep null (the cleanest placeholder) rather than coercing to ''.
262+
const validate = (item: Item): ValidationError[] | null => {
263+
if (!('note' in (item ?? {}))) {
264+
return [
265+
{
266+
instancePath: '',
267+
keyword: 'required',
268+
params: { missingProperty: 'note' },
269+
message: "must have required property 'note'",
270+
},
271+
];
272+
}
273+
if (item.note !== null && typeof item.note !== 'string') {
274+
return [
275+
{
276+
instancePath: '/note',
277+
keyword: 'type',
278+
params: { type: ['string', 'null'] },
279+
message: 'must be string,null',
280+
},
281+
];
282+
}
283+
return null;
284+
};
243285
const { pushFn, calls } = makeMockPush(validate);
244286
const res = await safePushData(pushFn, { name: 'x' });
245287
assert.equal(res.pushed, 1);
246-
assert.equal(calls[calls.length - 1][0].email, 'placeholder@example.com');
288+
assert.equal(res.dropped.length, 0);
289+
assert.deepEqual(calls[calls.length - 1][0], { name: 'x', note: null });
247290
});
248291

249292
test('drops item when a placeholder constraint has no known fix (pattern)', async () => {

0 commit comments

Comments
 (0)