astroturf 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.
checksums.yaml ADDED
@@ -0,0 +1,7 @@
1
+ ---
2
+ SHA256:
3
+ metadata.gz: 79d39987b44a757f2689d900dd7256baef7b9d95a3206bf33384b84d57c84b9a
4
+ data.tar.gz: '004561878c96134cdfe94faf12bfb503985aa941e608e353b6a055f8d39073dc'
5
+ SHA512:
6
+ metadata.gz: 49b5b8e9dba709aaef0bb233a04a0be9d97feac081a59a139a193b915a7b300316dfd39d5440469a12d568ac3052093f7eb9ff5930729c262f8fa9592d072dc4
7
+ data.tar.gz: 1d660ba73e13a93ea2c385815a7ea8283093aac4329bd5321b5b4b77d779caa5ebe523b50a72b94f23ae1b6d951ab4762f6d7ef8ba5de6d9cd10eb2647ec4965
data/.gitignore ADDED
@@ -0,0 +1,21 @@
1
+ *.gem
2
+ *.rbc
3
+ /.config
4
+ /coverage/
5
+ /InstalledFiles
6
+ /pkg/
7
+ /spec/reports/
8
+ /spec/examples.txt
9
+ /test/tmp/
10
+ /test/version_tmp/
11
+ /tmp/
12
+
13
+ # Bundler
14
+ /.bundle/
15
+ /vendor/bundle
16
+ /lib/bundler/man/
17
+
18
+ # Environment / IDE
19
+ .DS_Store
20
+ .vscode/
21
+ .idea/
data/Gemfile ADDED
@@ -0,0 +1,5 @@
1
+ # frozen_string_literal: true
2
+
3
+ source "https://rubygems.org"
4
+
5
+ gemspec
data/LICENSE.txt ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Jeff Lunt <jefflunt@gmail.com>
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
data/README.md ADDED
@@ -0,0 +1,188 @@
1
+ # Astroturf
2
+
3
+ Exhaustive synthetic data generator for Rails and Ruby applications.
4
+
5
+ ---
6
+
7
+ ## Why Astroturf?
8
+
9
+ Most test suites test only the happy path.
10
+
11
+ Hand-written fixtures rot. Factory builders are slow and sample only a tiny fraction of valid states. When you undertake a large architectural overhaul or an automated AI-driven refactoring pass, hidden edge cases surface in production: an unhandled enum state, a `nil` middle name, an unexpected boolean combination, or a broken foreign key relationship.
12
+
13
+ **Astroturf aims for total coverage of your value and combination space.**
14
+
15
+ You define the dimensions in a YAML file. Astroturf computes the Cartesian product across every attribute, wires relational foreign keys, generates deterministic labels, and produces in-memory datasets in milliseconds.
16
+
17
+ Use these datasets to:
18
+ - Verify behavioral equivalence before and after sweeping refactors.
19
+ - Generate deterministic Rails fixtures (`test/fixtures/*.yml`).
20
+ - Seed development databases with every permutation of your domain model.
21
+
22
+ ---
23
+
24
+ ## Features
25
+
26
+ - **Full Cartesian Matrix Generation**: Computes every combination across arbitrary attribute dimensions.
27
+ - **Relational Graph Wiring**: Supports nested `has_many` and `has_one` hierarchies with automatic foreign key resolution.
28
+ - **Two Association Modes**:
29
+ - `mode: combinations` (default): Generates the full Cartesian explosion of child records for every parent record.
30
+ - `mode: count`: Cycles or samples a fixed count of children per parent.
31
+ - **Semantic Fixture Labels**: Automatically names records from their matrix values (e.g., `user_admin_active_true`, `user_admin_post_draft`).
32
+ - **Pluggable Primary Keys**:
33
+ - Sequential integers (`1, 2, 3...`) with configurable starting points and steps.
34
+ - Deterministic RFC 4122 UUIDv5 strings based on record labels.
35
+ - Custom callable procs.
36
+ - **Attribute Interpolation**: Dynamically references `%{label}`, `%{seq}`, `%{id}`, parent attributes (`%{parent.label}`, `%{parent.id}`), or current matrix values in string templates.
37
+ - **Zero External Dependencies**: Pure Ruby. Generates fast in-memory hashes without requiring an active database connection.
38
+
39
+ ---
40
+
41
+ ## The Matrix Specification: `astroturf.yml`
42
+
43
+ ```yaml
44
+ users:
45
+ primary_key: integer # Options: integer (default), uuid, or hash options
46
+ matrix:
47
+ role: [admin, member, guest]
48
+ status: [active, suspended]
49
+ email_verified: [true, false]
50
+ defaults:
51
+ name: "User %{label}"
52
+ email: "%{label}@example.com"
53
+ created_at: 2026-01-01 00:00:00 UTC
54
+
55
+ has_many:
56
+ # Mode 1: Full Cartesian Explosion (Default)
57
+ # 12 users * 4 post combinations = 48 posts total
58
+ posts:
59
+ mode: combinations
60
+ matrix:
61
+ visibility: [public, private]
62
+ published: [true, false]
63
+ defaults:
64
+ title: "Post for %{parent.label}"
65
+
66
+ # Mode 2: Fixed Cardinality
67
+ # Each user gets exactly 2 tokens
68
+ tokens:
69
+ mode: count
70
+ count: 2
71
+ defaults:
72
+ token: "tok_%{parent.id}_%{seq}"
73
+
74
+ has_one:
75
+ profile:
76
+ defaults:
77
+ bio: "Bio for %{parent.label}"
78
+ ```
79
+
80
+ ---
81
+
82
+ ## Usage
83
+
84
+ ### 1. Generate In-Memory Hashes
85
+
86
+ ```ruby
87
+ require "astroturf"
88
+
89
+ dataset = Astroturf.load_file("astroturf.yml")
90
+ # or: dataset = Astroturf.load(yaml_string_or_hash)
91
+
92
+ # Access all generated tables and records
93
+ dataset.tables.keys
94
+ # => ["users", "posts", "tokens", "profiles"]
95
+
96
+ # Inspect a single table
97
+ users = dataset["users"]
98
+ # => {
99
+ # "user_admin_active_true" => {
100
+ # "id" => 1,
101
+ # "role" => "admin",
102
+ # "status" => "active",
103
+ # "email_verified" => true,
104
+ # "name" => "User user_admin_active_true",
105
+ # "email" => "user_admin_active_true@example.com",
106
+ # "created_at" => 2026-01-01 00:00:00 UTC
107
+ # },
108
+ # ...
109
+ # }
110
+
111
+ # Total record count
112
+ dataset.total_records
113
+ # => 74
114
+ ```
115
+
116
+ ### 2. Export Rails Fixtures
117
+
118
+ Generate standard Rails fixture YAML strings or write them directly to your test suite:
119
+
120
+ ```ruby
121
+ # Export as a YAML string for a single table
122
+ yaml_output = dataset.to_fixtures("users")
123
+
124
+ # Write .yml fixture files directly to disk
125
+ dataset.write_fixtures!("test/fixtures")
126
+ # Creates:
127
+ # test/fixtures/users.yml
128
+ # test/fixtures/posts.yml
129
+ # test/fixtures/tokens.yml
130
+ # test/fixtures/profiles.yml
131
+ ```
132
+
133
+ ---
134
+
135
+ ## Primary Key Strategies
136
+
137
+ ### Integer Sequences (Default)
138
+
139
+ ```yaml
140
+ users:
141
+ primary_key: integer
142
+ # Or with custom offset and step:
143
+ # primary_key:
144
+ # type: integer
145
+ # start_at: 1000
146
+ # step: 5
147
+ ```
148
+
149
+ ### Deterministic UUIDs
150
+
151
+ Astroturf produces RFC 4122 UUIDv5 values derived from the record's label. Generating the same dataset always produces identical UUIDs.
152
+
153
+ ```yaml
154
+ accounts:
155
+ primary_key: uuid
156
+ matrix:
157
+ plan: [free, pro]
158
+ ```
159
+
160
+ ---
161
+
162
+ ## Running Tests
163
+
164
+ Run the test suite using Rake:
165
+
166
+ ```bash
167
+ bundle exec rake test
168
+ ```
169
+
170
+ Or run tests directly with Ruby:
171
+
172
+ ```bash
173
+ ruby -Ilib -Itest test/astroturf_test.rb
174
+ ruby -Ilib -Itest test/matrix_test.rb
175
+ ruby -Ilib -Itest test/primary_key_test.rb
176
+ ruby -Ilib -Itest test/labeler_test.rb
177
+ ruby -Ilib -Itest test/definition_test.rb
178
+ ruby -Ilib -Itest test/generator_test.rb
179
+ ruby -Ilib -Itest test/dataset_test.rb
180
+ ruby -Ilib -Itest test/inflector_test.rb
181
+ ```
182
+
183
+ ---
184
+
185
+ ## License
186
+
187
+ This project is available as open source under the terms of the [MIT License](LICENSE.txt).
188
+ Authored by Jeff Lunt (<jefflunt@gmail.com>).
data/Rakefile ADDED
@@ -0,0 +1,12 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "rake/testtask"
4
+
5
+ Rake::TestTask.new(:test) do |t|
6
+ t.libs << "test"
7
+ t.libs << "lib"
8
+ t.test_files = FileList["test/**/*_test.rb"]
9
+ t.warning = false
10
+ end
11
+
12
+ task default: :test
data/astroturf.gemspec ADDED
@@ -0,0 +1,27 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "lib/astroturf/version"
4
+
5
+ Gem::Specification.new do |spec|
6
+ spec.name = "astroturf"
7
+ spec.version = Astroturf::VERSION
8
+ spec.authors = ["Jeff Lunt"]
9
+ spec.email = ["jefflunt@gmail.com"]
10
+ spec.summary = "Exhaustive synthetic data generator for Rails and Ruby applications"
11
+ spec.description = "Astroturf generates combinatorial synthetic data graphs in memory from declarative YAML matrix specifications."
12
+ spec.homepage = "https://github.com/elabs/astroturf"
13
+ spec.license = "MIT"
14
+ spec.required_ruby_version = ">= 2.6.0"
15
+
16
+ spec.metadata["source_code_uri"] = spec.homepage
17
+
18
+ spec.files = Dir.chdir(File.expand_path(__dir__)) do
19
+ `git ls-files -z`.split("\x0").reject { |f| f.match(%r{\A(?:test|spec|features)/}) }
20
+ end
21
+ spec.bindir = "exe"
22
+ spec.executables = spec.files.grep(%r{\Aexe/}) { |f| File.basename(f) }
23
+ spec.require_paths = ["lib"]
24
+
25
+ spec.add_development_dependency "minitest", "~> 5.0"
26
+ spec.add_development_dependency "rake", "~> 12.0"
27
+ end
@@ -0,0 +1,57 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "fileutils"
4
+ require "yaml"
5
+
6
+ module Astroturf
7
+ class Dataset
8
+ attr_reader :tables
9
+
10
+ def initialize(tables = {})
11
+ @tables = tables
12
+ end
13
+
14
+ def to_h
15
+ @tables
16
+ end
17
+
18
+ def [](table_name)
19
+ @tables[table_name.to_s] || {}
20
+ end
21
+
22
+ def table_names
23
+ @tables.keys
24
+ end
25
+
26
+ def records(table_name)
27
+ self[table_name].values
28
+ end
29
+
30
+ def total_records
31
+ @tables.values.map(&:size).sum
32
+ end
33
+
34
+ def to_fixtures(table_name = nil)
35
+ if table_name
36
+ data = self[table_name]
37
+ data.empty? ? "{}\n" : YAML.dump(data)
38
+ else
39
+ @tables.each_with_object({}) do |(tbl, data), hash|
40
+ hash[tbl] = data.empty? ? "{}\n" : YAML.dump(data)
41
+ end
42
+ end
43
+ end
44
+
45
+ def write_fixtures!(dir_path)
46
+ FileUtils.mkdir_p(dir_path)
47
+
48
+ written_files = []
49
+ @tables.each do |tbl, data|
50
+ file_path = File.join(dir_path, "#{tbl}.yml")
51
+ File.write(file_path, data.empty? ? "{}\n" : YAML.dump(data))
52
+ written_files << file_path
53
+ end
54
+ written_files
55
+ end
56
+ end
57
+ end
@@ -0,0 +1,98 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "matrix"
4
+ require_relative "primary_key"
5
+ require_relative "inflector"
6
+ require_relative "errors"
7
+
8
+ module Astroturf
9
+ class Definition
10
+ attr_reader :name, :matrix, :defaults, :pk_spec, :associations, :options
11
+
12
+ def self.from_hash(hash)
13
+ raise ConfigurationError, "Astroturf definition must be a Hash" unless hash.is_a?(Hash)
14
+
15
+ hash.map do |entity_name, config|
16
+ new(entity_name, config || {})
17
+ end
18
+ end
19
+
20
+ def initialize(name, config = {})
21
+ @name = name.to_s
22
+ @options = config || {}
23
+ @matrix = Matrix.new(config[:matrix] || config["matrix"] || {})
24
+ @defaults = config[:defaults] || config["defaults"] || {}
25
+ @pk_spec = config[:primary_key] || config["primary_key"] || :integer
26
+ @associations = parse_associations(config)
27
+ end
28
+
29
+ def singular_name
30
+ Inflector.singularize(@name)
31
+ end
32
+
33
+ def table_name
34
+ @options[:table_name] || @options["table_name"] || Inflector.tableize(@name)
35
+ end
36
+
37
+ def foreign_key_name
38
+ @options[:foreign_key] || @options["foreign_key"] || "#{singular_name}_id"
39
+ end
40
+
41
+ private
42
+
43
+ def parse_associations(config)
44
+ associations = []
45
+
46
+ # Parse has_many
47
+ raw_has_many = config[:has_many] || config["has_many"] || {}
48
+ raw_has_many.each do |assoc_name, assoc_config|
49
+ associations << build_association(:has_many, assoc_name, assoc_config)
50
+ end
51
+
52
+ # Parse has_one
53
+ raw_has_one = config[:has_one] || config["has_one"] || {}
54
+ raw_has_one.each do |assoc_name, assoc_config|
55
+ associations << build_association(:has_one, assoc_name, assoc_config)
56
+ end
57
+
58
+ associations
59
+ end
60
+
61
+ def build_association(type, assoc_name, assoc_config)
62
+ assoc_config ||= {}
63
+ mode = (assoc_config[:mode] || assoc_config["mode"] || :combinations).to_sym
64
+ count = assoc_config[:count] || assoc_config["count"]
65
+ foreign_key = assoc_config[:foreign_key] || assoc_config["foreign_key"]
66
+
67
+ Association.new(
68
+ type: type,
69
+ name: assoc_name.to_s,
70
+ definition: Definition.new(assoc_name, assoc_config),
71
+ mode: mode,
72
+ count: count ? count.to_i : nil,
73
+ foreign_key: foreign_key
74
+ )
75
+ end
76
+ end
77
+
78
+ class Association
79
+ attr_reader :type, :name, :definition, :mode, :count, :foreign_key
80
+
81
+ def initialize(type:, name:, definition:, mode: :combinations, count: nil, foreign_key: nil)
82
+ @type = type
83
+ @name = name
84
+ @definition = definition
85
+ @mode = mode
86
+ @count = count
87
+ @foreign_key = foreign_key
88
+ end
89
+
90
+ def has_many?
91
+ @type == :has_many
92
+ end
93
+
94
+ def has_one?
95
+ @type == :has_one
96
+ end
97
+ end
98
+ end
@@ -0,0 +1,8 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Astroturf
4
+ class Error < StandardError; end
5
+ class ConfigurationError < Error; end
6
+ class InvalidMatrixError < Error; end
7
+ class UnknownPrimaryKeyStrategyError < Error; end
8
+ end
@@ -0,0 +1,193 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "dataset"
4
+ require_relative "labeler"
5
+ require_relative "primary_key"
6
+
7
+ module Astroturf
8
+ class Generator
9
+ def self.generate(definitions)
10
+ new(definitions).generate
11
+ end
12
+
13
+ def initialize(definitions)
14
+ @definitions = Array(definitions)
15
+ @tables = Hash.new { |h, k| h[k] = {} }
16
+ @pk_generators = {}
17
+ end
18
+
19
+ def generate
20
+ @definitions.each do |definition|
21
+ generate_root(definition)
22
+ end
23
+
24
+ Dataset.new(@tables)
25
+ end
26
+
27
+ private
28
+
29
+ def pk_generator_for(definition)
30
+ @pk_generators[definition.table_name] ||= PrimaryKey.build(definition.pk_spec)
31
+ end
32
+
33
+ def generate_root(definition)
34
+ combos = definition.matrix.combinations
35
+ pk_gen = pk_generator_for(definition)
36
+
37
+ combos.each_with_index do |combo, index|
38
+ label = Labeler.generate(definition.singular_name, combo)
39
+ pk = pk_gen.next_id(label, combo)
40
+
41
+ attributes = build_attributes(
42
+ definition: definition,
43
+ combo: combo,
44
+ label: label,
45
+ pk: pk,
46
+ sequence: index + 1
47
+ )
48
+
49
+ @tables[definition.table_name][label] = attributes
50
+
51
+ generate_associations(definition, attributes, label, pk)
52
+ end
53
+ end
54
+
55
+ def generate_associations(definition, parent_attrs, parent_label, parent_pk)
56
+ definition.associations.each do |assoc|
57
+ assoc_def = assoc.definition
58
+ pk_gen = pk_generator_for(assoc_def)
59
+ fk_name = assoc.foreign_key || definition.foreign_key_name
60
+ child_name = assoc_def.singular_name
61
+
62
+ if assoc.has_one?
63
+ combo = assoc_def.matrix.combinations.first || {}
64
+ label = Labeler.generate(child_name, combo, parent_label: parent_label)
65
+ pk = pk_gen.next_id(label, combo)
66
+
67
+ child_attrs = build_attributes(
68
+ definition: assoc_def,
69
+ combo: combo,
70
+ label: label,
71
+ pk: pk,
72
+ parent_attrs: parent_attrs,
73
+ parent_label: parent_label,
74
+ parent_pk: parent_pk,
75
+ foreign_key_name: fk_name,
76
+ sequence: 1
77
+ )
78
+
79
+ @tables[assoc_def.table_name][label] = child_attrs
80
+ generate_associations(assoc_def, child_attrs, label, pk)
81
+
82
+ elsif assoc.has_many?
83
+ combos = assoc_def.matrix.combinations
84
+
85
+ if assoc.mode == :count && assoc.count
86
+ combos_to_generate = if combos.empty?
87
+ Array.new(assoc.count) { {} }
88
+ else
89
+ combos.cycle.take(assoc.count)
90
+ end
91
+
92
+ combos_to_generate.each_with_index do |combo, idx|
93
+ seq = idx + 1
94
+ label = Labeler.generate(child_name, combo, parent_label: parent_label, sequence: seq)
95
+ pk = pk_gen.next_id(label, combo)
96
+
97
+ child_attrs = build_attributes(
98
+ definition: assoc_def,
99
+ combo: combo,
100
+ label: label,
101
+ pk: pk,
102
+ parent_attrs: parent_attrs,
103
+ parent_label: parent_label,
104
+ parent_pk: parent_pk,
105
+ foreign_key_name: fk_name,
106
+ sequence: seq
107
+ )
108
+
109
+ @tables[assoc_def.table_name][label] = child_attrs
110
+ generate_associations(assoc_def, child_attrs, label, pk)
111
+ end
112
+ else
113
+ # :combinations mode (Default)
114
+ combos.each_with_index do |combo, idx|
115
+ label = Labeler.generate(child_name, combo, parent_label: parent_label)
116
+ pk = pk_gen.next_id(label, combo)
117
+
118
+ child_attrs = build_attributes(
119
+ definition: assoc_def,
120
+ combo: combo,
121
+ label: label,
122
+ pk: pk,
123
+ parent_attrs: parent_attrs,
124
+ parent_label: parent_label,
125
+ parent_pk: parent_pk,
126
+ foreign_key_name: fk_name,
127
+ sequence: idx + 1
128
+ )
129
+
130
+ @tables[assoc_def.table_name][label] = child_attrs
131
+ generate_associations(assoc_def, child_attrs, label, pk)
132
+ end
133
+ end
134
+ end
135
+ end
136
+ end
137
+
138
+ def build_attributes(definition:, combo:, label:, pk:, sequence:, parent_attrs: nil, parent_label: nil, parent_pk: nil, foreign_key_name: nil)
139
+ context = {
140
+ "id" => pk,
141
+ "label" => label,
142
+ "seq" => sequence
143
+ }
144
+
145
+ if parent_label
146
+ context["parent.label"] = parent_label
147
+ context["parent.id"] = parent_pk
148
+ end
149
+
150
+ # Add combo attributes into context
151
+ combo.each do |k, v|
152
+ context[k.to_s] = v
153
+ end
154
+
155
+ attributes = { "id" => pk }
156
+ attributes[foreign_key_name] = parent_pk if foreign_key_name && parent_pk
157
+
158
+ # Merge defaults with interpolation
159
+ definition.defaults.each do |key, val|
160
+ attributes[key.to_s] = resolve_value(val, context)
161
+ end
162
+
163
+ # Matrix values override defaults
164
+ combo.each do |key, val|
165
+ attributes[key.to_s] = val
166
+ end
167
+
168
+ attributes
169
+ end
170
+
171
+ def resolve_value(val, context)
172
+ case val
173
+ when String
174
+ interpolate(val, context)
175
+ when Proc
176
+ val.arity == 0 ? val.call : val.call(context)
177
+ else
178
+ val
179
+ end
180
+ end
181
+
182
+ def interpolate(template, context)
183
+ template.gsub(/%\{([^}]+)\}/) do
184
+ key = Regexp.last_match(1)
185
+ if context.key?(key)
186
+ context[key].to_s
187
+ else
188
+ "%{#{key}}"
189
+ end
190
+ end
191
+ end
192
+ end
193
+ end
@@ -0,0 +1,109 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Astroturf
4
+ module Inflector
5
+ PLURAL_TO_SINGULAR = [
6
+ [/(quiz)zes$/i, '\1'],
7
+ [/(matr)ices$/i, '\1ix'],
8
+ [/(vert|ind)ices$/i, '\1ex'],
9
+ [/^(ox)en$/i, '\1'],
10
+ [/(alias|status)es$/i, '\1'],
11
+ [/(octop|vir)i$/i, '\1us'],
12
+ [/(cris|ax|test)es$/i, '\1is'],
13
+ [/(shoe)s$/i, '\1'],
14
+ [/(o)es$/i, '\1'],
15
+ [/(bus)es$/i, '\1'],
16
+ [/([m|l])ice$/i, '\1ouse'],
17
+ [/(x|ch|ss|sh)es$/i, '\1'],
18
+ [/(m)ovies$/i, '\1ovie'],
19
+ [/(s)eries$/i, '\1eries'],
20
+ [/([^aeiouy]|qu)ies$/i, '\1y'],
21
+ [/([lr])ves$/i, '\1f'],
22
+ [/(tive)s$/i, '\1'],
23
+ [/(hive)s$/i, '\1'],
24
+ [/(li|wi|kni)ves$/i, '\1fe'],
25
+ [/(shea|loa|lea|thie)ves$/i, '\1f'],
26
+ [/(^analy)ses$/i, '\1sis'],
27
+ [/((a)naly|(b)a|(d)iagno|(p)arenthe|(p)rogno|(s)ynop|(t)he)ses$/i, '\1\2sis'],
28
+ [/([ti])a$/i, '\1um'],
29
+ [/(n)ews$/i, '\1ews'],
30
+ [/(h|j|k|l|m|n|p|q|r|s|t|v|w|x|z)zes$/i, '\1'],
31
+ [/s$/i, ""]
32
+ ].freeze
33
+
34
+ SINGULAR_TO_PLURAL = [
35
+ [/(quiz)$/i, '\1zes'],
36
+ [/^(ox)$/i, '\1en'],
37
+ [/([m|l])ouse$/i, '\1ice'],
38
+ [/(matr|vert|ind)ix|ex$/i, '\1ices'],
39
+ [/(x|ch|ss|sh)$/i, '\1es'],
40
+ [/([^aeiouy]|qu)y$/i, '\1ies'],
41
+ [/(hive)$/i, '\1s'],
42
+ [/(?:([^f])fe|([lr])f)$/i, '\1\2ves'],
43
+ [/sis$/i, "ses"],
44
+ [/([ti])um$/i, '\1a'],
45
+ [/(buffal|tomat)o$/i, '\1oes'],
46
+ [/(bu)s$/i, '\1ses'],
47
+ [/(alias|status)$/i, '\1es'],
48
+ [/(octop|vir)us$/i, '\1i'],
49
+ [/(ax|test)is$/i, '\1es'],
50
+ [/s$/i, "s"],
51
+ [/$/i, "s"]
52
+ ].freeze
53
+
54
+ IRREGULAR = {
55
+ "person" => "people",
56
+ "man" => "men",
57
+ "child" => "children",
58
+ "sex" => "sexes",
59
+ "move" => "moves",
60
+ "zombie" => "zombies"
61
+ }.freeze
62
+
63
+ def self.singularize(word)
64
+ str = word.to_s
65
+ return str if str.empty?
66
+
67
+ IRREGULAR.each do |singular, plural|
68
+ return singular.dup if str.downcase == plural
69
+ end
70
+
71
+ PLURAL_TO_SINGULAR.each do |rule, replacement|
72
+ if str =~ rule
73
+ return str.sub(rule, replacement)
74
+ end
75
+ end
76
+
77
+ str
78
+ end
79
+
80
+ def self.pluralize(word)
81
+ str = word.to_s
82
+ return str if str.empty?
83
+
84
+ IRREGULAR.each do |singular, plural|
85
+ return plural.dup if str.downcase == singular
86
+ end
87
+
88
+ SINGULAR_TO_PLURAL.each do |rule, replacement|
89
+ if str =~ rule
90
+ return str.sub(rule, replacement)
91
+ end
92
+ end
93
+
94
+ "#{str}s"
95
+ end
96
+
97
+ def self.tableize(word)
98
+ pluralize(underscore(word))
99
+ end
100
+
101
+ def self.underscore(word)
102
+ word.to_s
103
+ .gsub(/([A-Z\d]+)([A-Z][a-z])/, '\1_\2')
104
+ .gsub(/([a-z\d])([A-Z])/, '\1_\2')
105
+ .tr("-", "_")
106
+ .downcase
107
+ end
108
+ end
109
+ end
@@ -0,0 +1,51 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Astroturf
4
+ class Labeler
5
+ def self.generate(entity_name, coordinates = {}, parent_label: nil, sequence: nil)
6
+ new.generate(entity_name, coordinates, parent_label: parent_label, sequence: sequence)
7
+ end
8
+
9
+ def generate(entity_name, coordinates = {}, parent_label: nil, sequence: nil)
10
+ parts = []
11
+ parts << sanitize(parent_label) if parent_label && !parent_label.empty?
12
+ parts << sanitize(entity_name)
13
+
14
+ if coordinates && !coordinates.empty?
15
+ coordinates.each do |_key, val|
16
+ parts << sanitize(format_value(val))
17
+ end
18
+ end
19
+
20
+ parts << sequence.to_s if sequence
21
+
22
+ parts.reject(&:empty?).join("_")
23
+ end
24
+
25
+ def sanitize(value)
26
+ str = value.to_s.strip
27
+ # Replace spaces, hyphens, and non-alphanumeric chars with underscore
28
+ str = str.gsub(/[^a-zA-Z0-9]+/, "_")
29
+ # Collapse multiple underscores
30
+ str = str.gsub(/_+/, "_")
31
+ # Strip leading/trailing underscores
32
+ str = str.gsub(/\A_+|_+\z/, "")
33
+ str.downcase
34
+ end
35
+
36
+ private
37
+
38
+ def format_value(val)
39
+ case val
40
+ when nil
41
+ "nil"
42
+ when true
43
+ "true"
44
+ when false
45
+ "false"
46
+ else
47
+ val.to_s
48
+ end
49
+ end
50
+ end
51
+ end
@@ -0,0 +1,59 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "errors"
4
+
5
+ module Astroturf
6
+ class Matrix
7
+ attr_reader :dimensions
8
+
9
+ def self.combinations(dimensions)
10
+ new(dimensions).combinations
11
+ end
12
+
13
+ def initialize(dimensions = {})
14
+ @dimensions = normalize_dimensions(dimensions || {})
15
+ end
16
+
17
+ def combinations
18
+ return [{}] if @dimensions.empty?
19
+
20
+ keys = @dimensions.keys
21
+ value_arrays = @dimensions.values
22
+
23
+ return [] if value_arrays.any?(&:empty?)
24
+
25
+ value_arrays.first.product(*value_arrays[1..-1]).map do |tuple|
26
+ keys.zip(tuple).to_h
27
+ end
28
+ end
29
+
30
+ def size
31
+ return 1 if @dimensions.empty?
32
+ return 0 if @dimensions.values.any?(&:empty?)
33
+
34
+ @dimensions.values.map(&:size).inject(1, :*)
35
+ end
36
+ alias_method :count, :size
37
+
38
+ def empty?
39
+ size.zero?
40
+ end
41
+
42
+ private
43
+
44
+ def normalize_dimensions(raw_dimensions)
45
+ raise InvalidMatrixError, "Matrix dimensions must be a Hash" unless raw_dimensions.is_a?(Hash)
46
+
47
+ raw_dimensions.each_with_object({}) do |(key, values), hash|
48
+ hash[key.to_s] = case values
49
+ when Range
50
+ values.to_a
51
+ when Array
52
+ values
53
+ else
54
+ [values]
55
+ end
56
+ end
57
+ end
58
+ end
59
+ end
@@ -0,0 +1,25 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Astroturf
4
+ module PrimaryKey
5
+ class IntegerSequence
6
+ attr_reader :current, :start_at, :step
7
+
8
+ def initialize(start_at: 1, step: 1)
9
+ @start_at = start_at
10
+ @step = step
11
+ @current = start_at
12
+ end
13
+
14
+ def next_id(_label = nil, _context = {})
15
+ val = @current
16
+ @current += @step
17
+ val
18
+ end
19
+
20
+ def reset!
21
+ @current = @start_at
22
+ end
23
+ end
24
+ end
25
+ end
@@ -0,0 +1,42 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "digest"
4
+
5
+ module Astroturf
6
+ module PrimaryKey
7
+ class Uuid
8
+ DEFAULT_NAMESPACE = "astroturf"
9
+
10
+ attr_reader :namespace
11
+
12
+ def initialize(namespace: DEFAULT_NAMESPACE)
13
+ @namespace = namespace
14
+ @counter = 0
15
+ end
16
+
17
+ def next_id(label = nil, _context = {})
18
+ @counter += 1
19
+ name = label ? "#{label}" : "record_#{@counter}"
20
+ generate_uuid_v5(@namespace, name)
21
+ end
22
+
23
+ def reset!
24
+ @counter = 0
25
+ end
26
+
27
+ private
28
+
29
+ # Generates a standard RFC 4122 compliant UUIDv5 using SHA-1
30
+ def generate_uuid_v5(ns, name)
31
+ hash = Digest::SHA1.hexdigest("#{ns}:#{name}")
32
+ time_low = hash[0...8]
33
+ time_mid = hash[8...12]
34
+ time_hi = ((hash[12...16].to_i(16) & 0x0fff) | 0x5000).to_s(16).rjust(4, "0")
35
+ clock_seq = ((hash[16...20].to_i(16) & 0x3fff) | 0x8000).to_s(16).rjust(4, "0")
36
+ node = hash[20...32]
37
+
38
+ "#{time_low}-#{time_mid}-#{time_hi}-#{clock_seq}-#{node}"
39
+ end
40
+ end
41
+ end
42
+ end
@@ -0,0 +1,63 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "primary_key/integer_sequence"
4
+ require_relative "primary_key/uuid"
5
+ require_relative "errors"
6
+
7
+ module Astroturf
8
+ module PrimaryKey
9
+ def self.build(spec = :integer)
10
+ case spec
11
+ when :integer, "integer", nil
12
+ IntegerSequence.new
13
+ when :uuid, "uuid"
14
+ Uuid.new
15
+ when Hash
16
+ build_from_hash(spec)
17
+ else
18
+ if spec.respond_to?(:next_id)
19
+ spec
20
+ elsif spec.respond_to?(:call)
21
+ CallableAdapter.new(spec)
22
+ else
23
+ raise UnknownPrimaryKeyStrategyError, "Unsupported primary key strategy: #{spec.inspect}"
24
+ end
25
+ end
26
+ end
27
+
28
+ def self.build_from_hash(hash)
29
+ strategy = (hash[:type] || hash["type"] || :integer).to_s
30
+ case strategy
31
+ when "integer"
32
+ start_at = hash[:start_at] || hash["start_at"] || 1
33
+ step = hash[:step] || hash["step"] || 1
34
+ IntegerSequence.new(start_at: start_at.to_i, step: step.to_i)
35
+ when "uuid"
36
+ namespace = hash[:namespace] || hash["namespace"] || Uuid::DEFAULT_NAMESPACE
37
+ Uuid.new(namespace: namespace.to_s)
38
+ else
39
+ raise UnknownPrimaryKeyStrategyError, "Unsupported primary key strategy: #{strategy.inspect}"
40
+ end
41
+ end
42
+
43
+ class CallableAdapter
44
+ def initialize(callable)
45
+ @callable = callable
46
+ end
47
+
48
+ def next_id(label = nil, context = {})
49
+ if @callable.arity == 0
50
+ @callable.call
51
+ elsif @callable.arity == 1
52
+ @callable.call(label)
53
+ else
54
+ @callable.call(label, context)
55
+ end
56
+ end
57
+
58
+ def reset!
59
+ @callable.reset! if @callable.respond_to?(:reset!)
60
+ end
61
+ end
62
+ end
63
+ end
@@ -0,0 +1,5 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Astroturf
4
+ VERSION = "0.1.0"
5
+ end
data/lib/astroturf.rb ADDED
@@ -0,0 +1,39 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "yaml"
4
+ require "date"
5
+ require "time"
6
+ require_relative "astroturf/version"
7
+ require_relative "astroturf/errors"
8
+ require_relative "astroturf/matrix"
9
+ require_relative "astroturf/primary_key"
10
+ require_relative "astroturf/labeler"
11
+ require_relative "astroturf/definition"
12
+ require_relative "astroturf/dataset"
13
+ require_relative "astroturf/generator"
14
+
15
+ module Astroturf
16
+ def self.load_file(path)
17
+ content = File.read(path)
18
+ load(content)
19
+ end
20
+
21
+ def self.load(yaml_or_hash)
22
+ hash = case yaml_or_hash
23
+ when String
24
+ YAML.safe_load(yaml_or_hash, permitted_classes: [Date, Time, Symbol], aliases: true)
25
+ when Hash
26
+ yaml_or_hash
27
+ else
28
+ raise ConfigurationError, "Expected YAML string or Hash, got #{yaml_or_hash.class}"
29
+ end
30
+
31
+ definitions = Definition.from_hash(hash)
32
+ Generator.generate(definitions)
33
+ end
34
+
35
+ class << self
36
+ alias_method :generate, :load
37
+ alias_method :build, :load
38
+ end
39
+ end
metadata ADDED
@@ -0,0 +1,88 @@
1
+ --- !ruby/object:Gem::Specification
2
+ name: astroturf
3
+ version: !ruby/object:Gem::Version
4
+ version: 0.1.0
5
+ platform: ruby
6
+ authors:
7
+ - Jeff Lunt
8
+ bindir: exe
9
+ cert_chain: []
10
+ date: 1980-01-02 00:00:00.000000000 Z
11
+ dependencies:
12
+ - !ruby/object:Gem::Dependency
13
+ name: minitest
14
+ requirement: !ruby/object:Gem::Requirement
15
+ requirements:
16
+ - - "~>"
17
+ - !ruby/object:Gem::Version
18
+ version: '5.0'
19
+ type: :development
20
+ prerelease: false
21
+ version_requirements: !ruby/object:Gem::Requirement
22
+ requirements:
23
+ - - "~>"
24
+ - !ruby/object:Gem::Version
25
+ version: '5.0'
26
+ - !ruby/object:Gem::Dependency
27
+ name: rake
28
+ requirement: !ruby/object:Gem::Requirement
29
+ requirements:
30
+ - - "~>"
31
+ - !ruby/object:Gem::Version
32
+ version: '12.0'
33
+ type: :development
34
+ prerelease: false
35
+ version_requirements: !ruby/object:Gem::Requirement
36
+ requirements:
37
+ - - "~>"
38
+ - !ruby/object:Gem::Version
39
+ version: '12.0'
40
+ description: Astroturf generates combinatorial synthetic data graphs in memory from
41
+ declarative YAML matrix specifications.
42
+ email:
43
+ - jefflunt@gmail.com
44
+ executables: []
45
+ extensions: []
46
+ extra_rdoc_files: []
47
+ files:
48
+ - ".gitignore"
49
+ - Gemfile
50
+ - LICENSE.txt
51
+ - README.md
52
+ - Rakefile
53
+ - astroturf.gemspec
54
+ - lib/astroturf.rb
55
+ - lib/astroturf/dataset.rb
56
+ - lib/astroturf/definition.rb
57
+ - lib/astroturf/errors.rb
58
+ - lib/astroturf/generator.rb
59
+ - lib/astroturf/inflector.rb
60
+ - lib/astroturf/labeler.rb
61
+ - lib/astroturf/matrix.rb
62
+ - lib/astroturf/primary_key.rb
63
+ - lib/astroturf/primary_key/integer_sequence.rb
64
+ - lib/astroturf/primary_key/uuid.rb
65
+ - lib/astroturf/version.rb
66
+ homepage: https://github.com/elabs/astroturf
67
+ licenses:
68
+ - MIT
69
+ metadata:
70
+ source_code_uri: https://github.com/elabs/astroturf
71
+ rdoc_options: []
72
+ require_paths:
73
+ - lib
74
+ required_ruby_version: !ruby/object:Gem::Requirement
75
+ requirements:
76
+ - - ">="
77
+ - !ruby/object:Gem::Version
78
+ version: 2.6.0
79
+ required_rubygems_version: !ruby/object:Gem::Requirement
80
+ requirements:
81
+ - - ">="
82
+ - !ruby/object:Gem::Version
83
+ version: '0'
84
+ requirements: []
85
+ rubygems_version: 4.0.16
86
+ specification_version: 4
87
+ summary: Exhaustive synthetic data generator for Rails and Ruby applications
88
+ test_files: []