Compare commits

..
2 Commits
Author SHA1 Message Date
Sylphian 0fe7d247f8 feat: improved special characters usage on randomized strings
Build, Test & Deploy LotusGen / Build & Deploy Container (push) Successful in 20s
Build, Test & Deploy LotusGen / Run Pytest & Frontend Build Test (push) Successful in 29s
2026-09-23 22:28:06 +07:00
Sylphian 1ec7ca8f95 feat: declutter variables 2026-09-23 22:13:36 +07:00
3 changed files with 272 additions and 98 deletions
+16 -2
View File
@@ -77,11 +77,21 @@ def sample_weighted_without_replacement(
return [item for _, item in keys[:chosen_size]] return [item for _, item in keys[:chosen_size]]
HEX_UPPERCASE = "0123456789ABCDEF"
def sample_regex_mask(mask: str, rng: random.Random) -> str: def sample_regex_mask(mask: str, rng: random.Random) -> str:
# '#' -> digit, '?' -> uppercase, '@' -> lowercase, '*' -> alphanumeric # '#' -> digit, '?' -> uppercase, '@' -> lowercase, '*' -> alphanumeric, '%' -> hex uppercase
# '\' -> escape next character literally
out = [] out = []
escape = False
for char in mask: for char in mask:
if char == "#": if escape:
out.append(char)
escape = False
elif char == "\\":
escape = True
elif char == "#":
out.append(rng.choice(string.digits)) out.append(rng.choice(string.digits))
elif char == "?": elif char == "?":
out.append(rng.choice(string.ascii_uppercase)) out.append(rng.choice(string.ascii_uppercase))
@@ -89,8 +99,12 @@ def sample_regex_mask(mask: str, rng: random.Random) -> str:
out.append(rng.choice(string.ascii_lowercase)) out.append(rng.choice(string.ascii_lowercase))
elif char == "*": elif char == "*":
out.append(rng.choice(string.ascii_letters + string.digits)) out.append(rng.choice(string.ascii_letters + string.digits))
elif char == "%":
out.append(rng.choice(HEX_UPPERCASE))
else: else:
out.append(char) out.append(char)
if escape:
out.append("\\")
return "".join(out) return "".join(out)
+60 -1
View File
@@ -19,7 +19,7 @@ from backend.engine.sandbox import (
FormulaSyntaxError, FormulaSyntaxError,
extract_variable_names, extract_variable_names,
) )
from backend.engine.samplers import generate_uuid7, get_random_sublist from backend.engine.samplers import generate_uuid7, get_random_sublist, sample_regex_mask
from backend.engine.writers import StreamingCsvWriter, StreamingParquetWriter from backend.engine.writers import StreamingCsvWriter, StreamingParquetWriter
@@ -657,3 +657,62 @@ def test_extended_builtin_metadata_variables():
df_agg = pl.concat(chunks_agg) df_agg = pl.concat(chunks_agg)
assert len(df_agg) == 6 assert len(df_agg) == 6
assert df_agg["meta_calc"].to_list() == ["1/6", "2/6", "3/6", "4/6", "5/6", "6/6"] assert df_agg["meta_calc"].to_list() == ["1/6", "2/6", "3/6", "4/6", "5/6", "6/6"]
def test_sample_regex_mask_hex_and_escaping():
import random
rng = random.Random(42)
# 1. Hexadecimal token '%' (0-9, A-F)
hex_sample = sample_regex_mask("0x%%%%", rng)
assert hex_sample.startswith("0x")
assert len(hex_sample) == 6
for c in hex_sample[2:]:
assert c in "0123456789ABCDEF"
# 2. Escaped special tokens '\#', '\?', '\@', '\*', '\%', '\H'
escaped_sample = sample_regex_mask(r"\#\?\@\*\%\Hello", rng)
assert escaped_sample == "#?@*%Hello"
# 3. Escaped backslash '\\'
path_sample = sample_regex_mask(r"C:\\DIR\\\#-####", rng)
assert path_sample.startswith(r"C:\DIR\#-")
assert len(path_sample) == 13
assert path_sample[-4:].isdigit()
# 4. Trailing backslash
trailing_sample = sample_regex_mask("TEST-\\\\", rng)
assert trailing_sample == "TEST-\\"
single_trailing = sample_regex_mask("TEST-\\", rng)
assert single_trailing == "TEST-\\"
# 5. Table generator end-to-end integration test with masked string
table = {
"name": "hex_table",
"archetype": "record",
"status": "valid",
"fields": [
{
"name": "mac_addr",
"role": "randomized",
"type": "string",
"config": {"mask": r"MAC-\#%%-\#%%-\#%%"},
"active": True,
}
],
}
gen = TableGenerator(table, [], seed=77, chunk_size=5)
df = next(gen.generate_record_chunks(5))
macs = df["mac_addr"].to_list()
assert len(macs) == 5
for m in macs:
assert m.startswith("MAC-#")
parts = m.split("-")
assert len(parts) == 4
assert parts[0] == "MAC"
for p in parts[1:]:
assert p.startswith("#")
assert len(p) == 3
assert all(c in "0123456789ABCDEF" for c in p[1:])
+196 -95
View File
@@ -1,4 +1,4 @@
import React, { useState } from 'react'; import React, { useEffect, useRef, useState } from 'react';
import { import {
ArrowLeft, ArrowLeft,
ArrowUp, ArrowUp,
@@ -14,6 +14,8 @@ import {
Clipboard, Clipboard,
Check, Check,
X, X,
BracketsCurly,
MagnifyingGlass,
} from '@phosphor-icons/react'; } from '@phosphor-icons/react';
import { updateTable, validateTable, previewTable } from '../api'; import { updateTable, validateTable, previewTable } from '../api';
import type { ColumnRole, ColumnType, Field, Table } from '../types'; import type { ColumnRole, ColumnType, Field, Table } from '../types';
@@ -27,6 +29,16 @@ interface TableSchemaEditorProps {
onGenerate: (format: 'csv' | 'parquet') => void; onGenerate: (format: 'csv' | 'parquet') => void;
} }
const BUILTIN_METADATA_VARIABLES = [
{ name: '_row', desc: '1-based row counter' },
{ name: '_index', desc: '0-based row counter' },
{ name: '_count', desc: 'Total target rows' },
{ name: '_name', desc: 'Current table name' },
{ name: '_project', desc: 'Project name' },
{ name: '_timestamp', desc: 'UTC ISO timestamp' },
{ name: '_seed', desc: 'Project RNG seed' },
];
export const TableSchemaEditor: React.FC<TableSchemaEditorProps> = ({ export const TableSchemaEditor: React.FC<TableSchemaEditorProps> = ({
table, table,
allProjectTables, allProjectTables,
@@ -59,6 +71,38 @@ export const TableSchemaEditor: React.FC<TableSchemaEditorProps> = ({
const [copied, setCopied] = useState(false); const [copied, setCopied] = useState(false);
const [showConfirmGuard, setShowConfirmGuard] = useState(false); const [showConfirmGuard, setShowConfirmGuard] = useState(false);
// Variable Picker Popover state
const [activeVarPickerIdx, setActiveVarPickerIdx] = useState<number | null>(null);
const [varSearchQuery, setVarSearchQuery] = useState('');
const varPickerRef = useRef<HTMLDivElement>(null);
useEffect(() => {
if (activeVarPickerIdx === null) return;
const handleClickOutside = (e: MouseEvent) => {
if (varPickerRef.current && !varPickerRef.current.contains(e.target as Node)) {
setActiveVarPickerIdx(null);
}
};
const handleKeyDown = (e: KeyboardEvent) => {
if (e.key === 'Escape') {
setActiveVarPickerIdx(null);
}
};
document.addEventListener('mousedown', handleClickOutside);
document.addEventListener('keydown', handleKeyDown);
return () => {
document.removeEventListener('mousedown', handleClickOutside);
document.removeEventListener('keydown', handleKeyDown);
};
}, [activeVarPickerIdx]);
const handleInsertVariable = (fieldIdx: number, varName: string) => {
const currentExpr = fields[fieldIdx]?.config?.expression || '';
handleConfigChange(fieldIdx, 'expression', currentExpr + varName);
setActiveVarPickerIdx(null);
setVarSearchQuery('');
};
const isLocked = table.status === 'valid' && table.referencing_tables.length > 0; const isLocked = table.status === 'valid' && table.referencing_tables.length > 0;
const isAggregate = table.archetype === 'aggregate'; const isAggregate = table.archetype === 'aggregate';
@@ -690,102 +734,159 @@ export const TableSchemaEditor: React.FC<TableSchemaEditorProps> = ({
<span className="font-medium text-[#000000] dark:text-[#FFFFFF]"> <span className="font-medium text-[#000000] dark:text-[#FFFFFF]">
Python Expression (Safe Sandbox with f-strings, math, random): Python Expression (Safe Sandbox with f-strings, math, random):
</span> </span>
<div className="flex flex-wrap items-center gap-1.5 text-[11px] font-mono text-[#4F6170] dark:text-[#ADBFCE]">
<span>Built-ins:</span> {/* Unified Variable Picker Button & Popover */}
<div className="relative">
<button <button
type="button" type="button"
onClick={() => { disabled={isLocked || !isActive}
const currentExpr = field.config?.expression || ''; onClick={(e) => {
handleConfigChange(idx, 'expression', currentExpr + '_row'); e.stopPropagation();
if (activeVarPickerIdx === idx) {
setActiveVarPickerIdx(null);
} else {
setActiveVarPickerIdx(idx);
setVarSearchQuery('');
}
}} }}
className="px-2 py-0.5 rounded bg-[#EDF3EC] dark:bg-[#1b2b1e] border-[1.5px] border-[#DEE3E8] dark:border-[#4F6170] hover:border-[#000000] dark:hover:border-[#FFFFFF] text-[#346538] dark:text-[#88d48e] transition-colors" className={`flex items-center space-x-1.5 text-xs font-mono font-medium px-2.5 py-1 rounded-[5px] border-[1.5px] transition-colors disabled:opacity-40 cursor-pointer ${
title="1-based row counter (1, 2, 3...)" activeVarPickerIdx === idx
? 'border-[#000000] dark:border-[#FFFFFF] bg-[#EDF3EC] dark:bg-[#1b2b1e] text-[#346538] dark:text-[#88d48e]'
: 'border-[#DEE3E8] dark:border-[#4F6170] bg-[#FFFFFF] dark:bg-[#0E1317] hover:border-[#000000] dark:hover:border-[#FFFFFF] text-[#000000] dark:text-[#FFFFFF]'
}`}
title="Insert built-in variable or upstream column into expression"
> >
_row <BracketsCurly size={13} weight="bold" />
<span>{'{ }'} Insert Variable</span>
</button> </button>
<button
type="button" {activeVarPickerIdx === idx && (
onClick={() => { <div
const currentExpr = field.config?.expression || ''; ref={varPickerRef}
handleConfigChange(idx, 'expression', currentExpr + '_index'); onClick={(e) => e.stopPropagation()}
}} className="absolute right-0 top-full mt-1.5 w-80 max-h-[380px] overflow-y-auto z-40 bg-[#FFFFFF] dark:bg-[#0E1317] border-[1.5px] border-[#DEE3E8] dark:border-[#4F6170] rounded-[8px] shadow-xl p-3 space-y-3"
className="px-2 py-0.5 rounded bg-[#EDF3EC] dark:bg-[#1b2b1e] border-[1.5px] border-[#DEE3E8] dark:border-[#4F6170] hover:border-[#000000] dark:hover:border-[#FFFFFF] text-[#346538] dark:text-[#88d48e] transition-colors" >
title="0-based row counter (0, 1, 2...)" {/* Search Input */}
> <div className="relative">
_index <MagnifyingGlass
</button> size={13}
<button className="absolute left-2.5 top-1/2 -translate-y-1/2 text-[#4F6170] dark:text-[#ADBFCE]"
type="button" />
onClick={() => { <input
const currentExpr = field.config?.expression || ''; type="text"
handleConfigChange(idx, 'expression', currentExpr + '_count'); autoFocus
}} value={varSearchQuery}
className="px-2 py-0.5 rounded bg-[#EDF3EC] dark:bg-[#1b2b1e] border-[1.5px] border-[#DEE3E8] dark:border-[#4F6170] hover:border-[#000000] dark:hover:border-[#FFFFFF] text-[#346538] dark:text-[#88d48e] transition-colors" onChange={(e) => setVarSearchQuery(e.target.value)}
title="Total rows target count (int)" placeholder="Filter variables..."
> className="w-full pl-8 pr-2.5 py-1.5 text-xs font-mono border-[1.5px] border-[#DEE3E8] dark:border-[#4F6170] rounded-[5px] bg-[#F4FAFF] dark:bg-[#161C22] text-[#000000] dark:text-[#FFFFFF] focus:outline-none focus:border-[#000000] dark:focus:border-[#FFFFFF]"
_count />
</button> </div>
<button
type="button" {/* Fundamental / Built-in Variables Section */}
onClick={() => { <div className="space-y-1.5">
const currentExpr = field.config?.expression || ''; <div className="text-[10px] font-mono uppercase tracking-wider font-semibold text-[#4F6170] dark:text-[#ADBFCE] flex items-center justify-between">
handleConfigChange(idx, 'expression', currentExpr + '_name'); <span>Fundamental Variables</span>
}} <span>
className="px-2 py-0.5 rounded bg-[#EDF3EC] dark:bg-[#1b2b1e] border-[1.5px] border-[#DEE3E8] dark:border-[#4F6170] hover:border-[#000000] dark:hover:border-[#FFFFFF] text-[#346538] dark:text-[#88d48e] transition-colors" {
title="Current table name (str)" BUILTIN_METADATA_VARIABLES.filter(
> (v) =>
_name v.name
</button> .toLowerCase()
<button .includes(varSearchQuery.trim().toLowerCase()) ||
type="button" v.desc
onClick={() => { .toLowerCase()
const currentExpr = field.config?.expression || ''; .includes(varSearchQuery.trim().toLowerCase())
handleConfigChange(idx, 'expression', currentExpr + '_project'); ).length
}} }
className="px-2 py-0.5 rounded bg-[#EDF3EC] dark:bg-[#1b2b1e] border-[1.5px] border-[#DEE3E8] dark:border-[#4F6170] hover:border-[#000000] dark:hover:border-[#FFFFFF] text-[#346538] dark:text-[#88d48e] transition-colors" </span>
title="Project name (str)" </div>
> {BUILTIN_METADATA_VARIABLES.filter(
_project (v) =>
</button> v.name
<button .toLowerCase()
type="button" .includes(varSearchQuery.trim().toLowerCase()) ||
onClick={() => { v.desc
const currentExpr = field.config?.expression || ''; .toLowerCase()
handleConfigChange(idx, 'expression', currentExpr + '_timestamp'); .includes(varSearchQuery.trim().toLowerCase())
}} ).length === 0 ? (
className="px-2 py-0.5 rounded bg-[#EDF3EC] dark:bg-[#1b2b1e] border-[1.5px] border-[#DEE3E8] dark:border-[#4F6170] hover:border-[#000000] dark:hover:border-[#FFFFFF] text-[#346538] dark:text-[#88d48e] transition-colors" <div className="text-[11px] font-mono text-[#4F6170] dark:text-[#ADBFCE] italic px-1">
title="UTC generation timestamp in ISO 8601 (str)" No matching variables
> </div>
_timestamp ) : (
</button> <div className="grid grid-cols-1 gap-1">
<button {BUILTIN_METADATA_VARIABLES.filter(
type="button" (v) =>
onClick={() => { v.name
const currentExpr = field.config?.expression || ''; .toLowerCase()
handleConfigChange(idx, 'expression', currentExpr + '_seed'); .includes(varSearchQuery.trim().toLowerCase()) ||
}} v.desc
className="px-2 py-0.5 rounded bg-[#EDF3EC] dark:bg-[#1b2b1e] border-[1.5px] border-[#DEE3E8] dark:border-[#4F6170] hover:border-[#000000] dark:hover:border-[#FFFFFF] text-[#346538] dark:text-[#88d48e] transition-colors" .toLowerCase()
title="Project RNG seed (int)" .includes(varSearchQuery.trim().toLowerCase())
> ).map((v) => (
_seed <button
</button> key={v.name}
<span className="ml-1">Upstream:</span> type="button"
{upstreamColumns.length === 0 ? ( onClick={() => handleInsertVariable(idx, v.name)}
<span className="italic">none</span> className="flex items-center justify-between text-left px-2 py-1.5 rounded-[5px] bg-[#EDF3EC] dark:bg-[#1b2b1e] border border-[#DEE3E8] dark:border-[#4F6170] hover:border-[#000000] dark:hover:border-[#FFFFFF] transition-colors group cursor-pointer"
) : ( >
upstreamColumns.map((col) => ( <span className="font-mono text-xs font-bold text-[#346538] dark:text-[#88d48e]">
<button {v.name}
key={col} </span>
type="button" <span className="font-mono text-[10px] text-[#4F6170] dark:text-[#ADBFCE] group-hover:text-[#000000] dark:group-hover:text-[#FFFFFF] transition-colors truncate max-w-[155px]">
onClick={() => { {v.desc}
const currentExpr = field.config?.expression || ''; </span>
handleConfigChange(idx, 'expression', currentExpr + col); </button>
}} ))}
className="px-2 py-0.5 rounded bg-[#F4FAFF] dark:bg-[#161C22] border-[1.5px] border-[#DEE3E8] dark:border-[#4F6170] hover:border-[#000000] dark:hover:border-[#FFFFFF] text-[#000000] dark:text-[#FFFFFF] transition-colors" </div>
> )}
{col} </div>
</button>
)) {/* Upstream Columns Section */}
<div className="space-y-1.5 border-t-[1.5px] border-[#DEE3E8] dark:border-[#4F6170]/60 pt-2">
<div className="text-[10px] font-mono uppercase tracking-wider font-semibold text-[#4F6170] dark:text-[#ADBFCE] flex items-center justify-between">
<span>Upstream Columns</span>
<span>
{
upstreamColumns.filter((col) =>
col
.toLowerCase()
.includes(varSearchQuery.trim().toLowerCase())
).length
}
</span>
</div>
{upstreamColumns.filter((col) =>
col
.toLowerCase()
.includes(varSearchQuery.trim().toLowerCase())
).length === 0 ? (
<div className="text-[11px] font-mono text-[#4F6170] dark:text-[#ADBFCE] italic px-1">
{upstreamColumns.length === 0
? 'No upstream columns defined before this column'
: 'No matching columns'}
</div>
) : (
<div className="flex flex-wrap gap-1.5 max-h-36 overflow-y-auto pr-1">
{upstreamColumns
.filter((col) =>
col
.toLowerCase()
.includes(varSearchQuery.trim().toLowerCase())
)
.map((col) => (
<button
key={col}
type="button"
onClick={() => handleInsertVariable(idx, col)}
className="px-2 py-1 text-xs font-mono font-medium rounded-[5px] bg-[#F4FAFF] dark:bg-[#161C22] border-[1.5px] border-[#DEE3E8] dark:border-[#4F6170] hover:border-[#000000] dark:hover:border-[#FFFFFF] text-[#000000] dark:text-[#FFFFFF] transition-colors hover:bg-[#FFFFFF] dark:hover:bg-[#0E1317] cursor-pointer"
>
{col}
</button>
))}
</div>
)}
</div>
</div>
)} )}
</div> </div>
</div> </div>
@@ -1194,15 +1295,15 @@ export const TableSchemaEditor: React.FC<TableSchemaEditorProps> = ({
)} )}
{field.type === 'string' && ( {field.type === 'string' && (
<div className="col-span-2"> <div className="col-span-3">
<label className="block text-[#4F6170] dark:text-[#ADBFCE] text-[11px] mb-1 font-medium"> <label className="block text-[#4F6170] dark:text-[#ADBFCE] text-[11px] mb-1 font-medium">
Mask Pattern (# = digit, ? = uppercase, * = alphanumeric) Mask Pattern (# = digit, ? = uppercase, @ = lowercase, % = hex, * = alphanumeric, \ = escape)
</label> </label>
<input <input
type="text" type="text"
value={field.config?.mask || ''} value={field.config?.mask || ''}
onChange={(e) => handleConfigChange(idx, 'mask', e.target.value)} onChange={(e) => handleConfigChange(idx, 'mask', e.target.value)}
placeholder="e.g. USR-####" placeholder="e.g. USR-####, MAC-\#%%-\#%%, 0x%%%%"
className="w-full px-2.5 py-1.5 font-mono text-xs border-[1.5px] border-[#DEE3E8] dark:border-[#4F6170] rounded-[5px] bg-[#FFFFFF] dark:bg-[#0E1317] text-[#000000] dark:text-[#FFFFFF] focus:outline-none focus:border-[#000000] dark:focus:border-[#FFFFFF]" className="w-full px-2.5 py-1.5 font-mono text-xs border-[1.5px] border-[#DEE3E8] dark:border-[#4F6170] rounded-[5px] bg-[#FFFFFF] dark:bg-[#0E1317] text-[#000000] dark:text-[#FFFFFF] focus:outline-none focus:border-[#000000] dark:focus:border-[#FFFFFF]"
/> />
</div> </div>