athar 0.1.0 → 0.2.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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +22 -0
- data/README.md +102 -0
- data/lib/athar/sql.rb +7 -1
- data/lib/athar/version.rb +1 -1
- data/lib/generators/athar/install/functions/athar_apply_masks.sql +68 -0
- data/lib/generators/athar/install/functions/athar_capture_delete.sql +7 -0
- data/lib/generators/athar/install/functions/athar_mask_email.sql +28 -0
- data/lib/generators/athar/install/functions/athar_mask_hash.sql +22 -0
- data/lib/generators/athar/install/functions/athar_mask_partial.sql +38 -0
- data/lib/generators/athar/install/templates/install_migration.rb.erb +2 -1
- data/lib/generators/athar/install/templates/install_migration_fx.rb.erb +2 -1
- data/lib/generators/athar/mask/functions/athar_mask_regex.sql.erb +16 -0
- data/lib/generators/athar/mask/mask_generator.rb +192 -0
- data/lib/generators/athar/mask/templates/install_migration.rb.erb +11 -0
- data/lib/generators/athar/mask/templates/install_migration_fx.rb.erb +5 -0
- data/lib/generators/athar/mask/templates/remove_migration.rb.erb +9 -0
- data/lib/generators/athar/mask/templates/remove_migration_fx.rb.erb +9 -0
- data/lib/generators/athar/mask/templates/update_migration.rb.erb +11 -0
- data/lib/generators/athar/mask/templates/update_migration_fx.rb.erb +5 -0
- data/lib/generators/athar/model/model_generator.rb +114 -2
- data/lib/generators/athar/model/triggers/athar_delete.sql.erb +2 -1
- metadata +13 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: '078258b806e8803d8c53908c23db272a425f1bfced177c80748cecb2afb96a0e'
|
|
4
|
+
data.tar.gz: 2e8435f0a14564b17085dddca4b1739c10b8ea06fdd4610592cc42a5a0390785
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: af56062074cc9a29881321e7b8bd97e9d3758652f32118322c95a0e7a907ea9b3e890a7e534599b598b3a748183fae386ac2e641bcf012174d0fb92eda7414dc
|
|
7
|
+
data.tar.gz: 9e98d6d11ce1dc73727da90ba060bcd29d7c4df399699c38dbe2253cf91d5b65d8fb68346dfadfcc937f4bd806fd5102f8e7c52b3a35759478ae61fea67bbec2
|
data/CHANGELOG.md
CHANGED
|
@@ -6,6 +6,28 @@ The format is based on Keep a Changelog, and this project adheres to Semantic Ve
|
|
|
6
6
|
|
|
7
7
|
## [Unreleased]
|
|
8
8
|
|
|
9
|
+
## [0.2.0] - 2026-05-05
|
|
10
|
+
|
|
11
|
+
### Added
|
|
12
|
+
|
|
13
|
+
- Data masking for `athar_deletions.record_data`.
|
|
14
|
+
- Built-in masks: `:email`, `:partial:N:M`, `:hash`.
|
|
15
|
+
- New `athar:mask` generator for named regex masks.
|
|
16
|
+
- Custom mask functions via `athar_mask_<name>(jsonb) RETURNS jsonb`.
|
|
17
|
+
- New `--mask=col:mask_name[:arg...]` flag on `athar:model`.
|
|
18
|
+
- Bumped `athar_capture_delete` to v02 (adds optional 9th trigger argument for masks).
|
|
19
|
+
|
|
20
|
+
### Upgrade
|
|
21
|
+
|
|
22
|
+
After bundling, run:
|
|
23
|
+
|
|
24
|
+
```sh
|
|
25
|
+
bin/rails generate athar:install --update
|
|
26
|
+
bin/rails db:migrate
|
|
27
|
+
```
|
|
28
|
+
|
|
29
|
+
This installs the new built-in mask functions and bumps `athar_capture_delete`. Existing model triggers continue to work without regeneration; regenerate them only if you want to add masks (`bin/rails generate athar:model X --update --mask=...`).
|
|
30
|
+
|
|
9
31
|
## [0.1.0] - 2026-05-03
|
|
10
32
|
|
|
11
33
|
### Added
|
data/README.md
CHANGED
|
@@ -31,6 +31,7 @@ It does not turn deleted rows into queryable models, and it does not provide ful
|
|
|
31
31
|
- [The Solution](#the-solution)
|
|
32
32
|
- [Requirements](#requirements)
|
|
33
33
|
- [Installation](#installation)
|
|
34
|
+
- [Upgrading](#upgrading)
|
|
34
35
|
- [Quick Start](#quick-start)
|
|
35
36
|
- [Usage](#usage)
|
|
36
37
|
- [Configuration](#configuration)
|
|
@@ -93,6 +94,22 @@ config.active_record.schema_format = :sql
|
|
|
93
94
|
|
|
94
95
|
The generators raise a clear error if you pass `--no-fx` while the host app still uses `schema.rb`. They never edit `config/application.rb` for you.
|
|
95
96
|
|
|
97
|
+
## Upgrading
|
|
98
|
+
|
|
99
|
+
After upgrading Athar, run the install generator in update mode and migrate:
|
|
100
|
+
|
|
101
|
+
```sh
|
|
102
|
+
bin/rails generate athar:install --update
|
|
103
|
+
bin/rails db:migrate
|
|
104
|
+
```
|
|
105
|
+
|
|
106
|
+
This updates Athar's shared PostgreSQL functions without recreating the audit tables. Existing model triggers continue to work; regenerate a model trigger only when you want to change its capture policy, for example to add masks:
|
|
107
|
+
|
|
108
|
+
```sh
|
|
109
|
+
bin/rails generate athar:model User --update --snapshot --mask=email:email
|
|
110
|
+
bin/rails db:migrate
|
|
111
|
+
```
|
|
112
|
+
|
|
96
113
|
## Quick Start
|
|
97
114
|
|
|
98
115
|
Install capture for a model:
|
|
@@ -153,6 +170,83 @@ The model file does not change. Capture policy is owned by the trigger. To chang
|
|
|
153
170
|
|
|
154
171
|
Identity-only is the default and is recommended for high-churn tables. Prefer `--only` for PII-sensitive records over `--snapshot`. `--except` is not supported because it is too easy to accidentally retain new sensitive columns.
|
|
155
172
|
|
|
173
|
+
### Data Masking
|
|
174
|
+
|
|
175
|
+
Athar can mask values inside `record_data` before they are stored, so the audit log keeps signal without retaining the original sensitive value:
|
|
176
|
+
|
|
177
|
+
```sh
|
|
178
|
+
bin/rails generate athar:model User --snapshot --mask=email:email,phone:partial:0:4
|
|
179
|
+
bin/rails db:migrate
|
|
180
|
+
```
|
|
181
|
+
|
|
182
|
+
```ruby
|
|
183
|
+
User.find(user_id).destroy!
|
|
184
|
+
Athar::Deletion.last.record_data
|
|
185
|
+
# => { "email" => "use***@example.com", "phone" => "********4567", ... }
|
|
186
|
+
```
|
|
187
|
+
|
|
188
|
+
Three built-in masks are available:
|
|
189
|
+
|
|
190
|
+
| Spec | Behavior | Example |
|
|
191
|
+
|------|----------|---------|
|
|
192
|
+
| `email` | Keeps the first 3 chars of the local part, then `***@<domain>`. | `user.name@example.com` → `use***@example.com` |
|
|
193
|
+
| `partial:N:M` | Keeps the first `N` and last `M` characters; replaces the middle with `*` (length-preserving). When `N + M ≥ length`, returns all asterisks. | `4111111111111111` with `partial:0:4` → `************1111` |
|
|
194
|
+
| `hash` | Plain SHA-256 hex of the textual form. Deterministic across rows. | `user.name@example.com` → 64 hex chars |
|
|
195
|
+
|
|
196
|
+
Mask spec format: `column:mask_name[:arg1:arg2]`. Multiple specs are comma-separated. Built-ins each have a fixed arity (`partial` takes two integer args; `email` and `hash` take none). Custom mask functions take no DSL args.
|
|
197
|
+
|
|
198
|
+
The `--mask` flag requires `--only` or `--snapshot`; identity-only capture has nothing to mask.
|
|
199
|
+
|
|
200
|
+
#### Named Regex Masks
|
|
201
|
+
|
|
202
|
+
For per-app patterns where the built-ins do not fit, install a named regex mask:
|
|
203
|
+
|
|
204
|
+
```sh
|
|
205
|
+
bin/rails generate athar:mask ssn_keep_last4 \
|
|
206
|
+
--regex='^([0-9]{3})-([0-9]{2})-([0-9]{4})$' \
|
|
207
|
+
--replacement='XXX-XX-\3'
|
|
208
|
+
bin/rails db:migrate
|
|
209
|
+
```
|
|
210
|
+
|
|
211
|
+
Reference it from a model:
|
|
212
|
+
|
|
213
|
+
```sh
|
|
214
|
+
bin/rails generate athar:model User --snapshot --mask=ssn:ssn_keep_last4
|
|
215
|
+
bin/rails db:migrate
|
|
216
|
+
```
|
|
217
|
+
|
|
218
|
+
`bin/rails generate athar:mask <name> --update --regex=... --replacement=...` regenerates the function. `--remove` drops it (and refuses if any model trigger still references it).
|
|
219
|
+
|
|
220
|
+
#### Custom Mask Functions
|
|
221
|
+
|
|
222
|
+
Applications can install any PostgreSQL function with the signature `athar_mask_<name>(value jsonb) RETURNS jsonb` and reference it from `--mask`:
|
|
223
|
+
|
|
224
|
+
```sql
|
|
225
|
+
CREATE OR REPLACE FUNCTION athar_mask_uae_phone(value jsonb) RETURNS jsonb AS $$
|
|
226
|
+
DECLARE text_value text;
|
|
227
|
+
BEGIN
|
|
228
|
+
IF value IS NULL OR jsonb_typeof(value) <> 'string' THEN RETURN value; END IF;
|
|
229
|
+
text_value := value #>> '{}';
|
|
230
|
+
RETURN to_jsonb(regexp_replace(text_value, '^\+971([0-9]{2})[0-9]+([0-9]{4})$', '+971\1****\2'));
|
|
231
|
+
END;
|
|
232
|
+
$$ LANGUAGE plpgsql IMMUTABLE;
|
|
233
|
+
```
|
|
234
|
+
|
|
235
|
+
```sh
|
|
236
|
+
bin/rails generate athar:model User --snapshot --mask=phone:uae_phone
|
|
237
|
+
```
|
|
238
|
+
|
|
239
|
+
The names `email`, `partial`, and `hash` are reserved and cannot be used by custom or named-regex masks.
|
|
240
|
+
|
|
241
|
+
#### Privacy and Operational Notes
|
|
242
|
+
|
|
243
|
+
> [!IMPORTANT]
|
|
244
|
+
> Masking only protects what is stored in `athar_deletions`. It does **not** redact values from the PostgreSQL WAL — logical replication, point-in-time recovery, and `pg_dump` see the original `DELETE` row before the trigger fires.
|
|
245
|
+
|
|
246
|
+
- `:hash` is unsalted SHA-256. Same input always yields the same digest, which lets analysts correlate deletions across tables but means the audit log is brute-forceable for small input universes (emails, phone numbers, national IDs).
|
|
247
|
+
- Identity columns (`record_id`, `record_type`, `actor_*`, `schema_name`, `table_name`, `deleted_at`) are never masked; they are indexed lookup keys.
|
|
248
|
+
- Mask spec is frozen into the trigger at migration time. There is no runtime override.
|
|
249
|
+
|
|
156
250
|
### Generator Options
|
|
157
251
|
|
|
158
252
|
- `--primary-key=id`
|
|
@@ -387,6 +481,14 @@ You generated a trigger for a table whose primary key type does not match the sh
|
|
|
387
481
|
|
|
388
482
|
Confirm the table has an Athar trigger installed, and confirm the delete was not wrapped in `Athar.without_capture`.
|
|
389
483
|
|
|
484
|
+
### "Function `athar_mask_foo` does not exist"
|
|
485
|
+
|
|
486
|
+
The function was dropped after the trigger was installed (or `bin/rails generate athar:install --update` was never run after upgrading the gem). Either reinstall the function, run `--update`, or run `bin/rails generate athar:model X --update` to regenerate the trigger without the orphan reference.
|
|
487
|
+
|
|
488
|
+
### "athar_mask_partial: head and tail must be non-negative"
|
|
489
|
+
|
|
490
|
+
A trigger was hand-edited or constructed with negative `partial` arguments. The generator validates this at scaffold time, so the runtime check only fires for triggers that bypassed the generator.
|
|
491
|
+
|
|
390
492
|
## Development
|
|
391
493
|
|
|
392
494
|
The maintained local workflow is mise:
|
data/lib/athar/sql.rb
CHANGED
|
@@ -12,6 +12,10 @@ module Athar
|
|
|
12
12
|
STATIC_FUNCTIONS = %w[
|
|
13
13
|
athar_filter_keys
|
|
14
14
|
athar_capture_delete
|
|
15
|
+
athar_mask_email
|
|
16
|
+
athar_mask_partial
|
|
17
|
+
athar_mask_hash
|
|
18
|
+
athar_apply_masks
|
|
15
19
|
].freeze
|
|
16
20
|
|
|
17
21
|
TEMPLATE_FUNCTIONS = %w[
|
|
@@ -38,8 +42,10 @@ module Athar
|
|
|
38
42
|
|
|
39
43
|
def function_signature(name)
|
|
40
44
|
case name
|
|
41
|
-
when "athar_filter_keys" then "jsonb, text[]"
|
|
45
|
+
when "athar_filter_keys", "athar_apply_masks" then "jsonb, text[]"
|
|
42
46
|
when "athar_capture_delete", "athar_capture_truncate" then ""
|
|
47
|
+
when "athar_mask_email", "athar_mask_hash" then "jsonb"
|
|
48
|
+
when "athar_mask_partial" then "jsonb, integer, integer"
|
|
43
49
|
else
|
|
44
50
|
raise ArgumentError, "unknown SQL function: #{name.inspect}"
|
|
45
51
|
end
|
data/lib/athar/version.rb
CHANGED
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
-- Athar mask dispatcher (v1).
|
|
2
|
+
-- Walks an array of "column:mask_name[:arg1:arg2]" specs and applies each
|
|
3
|
+
-- mask to the named column inside the supplied jsonb object. Built-ins
|
|
4
|
+
-- are dispatched via a hardcoded CASE; unknown names are routed to a
|
|
5
|
+
-- custom user-installed athar_mask_<name>(jsonb) RETURNS jsonb function
|
|
6
|
+
-- via EXECUTE (the format() call %I-quotes the identifier).
|
|
7
|
+
--
|
|
8
|
+
-- IMPORTANT: jsonb_set propagates SQL NULL through `new_value`, which
|
|
9
|
+
-- would wipe the entire `data` object if a mask returned SQL NULL. We
|
|
10
|
+
-- COALESCE to JSON null so NULL-returning masks (e.g. athar_mask_email
|
|
11
|
+
-- on a NULL input) preserve the column as JSON null without corrupting
|
|
12
|
+
-- the surrounding object.
|
|
13
|
+
|
|
14
|
+
CREATE OR REPLACE FUNCTION athar_apply_masks(
|
|
15
|
+
data jsonb,
|
|
16
|
+
masks text[]
|
|
17
|
+
) RETURNS jsonb AS $$
|
|
18
|
+
DECLARE
|
|
19
|
+
spec text;
|
|
20
|
+
parts text[];
|
|
21
|
+
column_name text;
|
|
22
|
+
mask_name text;
|
|
23
|
+
value jsonb;
|
|
24
|
+
masked_value jsonb;
|
|
25
|
+
head_arg int;
|
|
26
|
+
tail_arg int;
|
|
27
|
+
BEGIN
|
|
28
|
+
IF data IS NULL OR masks IS NULL THEN
|
|
29
|
+
RETURN data;
|
|
30
|
+
END IF;
|
|
31
|
+
|
|
32
|
+
FOREACH spec IN ARRAY masks LOOP
|
|
33
|
+
parts := string_to_array(spec, ':');
|
|
34
|
+
column_name := parts[1];
|
|
35
|
+
mask_name := parts[2];
|
|
36
|
+
|
|
37
|
+
IF NOT (data ? column_name) THEN
|
|
38
|
+
CONTINUE;
|
|
39
|
+
END IF;
|
|
40
|
+
|
|
41
|
+
value := data -> column_name;
|
|
42
|
+
masked_value := NULL;
|
|
43
|
+
|
|
44
|
+
CASE mask_name
|
|
45
|
+
WHEN 'email' THEN
|
|
46
|
+
masked_value := athar_mask_email(value);
|
|
47
|
+
WHEN 'partial' THEN
|
|
48
|
+
head_arg := parts[3]::int;
|
|
49
|
+
tail_arg := parts[4]::int;
|
|
50
|
+
masked_value := athar_mask_partial(value, head_arg, tail_arg);
|
|
51
|
+
WHEN 'hash' THEN
|
|
52
|
+
masked_value := athar_mask_hash(value);
|
|
53
|
+
ELSE
|
|
54
|
+
EXECUTE format('SELECT %I($1)', 'athar_mask_' || mask_name)
|
|
55
|
+
INTO masked_value
|
|
56
|
+
USING value;
|
|
57
|
+
END CASE;
|
|
58
|
+
|
|
59
|
+
data := jsonb_set(
|
|
60
|
+
data,
|
|
61
|
+
ARRAY[column_name],
|
|
62
|
+
COALESCE(masked_value, 'null'::jsonb)
|
|
63
|
+
);
|
|
64
|
+
END LOOP;
|
|
65
|
+
|
|
66
|
+
RETURN data;
|
|
67
|
+
END;
|
|
68
|
+
$$ LANGUAGE plpgsql;
|
|
@@ -8,6 +8,7 @@
|
|
|
8
8
|
-- TG_ARGV[5] record_type_column ('type' | 'null')
|
|
9
9
|
-- TG_ARGV[6] capture_mode ('identity' | 'only' | 'snapshot')
|
|
10
10
|
-- TG_ARGV[7] columns ('{email,name}' or 'null')
|
|
11
|
+
-- TG_ARGV[8] masks ('{"col:mask_name[:arg1:arg2]"}' or 'null')
|
|
11
12
|
|
|
12
13
|
CREATE OR REPLACE FUNCTION athar_capture_delete()
|
|
13
14
|
RETURNS trigger AS $$
|
|
@@ -20,6 +21,7 @@ DECLARE
|
|
|
20
21
|
arg_record_type_column text;
|
|
21
22
|
arg_capture_mode text;
|
|
22
23
|
arg_columns text[];
|
|
24
|
+
arg_masks text[];
|
|
23
25
|
|
|
24
26
|
full_row jsonb;
|
|
25
27
|
filtered_data jsonb;
|
|
@@ -38,6 +40,7 @@ BEGIN
|
|
|
38
40
|
arg_record_type_column := NULLIF(TG_ARGV[5], 'null');
|
|
39
41
|
arg_capture_mode := NULLIF(TG_ARGV[6], 'null');
|
|
40
42
|
arg_columns := NULLIF(TG_ARGV[7], 'null')::text[];
|
|
43
|
+
arg_masks := NULLIF(TG_ARGV[8], 'null')::text[];
|
|
41
44
|
|
|
42
45
|
full_row := to_jsonb(OLD);
|
|
43
46
|
|
|
@@ -58,6 +61,10 @@ BEGIN
|
|
|
58
61
|
RAISE EXCEPTION 'Unsupported Athar capture mode: %', arg_capture_mode;
|
|
59
62
|
END IF;
|
|
60
63
|
|
|
64
|
+
IF arg_masks IS NOT NULL AND array_length(arg_masks, 1) > 0 THEN
|
|
65
|
+
filtered_data := athar_apply_masks(filtered_data, arg_masks);
|
|
66
|
+
END IF;
|
|
67
|
+
|
|
61
68
|
meta := '{}'::jsonb;
|
|
62
69
|
meta_text := current_setting('athar.meta', true);
|
|
63
70
|
IF coalesce(meta_text, '') <> '' THEN
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
-- Athar email mask (v1).
|
|
2
|
+
-- Keeps the first 3 characters of the local part, appends 3 literal
|
|
3
|
+
-- asterisks, then the original domain. NULL and non-string inputs pass
|
|
4
|
+
-- through unchanged.
|
|
5
|
+
|
|
6
|
+
CREATE OR REPLACE FUNCTION athar_mask_email(value jsonb) RETURNS jsonb AS $$
|
|
7
|
+
DECLARE
|
|
8
|
+
text_value text;
|
|
9
|
+
at_position int;
|
|
10
|
+
local_part text;
|
|
11
|
+
domain text;
|
|
12
|
+
BEGIN
|
|
13
|
+
IF value IS NULL OR jsonb_typeof(value) <> 'string' THEN
|
|
14
|
+
RETURN value;
|
|
15
|
+
END IF;
|
|
16
|
+
|
|
17
|
+
text_value := value #>> '{}';
|
|
18
|
+
at_position := position('@' in text_value);
|
|
19
|
+
IF at_position = 0 THEN
|
|
20
|
+
RETURN value;
|
|
21
|
+
END IF;
|
|
22
|
+
|
|
23
|
+
local_part := substring(text_value, 1, at_position - 1);
|
|
24
|
+
domain := substring(text_value, at_position + 1);
|
|
25
|
+
|
|
26
|
+
RETURN to_jsonb(left(local_part, 3) || '***@' || domain);
|
|
27
|
+
END;
|
|
28
|
+
$$ LANGUAGE plpgsql IMMUTABLE;
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
-- Athar SHA-256 hash mask (v1).
|
|
2
|
+
-- Hashes the textual representation of any JSON scalar. Deterministic
|
|
3
|
+
-- (no salt). NULL passes through. Uses Postgres built-in sha256() (PG 11+);
|
|
4
|
+
-- Athar requires PG 13+, so no extension is needed.
|
|
5
|
+
|
|
6
|
+
CREATE OR REPLACE FUNCTION athar_mask_hash(value jsonb) RETURNS jsonb AS $$
|
|
7
|
+
DECLARE
|
|
8
|
+
text_value text;
|
|
9
|
+
BEGIN
|
|
10
|
+
IF value IS NULL THEN
|
|
11
|
+
RETURN value;
|
|
12
|
+
END IF;
|
|
13
|
+
|
|
14
|
+
IF jsonb_typeof(value) = 'string' THEN
|
|
15
|
+
text_value := value #>> '{}';
|
|
16
|
+
ELSE
|
|
17
|
+
text_value := value::text;
|
|
18
|
+
END IF;
|
|
19
|
+
|
|
20
|
+
RETURN to_jsonb(encode(sha256(text_value::bytea), 'hex'));
|
|
21
|
+
END;
|
|
22
|
+
$$ LANGUAGE plpgsql IMMUTABLE;
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
-- Athar partial mask (v1).
|
|
2
|
+
-- Keeps the first `head` and last `tail` characters; replaces the middle
|
|
3
|
+
-- with `*` (length-preserving). When head+tail >= length, returns the
|
|
4
|
+
-- whole string as asterisks of the same length.
|
|
5
|
+
|
|
6
|
+
CREATE OR REPLACE FUNCTION athar_mask_partial(
|
|
7
|
+
value jsonb,
|
|
8
|
+
head int,
|
|
9
|
+
tail int
|
|
10
|
+
) RETURNS jsonb AS $$
|
|
11
|
+
DECLARE
|
|
12
|
+
text_value text;
|
|
13
|
+
total_length int;
|
|
14
|
+
middle_length int;
|
|
15
|
+
BEGIN
|
|
16
|
+
IF value IS NULL OR jsonb_typeof(value) <> 'string' THEN
|
|
17
|
+
RETURN value;
|
|
18
|
+
END IF;
|
|
19
|
+
|
|
20
|
+
IF head < 0 OR tail < 0 THEN
|
|
21
|
+
RAISE EXCEPTION 'athar_mask_partial: head and tail must be non-negative (got %, %)', head, tail;
|
|
22
|
+
END IF;
|
|
23
|
+
|
|
24
|
+
text_value := value #>> '{}';
|
|
25
|
+
total_length := char_length(text_value);
|
|
26
|
+
|
|
27
|
+
IF head + tail >= total_length THEN
|
|
28
|
+
RETURN to_jsonb(repeat('*', total_length));
|
|
29
|
+
END IF;
|
|
30
|
+
|
|
31
|
+
middle_length := total_length - head - tail;
|
|
32
|
+
RETURN to_jsonb(
|
|
33
|
+
substring(text_value, 1, head) ||
|
|
34
|
+
repeat('*', middle_length) ||
|
|
35
|
+
substring(text_value, total_length - tail + 1)
|
|
36
|
+
);
|
|
37
|
+
END;
|
|
38
|
+
$$ LANGUAGE plpgsql IMMUTABLE;
|
|
@@ -66,7 +66,7 @@ class <%= migration_class_name %> < ActiveRecord::Migration[<%= ActiveRecord::Mi
|
|
|
66
66
|
<% end -%>
|
|
67
67
|
SQL
|
|
68
68
|
end
|
|
69
|
-
|
|
69
|
+
<% unless update? -%>
|
|
70
70
|
private
|
|
71
71
|
|
|
72
72
|
def athar_primary_and_foreign_key_types
|
|
@@ -77,4 +77,5 @@ class <%= migration_class_name %> < ActiveRecord::Migration[<%= ActiveRecord::Mi
|
|
|
77
77
|
foreign_key_type = setting || :bigint
|
|
78
78
|
[primary_key_type, foreign_key_type]
|
|
79
79
|
end
|
|
80
|
+
<% end -%>
|
|
80
81
|
end
|
|
@@ -59,7 +59,7 @@ class <%= migration_class_name %> < ActiveRecord::Migration[<%= ActiveRecord::Mi
|
|
|
59
59
|
<% end -%>
|
|
60
60
|
<% end -%>
|
|
61
61
|
end
|
|
62
|
-
|
|
62
|
+
<% unless update? -%>
|
|
63
63
|
private
|
|
64
64
|
|
|
65
65
|
def athar_primary_and_foreign_key_types
|
|
@@ -70,4 +70,5 @@ class <%= migration_class_name %> < ActiveRecord::Migration[<%= ActiveRecord::Mi
|
|
|
70
70
|
foreign_key_type = setting || :bigint
|
|
71
71
|
[primary_key_type, foreign_key_type]
|
|
72
72
|
end
|
|
73
|
+
<% end -%>
|
|
73
74
|
end
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
-- Athar named regex mask: <%= name %>
|
|
2
|
+
-- Generated by `bin/rails g athar:mask <%= name %> --regex=... --replacement=...`.
|
|
3
|
+
|
|
4
|
+
CREATE OR REPLACE FUNCTION athar_mask_<%= name %>(value jsonb) RETURNS jsonb AS $$
|
|
5
|
+
DECLARE
|
|
6
|
+
text_value text;
|
|
7
|
+
BEGIN
|
|
8
|
+
IF value IS NULL OR jsonb_typeof(value) <> 'string' THEN
|
|
9
|
+
RETURN value;
|
|
10
|
+
END IF;
|
|
11
|
+
|
|
12
|
+
text_value := value #>> '{}';
|
|
13
|
+
|
|
14
|
+
RETURN to_jsonb(regexp_replace(text_value, <%= pg_quote(options[:regex]) %>, <%= pg_quote(options[:replacement]) %>, <%= pg_quote(options[:flags]) %>));
|
|
15
|
+
END;
|
|
16
|
+
$$ LANGUAGE plpgsql IMMUTABLE;
|
|
@@ -0,0 +1,192 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "rails/generators"
|
|
4
|
+
require "rails/generators/active_record"
|
|
5
|
+
require_relative "../fx_helper"
|
|
6
|
+
|
|
7
|
+
module Athar
|
|
8
|
+
module Generators
|
|
9
|
+
class MaskGenerator < ::Rails::Generators::NamedBase # rubocop:disable Metrics/ClassLength
|
|
10
|
+
include ::Rails::Generators::Migration
|
|
11
|
+
include FxHelper
|
|
12
|
+
|
|
13
|
+
RESERVED_MASK_NAMES = %w[email partial hash].freeze
|
|
14
|
+
SAFE_IDENTIFIER_REGEX = /\A[A-Za-z_][A-Za-z0-9_]*\z/
|
|
15
|
+
|
|
16
|
+
source_root File.expand_path("templates", __dir__)
|
|
17
|
+
|
|
18
|
+
argument :name, type: :string, banner: "MaskName"
|
|
19
|
+
|
|
20
|
+
class_option :regex, type: :string,
|
|
21
|
+
default: nil, desc: "Regex pattern passed to PostgreSQL regexp_replace"
|
|
22
|
+
class_option :replacement, type: :string, default: nil,
|
|
23
|
+
desc: "Replacement string (use \\1, \\2, etc. for capture groups)"
|
|
24
|
+
class_option :flags, type: :string, default: "g",
|
|
25
|
+
desc: "Flags for regexp_replace (default 'g')"
|
|
26
|
+
class_option :update, type: :boolean, default: false,
|
|
27
|
+
desc: "Generate an update migration that bumps the function version"
|
|
28
|
+
class_option :remove, type: :boolean, default: false,
|
|
29
|
+
desc: "Generate a removal migration that drops the function"
|
|
30
|
+
|
|
31
|
+
def validate_options!
|
|
32
|
+
validate_name!
|
|
33
|
+
validate_action!
|
|
34
|
+
validate_no_active_references! if remove?
|
|
35
|
+
ensure_raw_sql_supported! unless fx?
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
def write_function_file
|
|
39
|
+
return unless fx? && !remove?
|
|
40
|
+
|
|
41
|
+
FileUtils.mkdir_p(functions_destination)
|
|
42
|
+
version = next_version.to_s.rjust(2, "0")
|
|
43
|
+
path = File.join(functions_destination, "athar_mask_#{name}_v#{version}.sql")
|
|
44
|
+
File.write(path, function_body)
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
def generate_migration
|
|
48
|
+
template_name =
|
|
49
|
+
if remove?
|
|
50
|
+
fx? ? "remove_migration_fx.rb.erb" : "remove_migration.rb.erb"
|
|
51
|
+
elsif update?
|
|
52
|
+
fx? ? "update_migration_fx.rb.erb" : "update_migration.rb.erb"
|
|
53
|
+
else
|
|
54
|
+
fx? ? "install_migration_fx.rb.erb" : "install_migration.rb.erb"
|
|
55
|
+
end
|
|
56
|
+
migration_template template_name, "db/migrate/#{migration_filename}.rb"
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
no_tasks do # rubocop:disable Metrics/BlockLength
|
|
60
|
+
def update? = options.[](:update)
|
|
61
|
+
def remove? = options.[](:remove)
|
|
62
|
+
def function_name = "athar_mask_#{name}"
|
|
63
|
+
|
|
64
|
+
def function_body
|
|
65
|
+
template_path = File.join(__dir__, "functions/athar_mask_regex.sql.erb")
|
|
66
|
+
ERB.new(File.read(template_path), trim_mode: "-").result(binding)
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
def migration_filename
|
|
70
|
+
if remove?
|
|
71
|
+
"athar_remove_mask_#{name}"
|
|
72
|
+
elsif update?
|
|
73
|
+
"athar_update_mask_#{name}_v#{next_version.to_s.rjust(2, "0")}"
|
|
74
|
+
else
|
|
75
|
+
"athar_install_mask_#{name}"
|
|
76
|
+
end
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
def migration_class_name
|
|
80
|
+
migration_filename.camelize
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
def functions_destination
|
|
84
|
+
File.expand_path("db/functions", destination_root)
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
def pg_quote(value)
|
|
88
|
+
return "''" if value.nil?
|
|
89
|
+
|
|
90
|
+
"'#{value.to_s.gsub("'", "''")}'"
|
|
91
|
+
end
|
|
92
|
+
|
|
93
|
+
def next_version
|
|
94
|
+
@next_version ||= previous_version_for_mask + 1
|
|
95
|
+
end
|
|
96
|
+
|
|
97
|
+
def previous_version_for_mask # rubocop:disable Metrics/AbcSize,Metrics/CyclomaticComplexity,Metrics/MethodLength,Metrics/PerceivedComplexity
|
|
98
|
+
# In fx mode, determine version from .sql files in db/functions/
|
|
99
|
+
if fx? && File.directory?(functions_destination)
|
|
100
|
+
sql_version = Dir.entries(functions_destination)
|
|
101
|
+
.filter_map { |f| f[/\Aathar_mask_#{Regexp.escape(name)}_v(\d+)\.sql\z/, 1]&.to_i }
|
|
102
|
+
.max
|
|
103
|
+
|
|
104
|
+
return sql_version if sql_version
|
|
105
|
+
end
|
|
106
|
+
|
|
107
|
+
# Fallback: determine version from existing migration filenames.
|
|
108
|
+
# Install migration has no version suffix (counts as v1 when present).
|
|
109
|
+
# Update migrations have _vNN suffix.
|
|
110
|
+
migrations_dir = File.expand_path("db/migrate", destination_root)
|
|
111
|
+
return 0 unless File.directory?(migrations_dir)
|
|
112
|
+
|
|
113
|
+
install_exists = Dir.entries(migrations_dir).any? do |f|
|
|
114
|
+
f.match?(/\d+_athar_install_mask_#{Regexp.escape(name)}\.rb\z/)
|
|
115
|
+
end
|
|
116
|
+
|
|
117
|
+
update_versions = Dir.entries(migrations_dir).filter_map do |f|
|
|
118
|
+
f[/\d+_athar_update_mask_#{Regexp.escape(name)}_v(\d+)\.rb\z/,
|
|
119
|
+
1]&.to_i
|
|
120
|
+
end
|
|
121
|
+
|
|
122
|
+
max_update = update_versions.max
|
|
123
|
+
|
|
124
|
+
if max_update
|
|
125
|
+
max_update
|
|
126
|
+
elsif install_exists
|
|
127
|
+
1
|
|
128
|
+
else
|
|
129
|
+
0
|
|
130
|
+
end
|
|
131
|
+
end
|
|
132
|
+
end
|
|
133
|
+
|
|
134
|
+
def self.next_migration_number(dir)
|
|
135
|
+
::ActiveRecord::Generators::Base.next_migration_number(dir)
|
|
136
|
+
end
|
|
137
|
+
|
|
138
|
+
private
|
|
139
|
+
|
|
140
|
+
def validate_name!
|
|
141
|
+
raise_invalid("name #{name.inspect} is not a safe SQL identifier") unless name.match?(SAFE_IDENTIFIER_REGEX)
|
|
142
|
+
raise_invalid("name #{name.inspect} is reserved (built-in mask)") if RESERVED_MASK_NAMES.include?(name)
|
|
143
|
+
return unless name.start_with?("mask_")
|
|
144
|
+
|
|
145
|
+
raise_invalid("name should not start with 'mask_' (the function will already be prefixed athar_mask_)")
|
|
146
|
+
end
|
|
147
|
+
|
|
148
|
+
def validate_action!
|
|
149
|
+
raise_invalid("--update and --remove are mutually exclusive") if update? && remove?
|
|
150
|
+
return if remove?
|
|
151
|
+
return if options[:regex] && options[:replacement]
|
|
152
|
+
|
|
153
|
+
raise_invalid("--regex and --replacement are required (unless --remove)")
|
|
154
|
+
end
|
|
155
|
+
|
|
156
|
+
def validate_no_active_references!
|
|
157
|
+
references = scan_for_mask_references(name)
|
|
158
|
+
return if references.empty?
|
|
159
|
+
|
|
160
|
+
raise_invalid(
|
|
161
|
+
"cannot remove athar_mask_#{name}; still referenced by:\n - " + # rubocop:disable Style/StringConcatenation
|
|
162
|
+
references.join("\n - ") +
|
|
163
|
+
"\nRegenerate those model triggers without this mask first."
|
|
164
|
+
)
|
|
165
|
+
end
|
|
166
|
+
|
|
167
|
+
def scan_for_mask_references(mask_name) # rubocop:disable Metrics/AbcSize,Metrics/MethodLength
|
|
168
|
+
results = []
|
|
169
|
+
|
|
170
|
+
triggers_dir = File.join(destination_root, "db/triggers")
|
|
171
|
+
if Dir.exist?(triggers_dir)
|
|
172
|
+
Dir.glob(File.join(triggers_dir, "*.sql")).each do |path|
|
|
173
|
+
results << path if File.read(path).include?(":#{mask_name}\"")
|
|
174
|
+
end
|
|
175
|
+
end
|
|
176
|
+
|
|
177
|
+
migrate_dir = File.join(destination_root, "db/migrate")
|
|
178
|
+
if Dir.exist?(migrate_dir)
|
|
179
|
+
Dir.glob(File.join(migrate_dir, "*.rb")).each do |path|
|
|
180
|
+
results << path if File.read(path).include?(":#{mask_name}\"")
|
|
181
|
+
end
|
|
182
|
+
end
|
|
183
|
+
|
|
184
|
+
results
|
|
185
|
+
end
|
|
186
|
+
|
|
187
|
+
def raise_invalid(message)
|
|
188
|
+
raise ::Thor::Error, "Athar mask generator error: #{message}"
|
|
189
|
+
end
|
|
190
|
+
end
|
|
191
|
+
end
|
|
192
|
+
end
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
class <%= migration_class_name %> < ActiveRecord::Migration[<%= ActiveRecord::VERSION::STRING.to_f %>]
|
|
2
|
+
def up
|
|
3
|
+
execute <<~SQL
|
|
4
|
+
<%= function_body.lines.map { |l| " #{l}" }.join %>
|
|
5
|
+
SQL
|
|
6
|
+
end
|
|
7
|
+
|
|
8
|
+
def down
|
|
9
|
+
execute "DROP FUNCTION IF EXISTS athar_mask_<%= name %>(jsonb)"
|
|
10
|
+
end
|
|
11
|
+
end
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
class <%= migration_class_name %> < ActiveRecord::Migration[<%= ActiveRecord::VERSION::STRING.to_f %>]
|
|
2
|
+
def up
|
|
3
|
+
execute <<~SQL
|
|
4
|
+
<%= function_body.lines.map { |l| " #{l}" }.join %>
|
|
5
|
+
SQL
|
|
6
|
+
end
|
|
7
|
+
|
|
8
|
+
def down
|
|
9
|
+
raise ActiveRecord::IrreversibleMigration
|
|
10
|
+
end
|
|
11
|
+
end
|
|
@@ -12,6 +12,7 @@ module Athar
|
|
|
12
12
|
include FxHelper
|
|
13
13
|
|
|
14
14
|
ALLOWED_ID_TYPES = %w[bigint integer uuid].freeze
|
|
15
|
+
BUILTIN_MASKS = %w[email partial hash].freeze
|
|
15
16
|
CAPTURE_MODES = %w[identity only snapshot].freeze
|
|
16
17
|
UNSAFE_COLUMN_REGEX = /[\s,{}"\\']/
|
|
17
18
|
# PostgreSQL unquoted identifier surface: starts with letter or _,
|
|
@@ -34,12 +35,14 @@ module Athar
|
|
|
34
35
|
class_option :track_truncate, type: :boolean, default: false, desc: "Install AFTER TRUNCATE trigger."
|
|
35
36
|
class_option :update, type: :boolean, default: false, desc: "Generate an update migration."
|
|
36
37
|
class_option :remove, type: :boolean, default: false, desc: "Generate a removal migration."
|
|
38
|
+
class_option :mask, type: :array, default: nil, desc: "Mask spec: col:mask_name[:arg1:arg2],..."
|
|
37
39
|
|
|
38
40
|
def validate_options!
|
|
39
41
|
validate_capture_mode!
|
|
40
42
|
validate_identifiers!
|
|
41
43
|
validate_id_type!
|
|
42
44
|
validate_columns!
|
|
45
|
+
validate_masks!
|
|
43
46
|
ensure_raw_sql_supported! unless fx?
|
|
44
47
|
end
|
|
45
48
|
|
|
@@ -113,7 +116,8 @@ module Athar
|
|
|
113
116
|
id_type:,
|
|
114
117
|
record_type_column_arg:,
|
|
115
118
|
capture_mode:,
|
|
116
|
-
columns_arg
|
|
119
|
+
columns_arg:,
|
|
120
|
+
masks_arg:
|
|
117
121
|
}
|
|
118
122
|
Athar::SQL.render(template, locals)
|
|
119
123
|
end
|
|
@@ -231,6 +235,29 @@ module Athar
|
|
|
231
235
|
capture_mode == "only" ? "'{#{columns.join(",")}}'" : "'null'"
|
|
232
236
|
end
|
|
233
237
|
|
|
238
|
+
def masks_arg
|
|
239
|
+
return "'null'" if mask_specs.empty?
|
|
240
|
+
|
|
241
|
+
literals = mask_specs.map do |spec|
|
|
242
|
+
pieces = [spec[:column], spec[:mask], *spec[:args]]
|
|
243
|
+
%("#{pieces.join(":")}")
|
|
244
|
+
end
|
|
245
|
+
|
|
246
|
+
"'{#{literals.join(",")}}'"
|
|
247
|
+
end
|
|
248
|
+
|
|
249
|
+
def mask_specs
|
|
250
|
+
@mask_specs ||= Array(options[:mask]).flat_map { |item| item.to_s.split(",") }
|
|
251
|
+
.map(&:strip).reject(&:empty?)
|
|
252
|
+
.map { |spec| parse_mask_spec(spec) }
|
|
253
|
+
end
|
|
254
|
+
|
|
255
|
+
def parse_mask_spec(spec)
|
|
256
|
+
parts = spec.split(":")
|
|
257
|
+
raise_invalid("Mask spec #{spec.inspect} is missing a mask name") if parts.length < 2
|
|
258
|
+
{ column: parts[0], mask: parts[1], args: parts[2..] || [] }
|
|
259
|
+
end
|
|
260
|
+
|
|
234
261
|
def migration_filename
|
|
235
262
|
if remove?
|
|
236
263
|
"athar_remove_#{table_name}_trigger"
|
|
@@ -251,7 +278,7 @@ module Athar
|
|
|
251
278
|
# The `on:` argument passed to Fx's create_trigger / update_trigger /
|
|
252
279
|
# drop_trigger. For the public schema we keep the bare symbol so the
|
|
253
280
|
# generated migration matches the Rails convention. For non-public
|
|
254
|
-
# schemas we pass a "schema.table" string so DROP TRIGGER
|
|
281
|
+
# schemas we pass a "schema.table" string so DROP TRIGGER ... ON ... hits
|
|
255
282
|
# the correct relation regardless of search_path.
|
|
256
283
|
def fx_on_argument
|
|
257
284
|
if schema_name == "public"
|
|
@@ -336,6 +363,91 @@ module Athar
|
|
|
336
363
|
end
|
|
337
364
|
end
|
|
338
365
|
|
|
366
|
+
def validate_masks! # rubocop:disable Metrics/AbcSize,Metrics/CyclomaticComplexity,Metrics/MethodLength,Metrics/PerceivedComplexity
|
|
367
|
+
return if options[:mask].nil? || options[:mask].empty?
|
|
368
|
+
|
|
369
|
+
raise_invalid("--mask requires --only or --snapshot") unless options[:only] || options[:snapshot]
|
|
370
|
+
|
|
371
|
+
seen_columns = {}
|
|
372
|
+
mask_specs.each do |spec|
|
|
373
|
+
column = spec[:column]
|
|
374
|
+
mask = spec[:mask]
|
|
375
|
+
args = spec[:args]
|
|
376
|
+
|
|
377
|
+
validate_safe_identifier!("column", column)
|
|
378
|
+
validate_safe_identifier!("mask", mask)
|
|
379
|
+
|
|
380
|
+
raise_invalid("Duplicate column #{column.inspect} in --mask") if seen_columns[column]
|
|
381
|
+
seen_columns[column] = true
|
|
382
|
+
|
|
383
|
+
if options[:only] && !columns.include?(column)
|
|
384
|
+
raise_invalid("Mask references uncaptured column #{column.inspect}; add it to --only or use --snapshot")
|
|
385
|
+
end
|
|
386
|
+
|
|
387
|
+
if options[:snapshot] && !model_class.columns_hash.key?(column)
|
|
388
|
+
raise_invalid("Mask references unknown column #{column.inspect} on #{table_name}")
|
|
389
|
+
end
|
|
390
|
+
|
|
391
|
+
validate_mask_arity!(mask, args)
|
|
392
|
+
validate_mask_resolves!(mask)
|
|
393
|
+
end
|
|
394
|
+
end
|
|
395
|
+
|
|
396
|
+
def validate_mask_arity!(mask, args) # rubocop:disable Metrics/CyclomaticComplexity
|
|
397
|
+
case mask
|
|
398
|
+
when "email", "hash"
|
|
399
|
+
raise_invalid("#{mask} takes no arguments (got #{args.length})") unless args.empty?
|
|
400
|
+
when "partial"
|
|
401
|
+
unless args.length == 2 && args.all? { |a| a.match?(/\A\d+\z/) }
|
|
402
|
+
raise_invalid("partial requires exactly 2 integer args (got #{args.inspect})")
|
|
403
|
+
end
|
|
404
|
+
else
|
|
405
|
+
raise_invalid("#{mask} is a custom mask and takes no arguments (got #{args.inspect})") unless args.empty?
|
|
406
|
+
end
|
|
407
|
+
end
|
|
408
|
+
|
|
409
|
+
def validate_mask_resolves!(mask)
|
|
410
|
+
return if BUILTIN_MASKS.include?(mask)
|
|
411
|
+
return if custom_mask_installed?(mask)
|
|
412
|
+
|
|
413
|
+
raise_invalid(
|
|
414
|
+
"Mask :#{mask} is not installed. Run 'bin/rails g athar:mask #{mask} --regex=...' " \
|
|
415
|
+
"or install a custom athar_mask_#{mask}(jsonb) function first."
|
|
416
|
+
)
|
|
417
|
+
end
|
|
418
|
+
|
|
419
|
+
def custom_mask_installed?(mask)
|
|
420
|
+
custom_mask_file_installed?(mask) || custom_mask_database_installed?(mask)
|
|
421
|
+
end
|
|
422
|
+
|
|
423
|
+
def custom_mask_file_installed?(mask)
|
|
424
|
+
return false unless fx?
|
|
425
|
+
|
|
426
|
+
Dir.glob(File.join(destination_root, "db/functions/athar_mask_#{mask}_v*.sql")).any?
|
|
427
|
+
end
|
|
428
|
+
|
|
429
|
+
def custom_mask_database_installed?(mask)
|
|
430
|
+
sql = ActiveRecord::Base.sanitize_sql_array([
|
|
431
|
+
<<~SQL.squish,
|
|
432
|
+
SELECT 1
|
|
433
|
+
FROM pg_proc p
|
|
434
|
+
WHERE p.proname = ?
|
|
435
|
+
AND p.pronargs = 1
|
|
436
|
+
AND p.proargtypes[0] = 'jsonb'::regtype
|
|
437
|
+
AND p.prorettype = 'jsonb'::regtype
|
|
438
|
+
AND pg_function_is_visible(p.oid)
|
|
439
|
+
LIMIT 1
|
|
440
|
+
SQL
|
|
441
|
+
"athar_mask_#{mask}"
|
|
442
|
+
])
|
|
443
|
+
|
|
444
|
+
!ActiveRecord::Base.connection.select_value(sql).nil?
|
|
445
|
+
rescue StandardError
|
|
446
|
+
# If we can't reach the DB, fall back to false. The trigger install
|
|
447
|
+
# itself will fail loudly later if the function truly doesn't exist.
|
|
448
|
+
false
|
|
449
|
+
end
|
|
450
|
+
|
|
339
451
|
def raise_invalid(message)
|
|
340
452
|
raise ::Thor::Error, "Athar generator error: #{message}"
|
|
341
453
|
end
|
metadata
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
|
2
2
|
name: athar
|
|
3
3
|
version: !ruby/object:Gem::Version
|
|
4
|
-
version: 0.
|
|
4
|
+
version: 0.2.0
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- Ali Hamdi Ali Fadel
|
|
@@ -103,12 +103,24 @@ files:
|
|
|
103
103
|
- lib/athar/table_event.rb
|
|
104
104
|
- lib/athar/version.rb
|
|
105
105
|
- lib/generators/athar/fx_helper.rb
|
|
106
|
+
- lib/generators/athar/install/functions/athar_apply_masks.sql
|
|
106
107
|
- lib/generators/athar/install/functions/athar_capture_delete.sql
|
|
107
108
|
- lib/generators/athar/install/functions/athar_capture_truncate.sql.erb
|
|
108
109
|
- lib/generators/athar/install/functions/athar_filter_keys.sql
|
|
110
|
+
- lib/generators/athar/install/functions/athar_mask_email.sql
|
|
111
|
+
- lib/generators/athar/install/functions/athar_mask_hash.sql
|
|
112
|
+
- lib/generators/athar/install/functions/athar_mask_partial.sql
|
|
109
113
|
- lib/generators/athar/install/install_generator.rb
|
|
110
114
|
- lib/generators/athar/install/templates/install_migration.rb.erb
|
|
111
115
|
- lib/generators/athar/install/templates/install_migration_fx.rb.erb
|
|
116
|
+
- lib/generators/athar/mask/functions/athar_mask_regex.sql.erb
|
|
117
|
+
- lib/generators/athar/mask/mask_generator.rb
|
|
118
|
+
- lib/generators/athar/mask/templates/install_migration.rb.erb
|
|
119
|
+
- lib/generators/athar/mask/templates/install_migration_fx.rb.erb
|
|
120
|
+
- lib/generators/athar/mask/templates/remove_migration.rb.erb
|
|
121
|
+
- lib/generators/athar/mask/templates/remove_migration_fx.rb.erb
|
|
122
|
+
- lib/generators/athar/mask/templates/update_migration.rb.erb
|
|
123
|
+
- lib/generators/athar/mask/templates/update_migration_fx.rb.erb
|
|
112
124
|
- lib/generators/athar/model/model_generator.rb
|
|
113
125
|
- lib/generators/athar/model/templates/migration.rb.erb
|
|
114
126
|
- lib/generators/athar/model/templates/migration_fx.rb.erb
|