Преглед изворни кода

feat: sorting, top-N and dedupe in one node

Adds an outputField collision guard (count/removedDuplicates) matching
the pattern already used in aggregate.js, plus a dedicated error fixture
proving it throws.
fszontagh пре 1 месец
родитељ
комит
5dd9c88666

+ 163 - 0
nodes/core/sort-limit-dedupe.js

@@ -0,0 +1,163 @@
+/**
+ * @node sort-limit-dedupe
+ * @name Sort / Limit / Dedupe
+ * @category data
+ * @version 1.0.0
+ * @description Order a list, take the first N, and drop repeats by key
+ * @icon arrow-down-up
+ */
+
+const configSchema = {
+    type: 'object',
+    properties: {
+        inputField: {
+            type: 'string',
+            title: 'Array Field',
+            description: 'Path to the array to process',
+            default: 'data'
+        },
+        outputField: {
+            type: 'string',
+            title: 'Output Field',
+            default: 'items'
+        },
+        dedupeBy: {
+            type: 'string',
+            title: 'Dedupe By',
+            description: 'Field whose value must be unique. Leave empty to keep every item. Applied before sorting'
+        },
+        sortBy: {
+            type: 'array',
+            title: 'Sort By',
+            description: 'Applied in order, so the first entry is the primary sort',
+            items: {
+                type: 'object',
+                properties: {
+                    field: {
+                        type: 'string',
+                        title: 'Field'
+                    },
+                    direction: {
+                        type: 'string',
+                        title: 'Direction',
+                        enum: ['asc', 'desc'],
+                        default: 'asc'
+                    },
+                    type: {
+                        type: 'string',
+                        title: 'Compare As',
+                        enum: ['auto', 'string', 'number'],
+                        default: 'auto'
+                    }
+                }
+            }
+        },
+        limit: {
+            type: 'number',
+            title: 'Limit',
+            description: 'Keep at most this many items. 0 keeps them all',
+            default: 0
+        }
+    }
+};
+
+const inputSchema = {
+    type: 'object',
+    properties: {
+        data: { type: 'any' }
+    }
+};
+
+const outputSchema = {
+    type: 'object',
+    properties: {
+        count: { type: 'number', description: 'Items in the result' },
+        removedDuplicates: { type: 'number' }
+    }
+};
+
+function compareValues(left, right, compareAs) {
+    if (compareAs === 'number' || (compareAs === 'auto' && typeof left === 'number' && typeof right === 'number')) {
+        const a = Number(left);
+        const b = Number(right);
+        if (isNaN(a) && isNaN(b)) return 0;
+        if (isNaN(a)) return 1;
+        if (isNaN(b)) return -1;
+        return a < b ? -1 : (a > b ? 1 : 0);
+    }
+    const a = left === undefined || left === null ? '' : String(left);
+    const b = right === undefined || right === null ? '' : String(right);
+    return a < b ? -1 : (a > b ? 1 : 0);
+}
+
+async function execute(config, input, context) {
+    const inputField = config.inputField;
+    const outputField = config.outputField || 'items';
+    const dedupeBy = config.dedupeBy;
+    const sortBy = Array.isArray(config.sortBy) ? config.sortBy : [];
+    const limit = Number(config.limit) || 0;
+
+    if (outputField === 'count' || outputField === 'removedDuplicates') {
+        throw new Error('Sort / Limit / Dedupe: outputField cannot be "' + outputField +
+            '", which is a reserved output name for this node. Pick another name.');
+    }
+
+    let items;
+    if (Array.isArray(inputField)) {
+        items = inputField;
+    } else {
+        items = smartbotic.utils.getFieldValue(input, inputField || 'data');
+    }
+
+    if (!Array.isArray(items)) {
+        throw new Error('Sort / Limit / Dedupe: the value at "' + inputField + '" is not an array');
+    }
+
+    let working = items.slice();
+    let removedDuplicates = 0;
+
+    if (dedupeBy) {
+        const seen = {};
+        const unique = [];
+        for (const item of working) {
+            const key = String(smartbotic.utils.getFieldValue(item, dedupeBy));
+            if (seen[key] === true) {
+                removedDuplicates++;
+                continue;
+            }
+            seen[key] = true;
+            unique.push(item);
+        }
+        working = unique;
+    }
+
+    if (sortBy.length > 0) {
+        working.sort(function (left, right) {
+            for (const rule of sortBy) {
+                if (!rule || !rule.field) continue;
+                const order = compareValues(
+                    smartbotic.utils.getFieldValue(left, rule.field),
+                    smartbotic.utils.getFieldValue(right, rule.field),
+                    rule.type || 'auto'
+                );
+                if (order !== 0) {
+                    return rule.direction === 'desc' ? -order : order;
+                }
+            }
+            return 0;
+        });
+    }
+
+    if (limit > 0 && working.length > limit) {
+        working = working.slice(0, limit);
+    }
+
+    smartbotic.log.info('Sort / Limit / Dedupe: ' + items.length + ' in, ' + working.length + ' out, ' +
+        removedDuplicates + ' duplicates dropped');
+
+    const result = { count: working.length, removedDuplicates: removedDuplicates };
+    result[outputField] = working;
+    return result;
+}
+
+module.exports = { configSchema, inputSchema, outputSchema, execute };

+ 17 - 0
tests/nodes/sort-limit-dedupe-errors.json

@@ -0,0 +1,17 @@
+{
+  "name": "verify-sort-limit-dedupe-errors",
+  "nodes": [
+    {"id": "n1", "name": "Trigger", "type": "click-trigger", "position": {"x": 0, "y": 0}, "config": {}},
+    {"id": "n2", "name": "Fixture", "type": "code", "position": {"x": 0, "y": 100},
+     "config": {"code": "return { rows: [{id: 'a', score: 3}, {id: 'b', score: 9}] };"}},
+    {"id": "n3", "name": "ReservedName", "type": "sort-limit-dedupe", "position": {"x": 0, "y": 200},
+     "config": {"inputField": "data.result.rows", "outputField": "count"}}
+  ],
+  "connections": [
+    {"sourceNodeId": "n1", "sourceOutput": "main", "targetNodeId": "n2", "targetInput": "data"},
+    {"sourceNodeId": "n2", "sourceOutput": "main", "targetNodeId": "n3", "targetInput": "data"}
+  ],
+  "expect": {
+    "n3": {"status": "failed"}
+  }
+}

+ 27 - 0
tests/nodes/sort-limit-dedupe.json

@@ -0,0 +1,27 @@
+{
+  "name": "verify-sort-limit-dedupe",
+  "nodes": [
+    {"id": "n1", "name": "Trigger", "type": "click-trigger", "position": {"x": 0, "y": 0}, "config": {}},
+    {"id": "n2", "name": "Fixture", "type": "code", "position": {"x": 0, "y": 100},
+     "config": {"code": "return { rows: [{id: 'a', score: 3}, {id: 'b', score: 9}, {id: 'a', score: 1}, {id: 'c', score: 5}] };"}},
+    {"id": "n3", "name": "Shape", "type": "sort-limit-dedupe", "position": {"x": 0, "y": 200},
+     "config": {
+       "inputField": "data.result.rows",
+       "outputField": "items",
+       "dedupeBy": "id",
+       "sortBy": [{"field": "score", "direction": "desc", "type": "number"}],
+       "limit": 2
+     }}
+  ],
+  "connections": [
+    {"sourceNodeId": "n1", "sourceOutput": "main", "targetNodeId": "n2", "targetInput": "data"},
+    {"sourceNodeId": "n2", "sourceOutput": "main", "targetNodeId": "n3", "targetInput": "data"}
+  ],
+  "expect": {
+    "n3": {"status": "completed", "output": {
+      "count": 2,
+      "removedDuplicates": 1,
+      "items": [{"id": "b", "score": 9}, {"id": "c", "score": 5}]
+    }}
+  }
+}