@saltcorn/meta-marketing-api 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +180 -0
- package/api.js +561 -0
- package/common.js +449 -0
- package/index.js +516 -0
- package/package.json +36 -0
- package/sync-action.js +337 -0
- package/table-provider.js +351 -0
- package/tests/api.test.js +94 -0
- package/tests/common.test.js +186 -0
package/common.js
ADDED
|
@@ -0,0 +1,449 @@
|
|
|
1
|
+
const crypto = require("crypto");
|
|
2
|
+
const {
|
|
3
|
+
DEFAULT_FIELDS,
|
|
4
|
+
NUMERIC_INSIGHTS_FIELDS,
|
|
5
|
+
insightsFields,
|
|
6
|
+
actId,
|
|
7
|
+
getAdAccounts,
|
|
8
|
+
getCampaigns,
|
|
9
|
+
getAdSets,
|
|
10
|
+
getCampaignAdSets,
|
|
11
|
+
getAds,
|
|
12
|
+
getCampaignAds,
|
|
13
|
+
getAdSetAds,
|
|
14
|
+
getAdCreatives,
|
|
15
|
+
getInsights,
|
|
16
|
+
getInsightsAsync,
|
|
17
|
+
} = require("./api");
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* The kinds of Meta object that can be read into a Saltcorn table, either
|
|
21
|
+
* live (table provider) or copied (sync action). Each knows how to fetch
|
|
22
|
+
* itself and which parent ids can be pushed down to a narrower endpoint.
|
|
23
|
+
*/
|
|
24
|
+
const objectTypes = {
|
|
25
|
+
"Ad accounts": {
|
|
26
|
+
kind: "adaccount",
|
|
27
|
+
fields: DEFAULT_FIELDS.adaccount,
|
|
28
|
+
needs_account: false,
|
|
29
|
+
fetch: async ({ cfg, query, opts }) => await getAdAccounts(query, cfg),
|
|
30
|
+
},
|
|
31
|
+
Campaigns: {
|
|
32
|
+
kind: "campaign",
|
|
33
|
+
fields: DEFAULT_FIELDS.campaign,
|
|
34
|
+
needs_account: true,
|
|
35
|
+
fetch: async ({ cfg, account_id, query }) =>
|
|
36
|
+
await getCampaigns(account_id, query, cfg),
|
|
37
|
+
},
|
|
38
|
+
"Ad sets": {
|
|
39
|
+
kind: "adset",
|
|
40
|
+
fields: DEFAULT_FIELDS.adset,
|
|
41
|
+
needs_account: true,
|
|
42
|
+
pushdown: ["campaign_id"],
|
|
43
|
+
fetch: async ({ cfg, account_id, query, parents }) =>
|
|
44
|
+
parents?.campaign_id
|
|
45
|
+
? await getCampaignAdSets(parents.campaign_id, query, cfg)
|
|
46
|
+
: await getAdSets(account_id, query, cfg),
|
|
47
|
+
},
|
|
48
|
+
Ads: {
|
|
49
|
+
kind: "ad",
|
|
50
|
+
fields: DEFAULT_FIELDS.ad,
|
|
51
|
+
needs_account: true,
|
|
52
|
+
pushdown: ["adset_id", "campaign_id"],
|
|
53
|
+
fetch: async ({ cfg, account_id, query, parents }) =>
|
|
54
|
+
parents?.adset_id
|
|
55
|
+
? await getAdSetAds(parents.adset_id, query, cfg)
|
|
56
|
+
: parents?.campaign_id
|
|
57
|
+
? await getCampaignAds(parents.campaign_id, query, cfg)
|
|
58
|
+
: await getAds(account_id, query, cfg),
|
|
59
|
+
},
|
|
60
|
+
"Ad creatives": {
|
|
61
|
+
kind: "adcreative",
|
|
62
|
+
fields: DEFAULT_FIELDS.adcreative,
|
|
63
|
+
needs_account: true,
|
|
64
|
+
fetch: async ({ cfg, account_id, query }) =>
|
|
65
|
+
await getAdCreatives(account_id, query, cfg),
|
|
66
|
+
},
|
|
67
|
+
Insights: {
|
|
68
|
+
kind: "insights",
|
|
69
|
+
needs_account: true,
|
|
70
|
+
synthetic_id: true,
|
|
71
|
+
fetch: async ({ cfg, account_id, query, config, opts }) => {
|
|
72
|
+
const objectId = config?.object_id || actId(account_id);
|
|
73
|
+
return config?.asynchronous
|
|
74
|
+
? await getInsightsAsync(objectId, query, cfg, opts)
|
|
75
|
+
: await getInsights(objectId, query, cfg, opts);
|
|
76
|
+
},
|
|
77
|
+
},
|
|
78
|
+
};
|
|
79
|
+
|
|
80
|
+
const objectTypeNames = Object.keys(objectTypes);
|
|
81
|
+
|
|
82
|
+
const INSIGHTS_LEVELS = ["account", "campaign", "adset", "ad"];
|
|
83
|
+
|
|
84
|
+
const DATE_PRESETS = [
|
|
85
|
+
"today",
|
|
86
|
+
"yesterday",
|
|
87
|
+
"this_week_mon_today",
|
|
88
|
+
"this_week_sun_today",
|
|
89
|
+
"last_week_mon_sun",
|
|
90
|
+
"last_week_sun_sat",
|
|
91
|
+
"this_month",
|
|
92
|
+
"last_month",
|
|
93
|
+
"this_quarter",
|
|
94
|
+
"last_quarter",
|
|
95
|
+
"this_year",
|
|
96
|
+
"last_year",
|
|
97
|
+
"last_3d",
|
|
98
|
+
"last_7d",
|
|
99
|
+
"last_14d",
|
|
100
|
+
"last_28d",
|
|
101
|
+
"last_30d",
|
|
102
|
+
"last_90d",
|
|
103
|
+
"maximum",
|
|
104
|
+
];
|
|
105
|
+
|
|
106
|
+
const EFFECTIVE_STATUSES = [
|
|
107
|
+
"ACTIVE",
|
|
108
|
+
"PAUSED",
|
|
109
|
+
"DELETED",
|
|
110
|
+
"PENDING_REVIEW",
|
|
111
|
+
"DISAPPROVED",
|
|
112
|
+
"PREAPPROVED",
|
|
113
|
+
"PENDING_BILLING_INFO",
|
|
114
|
+
"CAMPAIGN_PAUSED",
|
|
115
|
+
"ARCHIVED",
|
|
116
|
+
"ADSET_PAUSED",
|
|
117
|
+
"IN_PROCESS",
|
|
118
|
+
"WITH_ISSUES",
|
|
119
|
+
];
|
|
120
|
+
|
|
121
|
+
const splitList = (s) =>
|
|
122
|
+
typeof s === "string"
|
|
123
|
+
? s
|
|
124
|
+
.split(",")
|
|
125
|
+
.map((t) => t.trim())
|
|
126
|
+
.filter(Boolean)
|
|
127
|
+
: Array.isArray(s)
|
|
128
|
+
? s
|
|
129
|
+
: [];
|
|
130
|
+
|
|
131
|
+
const parseJSONish = (s) => {
|
|
132
|
+
if (!s) return undefined;
|
|
133
|
+
if (typeof s !== "string") return s;
|
|
134
|
+
try {
|
|
135
|
+
return JSON.parse(s);
|
|
136
|
+
} catch (e) {
|
|
137
|
+
throw new Error(`Meta Marketing API: not valid JSON: ${s}`);
|
|
138
|
+
}
|
|
139
|
+
};
|
|
140
|
+
|
|
141
|
+
/** Which fields the API will be asked for, given the object type and config */
|
|
142
|
+
const fieldsFor = (typeName, config) => {
|
|
143
|
+
const chosen = splitList(config?.fields);
|
|
144
|
+
if (chosen.length) return chosen;
|
|
145
|
+
if (typeName === "Insights") return insightsFields(config?.level);
|
|
146
|
+
return objectTypes[typeName]?.fields || ["id", "name"];
|
|
147
|
+
};
|
|
148
|
+
|
|
149
|
+
/** Turn the plugin/table/action configuration into Graph API query parameters */
|
|
150
|
+
const buildQuery = (typeName, config = {}) => {
|
|
151
|
+
const query = { fields: fieldsFor(typeName, config) };
|
|
152
|
+
if (config.limit) query.limit = config.limit;
|
|
153
|
+
|
|
154
|
+
if (typeName === "Insights") {
|
|
155
|
+
if (config.level) query.level = config.level;
|
|
156
|
+
if (config.since && config.until)
|
|
157
|
+
query.time_range = { since: config.since, until: config.until };
|
|
158
|
+
else if (config.date_preset) query.date_preset = config.date_preset;
|
|
159
|
+
if (config.time_increment) query.time_increment = config.time_increment;
|
|
160
|
+
const breakdowns = splitList(config.breakdowns);
|
|
161
|
+
if (breakdowns.length) query.breakdowns = breakdowns;
|
|
162
|
+
const actionBreakdowns = splitList(config.action_breakdowns);
|
|
163
|
+
if (actionBreakdowns.length) query.action_breakdowns = actionBreakdowns;
|
|
164
|
+
if (config.use_account_attribution_setting)
|
|
165
|
+
query.use_account_attribution_setting = true;
|
|
166
|
+
} else {
|
|
167
|
+
const statuses = splitList(config.effective_status);
|
|
168
|
+
// Meta expects this one as a JSON array, not a comma separated list
|
|
169
|
+
if (statuses.length) query.effective_status = JSON.stringify(statuses);
|
|
170
|
+
}
|
|
171
|
+
if (config.filtering) query.filtering = parseJSONish(config.filtering);
|
|
172
|
+
if (config.extra_params)
|
|
173
|
+
Object.assign(query, parseJSONish(config.extra_params) || {});
|
|
174
|
+
return query;
|
|
175
|
+
};
|
|
176
|
+
|
|
177
|
+
/**
|
|
178
|
+
* Read rows of one Meta object type. `where` is used only to pick a narrower
|
|
179
|
+
* endpoint (the ads of one campaign rather than of the whole account).
|
|
180
|
+
*/
|
|
181
|
+
const pushdownParents = (typeName, where) => {
|
|
182
|
+
const parents = {};
|
|
183
|
+
(objectTypes[typeName]?.pushdown || []).forEach((k) => {
|
|
184
|
+
const v = where?.[k];
|
|
185
|
+
if (typeof v === "string" || typeof v === "number") parents[k] = v;
|
|
186
|
+
});
|
|
187
|
+
return parents;
|
|
188
|
+
};
|
|
189
|
+
|
|
190
|
+
const fetchObjects = async ({ cfg, config = {}, where, opts }) => {
|
|
191
|
+
const typeName = config.object_type || "Campaigns";
|
|
192
|
+
const objectType = objectTypes[typeName];
|
|
193
|
+
if (!objectType)
|
|
194
|
+
throw new Error(`Meta Marketing API: unknown object type ${typeName}`);
|
|
195
|
+
const account_id = config.ad_account_id || cfg?.ad_account_id;
|
|
196
|
+
if (objectType.needs_account && !account_id && !config.object_id)
|
|
197
|
+
throw new Error(
|
|
198
|
+
"Meta Marketing API: no ad account set, in the table or in the plugin configuration"
|
|
199
|
+
);
|
|
200
|
+
const parents = pushdownParents(typeName, where);
|
|
201
|
+
// a page limit set on the table or action overrides the plugin-wide one
|
|
202
|
+
const useCfg = config.max_pages
|
|
203
|
+
? { ...cfg, max_pages: config.max_pages }
|
|
204
|
+
: cfg;
|
|
205
|
+
const rows = await objectType.fetch({
|
|
206
|
+
cfg: useCfg,
|
|
207
|
+
account_id,
|
|
208
|
+
query: buildQuery(typeName, config),
|
|
209
|
+
config,
|
|
210
|
+
parents,
|
|
211
|
+
opts: { max_pages: config.max_pages, ...(opts || {}) },
|
|
212
|
+
});
|
|
213
|
+
return (rows || []).map((row) => prepareRow(row, typeName, config));
|
|
214
|
+
};
|
|
215
|
+
|
|
216
|
+
/**
|
|
217
|
+
* Graph API rows nest sub-objects (creative, targeting). Flatten the shallow
|
|
218
|
+
* ones into creative_id, creative_name and so on; leave arrays and deeper
|
|
219
|
+
* objects as JSON.
|
|
220
|
+
*/
|
|
221
|
+
const flattenRow = (row, prefix = "", out = {}, depth = 0) => {
|
|
222
|
+
Object.entries(row || {}).forEach(([k, v]) => {
|
|
223
|
+
const key = prefix ? `${prefix}_${k}` : k;
|
|
224
|
+
if (v && typeof v === "object" && !Array.isArray(v) && depth < 2)
|
|
225
|
+
flattenRow(v, key, out, depth + 1);
|
|
226
|
+
else out[key] = v;
|
|
227
|
+
});
|
|
228
|
+
return out;
|
|
229
|
+
};
|
|
230
|
+
|
|
231
|
+
// Insights rows have no id of their own, so one is made from the dimensions
|
|
232
|
+
const INSIGHTS_ID_FIELDS = [
|
|
233
|
+
"date_start",
|
|
234
|
+
"date_stop",
|
|
235
|
+
"account_id",
|
|
236
|
+
"campaign_id",
|
|
237
|
+
"adset_id",
|
|
238
|
+
"ad_id",
|
|
239
|
+
];
|
|
240
|
+
|
|
241
|
+
const insightsRowId = (row, breakdowns = []) => {
|
|
242
|
+
const key = [...INSIGHTS_ID_FIELDS, ...breakdowns]
|
|
243
|
+
.map((f) => (typeof row[f] === "undefined" ? "" : row[f]))
|
|
244
|
+
.join("|");
|
|
245
|
+
return crypto.createHash("sha1").update(key).digest("hex").slice(0, 20);
|
|
246
|
+
};
|
|
247
|
+
|
|
248
|
+
const prepareRow = (row, typeName, config = {}) => {
|
|
249
|
+
const flat = flattenRow(row);
|
|
250
|
+
if (objectTypes[typeName]?.synthetic_id && !flat.id)
|
|
251
|
+
flat.id = insightsRowId(flat, splitList(config.breakdowns));
|
|
252
|
+
return flat;
|
|
253
|
+
};
|
|
254
|
+
|
|
255
|
+
const INTEGER_FIELDS = new Set([
|
|
256
|
+
"impressions",
|
|
257
|
+
"reach",
|
|
258
|
+
"clicks",
|
|
259
|
+
"unique_clicks",
|
|
260
|
+
"inline_link_clicks",
|
|
261
|
+
"account_status",
|
|
262
|
+
"timezone_id",
|
|
263
|
+
]);
|
|
264
|
+
|
|
265
|
+
const ISO_DATE_RE = /^\d{4}-\d{2}-\d{2}([T ].*)?$/;
|
|
266
|
+
|
|
267
|
+
// Budgets and spend come back as strings in the account's minor currency unit
|
|
268
|
+
const MONEY_FIELD_RE =
|
|
269
|
+
/(^|_)(daily_budget|lifetime_budget|budget_remaining|bid_amount|amount_spent|balance|spend_cap|min_daily_budget)$/;
|
|
270
|
+
|
|
271
|
+
const guessType = (name, value) => {
|
|
272
|
+
if (INTEGER_FIELDS.has(name) || MONEY_FIELD_RE.test(name)) return "Integer";
|
|
273
|
+
if (NUMERIC_INSIGHTS_FIELDS.has(name)) return "Float";
|
|
274
|
+
if (value === null || typeof value === "undefined") return "String";
|
|
275
|
+
if (Array.isArray(value) || typeof value === "object") return "JSON";
|
|
276
|
+
if (typeof value === "boolean") return "Bool";
|
|
277
|
+
if (typeof value === "number")
|
|
278
|
+
return Number.isInteger(value) ? "Integer" : "Float";
|
|
279
|
+
if (ISO_DATE_RE.test(value)) return "Date";
|
|
280
|
+
return "String";
|
|
281
|
+
};
|
|
282
|
+
|
|
283
|
+
const coerceValue = (value, type) => {
|
|
284
|
+
if (value === null || typeof value === "undefined") return null;
|
|
285
|
+
switch (type) {
|
|
286
|
+
case "Integer": {
|
|
287
|
+
const n = parseInt(value, 10);
|
|
288
|
+
return Number.isNaN(n) ? null : n;
|
|
289
|
+
}
|
|
290
|
+
case "Float": {
|
|
291
|
+
const n = parseFloat(value);
|
|
292
|
+
return Number.isNaN(n) ? null : n;
|
|
293
|
+
}
|
|
294
|
+
case "Bool":
|
|
295
|
+
return typeof value === "boolean"
|
|
296
|
+
? value
|
|
297
|
+
: ["true", "1", "yes"].includes(`${value}`.toLowerCase());
|
|
298
|
+
case "Date": {
|
|
299
|
+
const d = value instanceof Date ? value : new Date(value);
|
|
300
|
+
return isNaN(d.getTime()) ? null : d;
|
|
301
|
+
}
|
|
302
|
+
case "JSON":
|
|
303
|
+
return value;
|
|
304
|
+
case "String":
|
|
305
|
+
return typeof value === "object" ? JSON.stringify(value) : `${value}`;
|
|
306
|
+
default:
|
|
307
|
+
return value;
|
|
308
|
+
}
|
|
309
|
+
};
|
|
310
|
+
|
|
311
|
+
/** Apply the configured column types to a fetched row */
|
|
312
|
+
const coerceRow = (row, columns) => {
|
|
313
|
+
const out = {};
|
|
314
|
+
(columns || []).forEach((col) => {
|
|
315
|
+
out[col.name] = coerceValue(row[col.name], col.type);
|
|
316
|
+
});
|
|
317
|
+
return out;
|
|
318
|
+
};
|
|
319
|
+
|
|
320
|
+
//
|
|
321
|
+
// In memory query support. The Graph API cannot filter or sort the way
|
|
322
|
+
// Saltcorn views expect, so the where clause is applied to the fetched rows.
|
|
323
|
+
//
|
|
324
|
+
|
|
325
|
+
const SPECIAL_WHERE_KEYS = new Set([
|
|
326
|
+
"limit",
|
|
327
|
+
"offset",
|
|
328
|
+
"orderBy",
|
|
329
|
+
"orderDesc",
|
|
330
|
+
"forUser",
|
|
331
|
+
"forPublic",
|
|
332
|
+
]);
|
|
333
|
+
|
|
334
|
+
const asComparable = (v) =>
|
|
335
|
+
v instanceof Date ? v.getTime() : typeof v === "string" ? v : v;
|
|
336
|
+
|
|
337
|
+
const valueMatches = (value, cond) => {
|
|
338
|
+
if (cond === null) return value === null || typeof value === "undefined";
|
|
339
|
+
if (Array.isArray(cond)) return cond.every((c) => valueMatches(value, c));
|
|
340
|
+
if (cond instanceof Date)
|
|
341
|
+
return (
|
|
342
|
+
value instanceof Date && value.getTime() === cond.getTime()
|
|
343
|
+
);
|
|
344
|
+
if (cond && typeof cond === "object") {
|
|
345
|
+
if ("in" in cond)
|
|
346
|
+
return (cond.in || []).map((x) => `${x}`).includes(`${value}`);
|
|
347
|
+
if ("ilike" in cond)
|
|
348
|
+
return `${value === null || typeof value === "undefined" ? "" : value}`
|
|
349
|
+
.toLowerCase()
|
|
350
|
+
.includes(`${cond.ilike}`.toLowerCase());
|
|
351
|
+
if ("gt" in cond) {
|
|
352
|
+
const a = asComparable(value);
|
|
353
|
+
const b = asComparable(cond.gt);
|
|
354
|
+
return cond.equal ? a >= b : a > b;
|
|
355
|
+
}
|
|
356
|
+
if ("lt" in cond) {
|
|
357
|
+
const a = asComparable(value);
|
|
358
|
+
const b = asComparable(cond.lt);
|
|
359
|
+
return cond.equal ? a <= b : a < b;
|
|
360
|
+
}
|
|
361
|
+
if ("not" in cond) return !valueMatches(value, cond.not);
|
|
362
|
+
// an operator we do not implement: do not silently hide rows
|
|
363
|
+
return true;
|
|
364
|
+
}
|
|
365
|
+
if (value instanceof Date) return value.getTime() === new Date(cond).getTime();
|
|
366
|
+
return `${value}` === `${cond}`;
|
|
367
|
+
};
|
|
368
|
+
|
|
369
|
+
const ftsMatches = (row, fts) => {
|
|
370
|
+
const term = `${fts?.searchTerm || ""}`.toLowerCase();
|
|
371
|
+
if (!term) return true;
|
|
372
|
+
const fields = (fts.fields || []).map((f) => (f.name ? f.name : f));
|
|
373
|
+
const inFields = fields.length ? fields : Object.keys(row);
|
|
374
|
+
return inFields.some((f) => {
|
|
375
|
+
const v = row[f];
|
|
376
|
+
if (v === null || typeof v === "undefined") return false;
|
|
377
|
+
const s = typeof v === "object" ? JSON.stringify(v) : `${v}`;
|
|
378
|
+
return s.toLowerCase().includes(term);
|
|
379
|
+
});
|
|
380
|
+
};
|
|
381
|
+
|
|
382
|
+
const rowMatches = (row, where) => {
|
|
383
|
+
for (const [k, cond] of Object.entries(where || {})) {
|
|
384
|
+
if (SPECIAL_WHERE_KEYS.has(k)) continue;
|
|
385
|
+
if (k === "_fts") {
|
|
386
|
+
if (!ftsMatches(row, cond)) return false;
|
|
387
|
+
continue;
|
|
388
|
+
}
|
|
389
|
+
if (k === "or") {
|
|
390
|
+
if (!(cond || []).some((w) => rowMatches(row, w))) return false;
|
|
391
|
+
continue;
|
|
392
|
+
}
|
|
393
|
+
if (k === "not") {
|
|
394
|
+
if (rowMatches(row, cond)) return false;
|
|
395
|
+
continue;
|
|
396
|
+
}
|
|
397
|
+
if (!valueMatches(row[k], cond)) return false;
|
|
398
|
+
}
|
|
399
|
+
return true;
|
|
400
|
+
};
|
|
401
|
+
|
|
402
|
+
const compareValues = (a, b) => {
|
|
403
|
+
const x = asComparable(a);
|
|
404
|
+
const y = asComparable(b);
|
|
405
|
+
if (x === y) return 0;
|
|
406
|
+
if (x === null || typeof x === "undefined") return -1;
|
|
407
|
+
if (y === null || typeof y === "undefined") return 1;
|
|
408
|
+
return x < y ? -1 : 1;
|
|
409
|
+
};
|
|
410
|
+
|
|
411
|
+
/** filter, sort and paginate fetched rows the way the view asked for */
|
|
412
|
+
const applyWhere = (rows, where = {}, opts = {}) => {
|
|
413
|
+
let result = rows.filter((r) => rowMatches(r, where));
|
|
414
|
+
const orderBy = where.orderBy || opts.orderBy;
|
|
415
|
+
const orderDesc = where.orderDesc || opts.orderDesc;
|
|
416
|
+
if (orderBy && typeof orderBy === "string") {
|
|
417
|
+
result = [...result].sort((a, b) => compareValues(a[orderBy], b[orderBy]));
|
|
418
|
+
if (orderDesc) result.reverse();
|
|
419
|
+
}
|
|
420
|
+
const offset = where.offset || opts.offset;
|
|
421
|
+
const limit = where.limit || opts.limit;
|
|
422
|
+
if (offset) result = result.slice(offset);
|
|
423
|
+
if (limit) result = result.slice(0, limit);
|
|
424
|
+
return result;
|
|
425
|
+
};
|
|
426
|
+
|
|
427
|
+
module.exports = {
|
|
428
|
+
objectTypes,
|
|
429
|
+
objectTypeNames,
|
|
430
|
+
INSIGHTS_LEVELS,
|
|
431
|
+
DATE_PRESETS,
|
|
432
|
+
EFFECTIVE_STATUSES,
|
|
433
|
+
INSIGHTS_ID_FIELDS,
|
|
434
|
+
splitList,
|
|
435
|
+
parseJSONish,
|
|
436
|
+
fieldsFor,
|
|
437
|
+
buildQuery,
|
|
438
|
+
pushdownParents,
|
|
439
|
+
fetchObjects,
|
|
440
|
+
flattenRow,
|
|
441
|
+
insightsRowId,
|
|
442
|
+
prepareRow,
|
|
443
|
+
guessType,
|
|
444
|
+
coerceValue,
|
|
445
|
+
coerceRow,
|
|
446
|
+
valueMatches,
|
|
447
|
+
rowMatches,
|
|
448
|
+
applyWhere,
|
|
449
|
+
};
|