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
10 changes: 9 additions & 1 deletion app/Commands/Cloning/ColumnEditCommand.php
Original file line number Diff line number Diff line change
Expand Up @@ -28,7 +28,7 @@ class ColumnEditCommand extends Command
{file? : Path to the .cloning.yaml file}
{--table= : Table name}
{--column= : Column name}
{--strategy= : Strategy to apply (keep, fake, hash, mask, static, remapping)}
{--strategy= : Strategy to apply (keep, fake, hash, mask, static, template, remapping)}
{--faker-method= : (fake) Faker method name}
{--faker-arguments= : (fake) Comma-separated faker arguments}
{--algorithm= : (hash) Hash algorithm}
Expand All @@ -37,6 +37,7 @@ class ColumnEditCommand extends Command
{--visible-chars= : (mask) Number of visible characters}
{--preserve-format : (mask) Preserve original format}
{--value= : (static) Static replacement value}
{--template= : (template) Template with {fakerMethod} placeholders}
{--remapping-use= : (remapping) random_integer | new_uuid}';

/**
Expand Down Expand Up @@ -89,6 +90,13 @@ private function strategyDefinitions(): array
['name' => 'value', 'label' => 'Static value', 'type' => 'string', 'default' => ''],
],
],
'template' => [
'label' => 'Template',
'description' => 'Build a string by mixing literal text with {fakerMethod} placeholders. Example: {userName}@acme.test',
'parameters' => [
['name' => 'template', 'label' => 'Template (use {fakerMethod} for placeholders)', 'type' => 'string', 'default' => ''],
],
],
'remapping' => [
'label' => 'Remapping',
'description' => 'Replace primary key values; foreign keys auto-detected from the source schema.',
Expand Down
1 change: 1 addition & 0 deletions app/Commands/Cloning/DumpCommand.php
Original file line number Diff line number Diff line change
Expand Up @@ -180,6 +180,7 @@ public function handle(
staticValue: $transformation->staticValue,
piiDetected: true,
piiCategory: $matcher->name,
template: $transformation->template,
);
} else {
$columnDump = new ColumnDumpData(
Expand Down
2 changes: 2 additions & 0 deletions app/Commands/Matchers/CheckCommand.php
Original file line number Diff line number Diff line change
Expand Up @@ -120,6 +120,8 @@ public function handle(PiiMatcherLoader $loader, AnonymizationEngine $engine): i
$this->line(sprintf(' preserve_format: %s', ($t->preserveFormat ?? false) ? 'true' : 'false'));
} elseif ($t->strategy === 'static') {
$this->line(sprintf(' value: "%s"', $t->staticValue ?? ''));
} elseif ($t->strategy === 'template') {
$this->line(sprintf(' template: "%s"', $t->template ?? ''));
}

$this->line('');
Expand Down
2 changes: 2 additions & 0 deletions app/Commands/Matchers/ListCommand.php
Original file line number Diff line number Diff line change
Expand Up @@ -97,6 +97,8 @@ public function handle(PiiMatcherLoader $loader, PiiMatcherYamlReader $reader):
$transformLabel = sprintf('hash → %s', $matcher->transformation->hashAlgorithm ?? 'sha256');
} elseif ($matcher->transformation->strategy === 'mask') {
$transformLabel = 'mask';
} elseif ($matcher->transformation->strategy === 'template' && $matcher->transformation->template !== null) {
$transformLabel = sprintf('template → %s', $matcher->transformation->template);
} else {
$transformLabel = $matcher->transformation->strategy;
}
Expand Down
1 change: 1 addition & 0 deletions app/Data/Cloning/ColumnCloningConfigData.php
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@ public function __construct(
public ?int $visibleChars,
public ?bool $preserveFormat,
public ?string $staticValue,
public ?string $template = null,
public ?string $remappingUse = null,
public ?array $remappingForeignKeys = null,
) {}
Expand Down
1 change: 1 addition & 0 deletions app/Data/Cloning/ColumnDumpData.php
Original file line number Diff line number Diff line change
Expand Up @@ -20,5 +20,6 @@ public function __construct(
public ?string $staticValue,
public bool $piiDetected,
public ?string $piiCategory,
public ?string $template = null,
) {}
}
41 changes: 41 additions & 0 deletions app/Services/Cloning/AnonymizationEngine.php
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,7 @@ public function transform(mixed $value, ColumnCloningConfigData $config): mixed
'fake' => $this->applyFake($config),
'hash' => $this->applyHash(is_scalar($value) ? (string) $value : '', $config),
'mask' => $this->applyMask(is_scalar($value) ? (string) $value : '', $config),
'template' => $this->applyTemplate($config),
default => $value,
};
}
Expand Down Expand Up @@ -57,6 +58,46 @@ private function applyHash(string $value, ColumnCloningConfigData $config): stri
return hash($config->hashAlgorithm ?? 'sha256', ($config->hashSalt ?? '').$value);
}

/**
* Expand a template string by replacing `{fakerMethod}` placeholders with
* Faker output. Literal text passes through unchanged. Useful for mixing
* randomized parts with fixed parts (e.g. fixed email domain).
*
* Example: `{userName}@acme.test` → `alice.j42@acme.test`
*
* Unknown methods on the Faker generator render as the empty string so
* pipelines fail soft; the validator rejects them at config-load time.
*/
private function applyTemplate(ColumnCloningConfigData $config): string
{
$template = $config->template;

if ($template === null || $template === '') {
return '';
}

return (string) preg_replace_callback(
'/\{([a-zA-Z][a-zA-Z0-9]*)\}/',
function (array $matches): string {
$method = $matches[1];

if (! method_exists($this->faker, $method)) {
return '';
}

/** @var mixed $result */
$result = $this->faker->{$method}();

if (is_array($result)) {
return implode(' ', array_map(static fn (mixed $v): string => is_scalar($v) ? (string) $v : '', $result));
}

return is_scalar($result) ? (string) $result : '';
},
$template,
);
}

private function applyMask(string $value, ColumnCloningConfigData $config): string
{
$visible = $config->visibleChars ?? 0;
Expand Down
7 changes: 7 additions & 0 deletions app/Services/Cloning/CloningYamlLoader.php
Original file line number Diff line number Diff line change
Expand Up @@ -260,6 +260,7 @@ private function mapColumnConfig(string $columnName, array $config): ColumnCloni
$visibleChars = null;
$preserveFormat = null;
$staticValue = null;
$template = null;
$remappingUse = null;
$remappingForeignKeys = null;

Expand Down Expand Up @@ -288,6 +289,11 @@ private function mapColumnConfig(string $columnName, array $config): ColumnCloni
$staticValue = is_scalar($rawValue) ? (string) $rawValue : null;
break;

case 'template':
$rawTemplate = $config['template'] ?? null;
$template = is_string($rawTemplate) ? $rawTemplate : null;
break;

case 'remapping':
$argsRaw = $config['arguments'] ?? [];
if (is_array($argsRaw)) {
Expand Down Expand Up @@ -342,6 +348,7 @@ private function mapColumnConfig(string $columnName, array $config): ColumnCloni
visibleChars: $visibleChars,
preserveFormat: $preserveFormat,
staticValue: $staticValue,
template: $template,
remappingUse: $remappingUse,
remappingForeignKeys: $remappingForeignKeys,
);
Expand Down
20 changes: 19 additions & 1 deletion app/Services/Cloning/CloningYamlValidator.php
Original file line number Diff line number Diff line change
Expand Up @@ -26,7 +26,7 @@ class CloningYamlValidator

private const array VALID_ROW_STRATEGIES = ['full', 'first', 'last', 'skip'];

private const array VALID_COLUMN_STRATEGIES = ['keep', 'fake', 'hash', 'mask', 'null', 'static', 'remapping'];
private const array VALID_COLUMN_STRATEGIES = ['keep', 'fake', 'hash', 'mask', 'null', 'static', 'template', 'remapping'];

private const array VALID_HASH_ALGORITHMS = ['sha256', 'sha512', 'md5', 'sha1'];

Expand Down Expand Up @@ -415,6 +415,24 @@ private function validateColumnStrategy(string $prefix, string $strategy, array

break;

case 'template':
$template = $config['template'] ?? null;

if (! is_string($template) || $template === '') {
$errors[] = sprintf("%s: 'template' strategy requires non-empty 'template' string", $prefix);
break;
}

if (preg_match_all('/\{([a-zA-Z][a-zA-Z0-9]*)\}/', $template, $matches) > 0) {
foreach ($matches[1] as $method) {
if (! in_array($method, self::KNOWN_FAKER_METHODS, true)) {
$errors[] = sprintf("%s: template references unknown faker method '%s'", $prefix, $method);
}
}
}

break;

case 'remapping':
$argsRaw = $config['arguments'] ?? null;

Expand Down
4 changes: 4 additions & 0 deletions app/Services/Cloning/CloningYamlWriter.php
Original file line number Diff line number Diff line change
Expand Up @@ -120,6 +120,10 @@ public function write(DumpResultData $result): string
$lines[] = sprintf(' value: %s', $this->encodeYamlScalar($column->staticValue));
break;

case 'template':
$lines[] = sprintf(' template: %s', $this->encodeYamlScalar($column->template));
break;

case 'keep':
// no extra fields needed
break;
Expand Down
5 changes: 5 additions & 0 deletions app/Services/Pii/PiiMatcherYamlReader.php
Original file line number Diff line number Diff line change
Expand Up @@ -174,6 +174,10 @@ private function parseTransformation(string $groupKey, string $matcherKey, mixed
? $rawTransformation['value']
: null;

$template = isset($rawTransformation['template']) && is_string($rawTransformation['template'])
? $rawTransformation['template']
: null;

/** @var list<scalar> $fakerArguments */
return new ColumnCloningConfigData(
columnName: $columnName,
Expand All @@ -186,6 +190,7 @@ private function parseTransformation(string $groupKey, string $matcherKey, mixed
visibleChars: $visibleChars,
preserveFormat: $preserveFormat,
staticValue: $staticValue,
template: $template,
);
}
}
2 changes: 2 additions & 0 deletions app/Services/Pii/PiiMatcherYamlWriter.php
Original file line number Diff line number Diff line change
Expand Up @@ -63,6 +63,8 @@ public function write(array $groups, string $path): void
}
} elseif ($t->strategy === 'static') {
$transformationData['value'] = $t->staticValue ?? '';
} elseif ($t->strategy === 'template') {
$transformationData['template'] = $t->template ?? '';
}

$matcherEntry['transformation'] = $transformationData;
Expand Down
30 changes: 30 additions & 0 deletions docs/cloning-yaml.md
Original file line number Diff line number Diff line change
Expand Up @@ -259,6 +259,36 @@ environment_tag:

---

### `template`

Build a string by mixing literal text with `{fakerMethod}` placeholders. Each placeholder is expanded to the output of the named [Faker method](#supported-faker-methods) (no-argument form). Useful when only part of a value needs to be randomized — typically a fixed email domain, a fixed phone prefix, or a fixed organizational suffix.

```yaml
email:
strategy: template
template: "{userName}@acme.test"

display_name:
strategy: template
template: "{firstName} {lastName}"

support_ref:
strategy: template
template: "ACME-{uuid}"
```

| Field | Required | Description |
|-------|:--------:|-------------|
| `template` | yes | Non-empty string. Tokens of the form `{methodName}` are replaced; everything else is passed through. The validator rejects unknown faker methods at config-load time. |

Notes:

- Only no-argument Faker methods are supported inside `{…}`. For arguments, use the regular `fake` strategy.
- The original column value is **not** read. Each output is freshly generated per row, so cross-run linkability is defeated by design.
- Validators reject `{unknown}` tokens; at runtime, unknown methods render as the empty string (defense in depth).

---

### `remapping`

Assign a new primary key value to each transferred row and rewrite all foreign key columns that reference it, preventing ID collisions on the target.
Expand Down
37 changes: 32 additions & 5 deletions specs/PRD-cloning-yaml-schema.md
Original file line number Diff line number Diff line change
Expand Up @@ -78,7 +78,7 @@ tables:
clear: false # optional; false | truncate | delete — default false
columns: # optional section; see column rule below
<column_name>:
strategy: keep # keep | fake | hash | mask | null | static
strategy: keep # keep | fake | hash | mask | null | static | template
# strategy-specific options follow (see §5)
```

Expand Down Expand Up @@ -185,6 +185,24 @@ environment_tag:
|-------|------|:--------:|-------------|
| `value` | string | yes | The fixed value to use (may be empty string) |

### 5.7 `template`

Build a string from literal text mixed with `{fakerMethod}` placeholders. Each placeholder is replaced with the no-argument output of the named Faker method. The original column value is **not** read — every output is freshly generated.

```yaml
email:
strategy: template
template: "{userName}@acme.test"
```

| Field | Type | Required | Description |
|-------|------|:--------:|-------------|
| `template` | string | yes | Non-empty template. Tokens `{methodName}` are expanded; other text passes through. Methods must be in §6. |

Constraints:
- Only no-argument Faker methods are accepted inside `{…}`. For methods with arguments (e.g. `numerify`), use the `fake` strategy instead.
- The validator rejects unknown method names at config-load time.

---

## 6. Supported FakerPHP Methods
Expand Down Expand Up @@ -440,7 +458,7 @@ A YAML language server hint can be placed at the top of every generated file:
"properties": {
"strategy": {
"type": "string",
"enum": ["keep", "fake", "hash", "mask", "null", "static"]
"enum": ["keep", "fake", "hash", "mask", "null", "static", "template"]
},
"faker_method": {
"type": "string",
Expand Down Expand Up @@ -481,6 +499,11 @@ A YAML language server hint can be placed at the top of every generated file:
"value": {
"type": "string",
"description": "Fixed replacement value. Required when strategy is 'static'."
},
"template": {
"type": "string",
"minLength": 1,
"description": "Template string with {fakerMethod} placeholders. Required when strategy is 'template'."
}
},
"allOf": [
Expand All @@ -499,6 +522,10 @@ A YAML language server hint can be placed at the top of every generated file:
{
"if": { "properties": { "strategy": { "const": "static" } }, "required": ["strategy"] },
"then": { "required": ["value"] }
},
{
"if": { "properties": { "strategy": { "const": "template" } }, "required": ["strategy"] },
"then": { "required": ["template"] }
}
]
}
Expand Down Expand Up @@ -534,9 +561,9 @@ tables:
# Only columns that need transformation are listed.
# All other columns (id, status, created_at, …) are implicitly kept as-is.
email:
strategy: fake
faker_method: safeEmail
faker_arguments: []
# Template: fixed domain, randomized local part.
strategy: template
template: "{userName}@acme.test"
first_name:
strategy: fake
faker_method: firstName
Expand Down
16 changes: 16 additions & 0 deletions specs/PRD-pii-matchers.md
Original file line number Diff line number Diff line change
Expand Up @@ -178,6 +178,22 @@ transformation:
value: "[REDACTED]"
```

### 7.6 `strategy: template`

Build a string from literal text plus `{fakerMethod}` placeholders. Each placeholder is replaced with the no-argument output of the named Faker method. Useful when only part of a value needs to be randomized (e.g. fixed email domain).

```yaml
transformation:
strategy: template
template: "{userName}@acme.test"
```

| Field | Required | Description |
|-------|:--------:|-------------|
| `template` | yes | Non-empty template string. Unknown method names are rejected at config-load time. |

Only no-argument Faker methods may appear inside `{…}`. For arguments, use `strategy: fake` instead.

---

## 8. Baseline Groups and Matchers
Expand Down
Loading