synthra 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 +7 -0
- data/.rspec +4 -0
- data/.rubocop.yml +40 -0
- data/.yardopts +18 -0
- data/ACCESS_DOCS.md +116 -0
- data/ADVANCED_FEATURES_SUMMARY.md +245 -0
- data/CHANGELOG.md +498 -0
- data/CODE_OF_CONDUCT.md +122 -0
- data/CONTRIBUTING.md +307 -0
- data/DOCUMENTATION.md +204 -0
- data/DOCUMENTATION_GUIDE.md +194 -0
- data/LICENSE +22 -0
- data/README.md +1458 -0
- data/Rakefile +128 -0
- data/benchmark/README.md +591 -0
- data/benchmark/batch_generation.rb +63 -0
- data/benchmark/engine_comparison.rb +295 -0
- data/benchmark/single_record.rb +108 -0
- data/benchmark/streaming.rb +74 -0
- data/docs/ACTIVERECORD_INFERENCE.md +524 -0
- data/docs/API_SERVER.md +278 -0
- data/docs/CONFIG_FILE.md +315 -0
- data/docs/DATA_CONTRACTS.md +312 -0
- data/docs/ENHANCED_REPL.md +304 -0
- data/docs/FACTORY_BOT.md +271 -0
- data/docs/GITHUB_ACTION.md +420 -0
- data/docs/GRAPHQL_EXPORT.md +303 -0
- data/docs/GRAPHQL_FEDERATION.md +651 -0
- data/docs/HOW_TO_GENERATE_DOCS.md +252 -0
- data/docs/LSP.md +189 -0
- data/docs/MIGRATION_GENERATOR.md +547 -0
- data/docs/MOCK_SERVER.md +280 -0
- data/docs/NATIVE_ENGINE.md +298 -0
- data/docs/OPENAPI_EXPORT.md +252 -0
- data/docs/PERFORMANCE_MODE.md +228 -0
- data/docs/PERSONAS.md +547 -0
- data/docs/PROPERTY_TESTING.md +284 -0
- data/docs/PROTOBUF_EXPORT.md +276 -0
- data/docs/QUALITY_METRICS.md +555 -0
- data/docs/QUICK_REFERENCE.md +88 -0
- data/docs/RAILS_ENGINE.md +404 -0
- data/docs/RAILS_INTEGRATION.md +454 -0
- data/docs/README.md +175 -0
- data/docs/REPL_QUICK_REFERENCE.md +149 -0
- data/docs/RUST_INTEGRATION_GUIDE.md +1376 -0
- data/docs/SCENARIOS.md +576 -0
- data/docs/SECURITY_TESTING.md +318 -0
- data/docs/SNAPSHOT_TESTING.md +308 -0
- data/docs/STRING_TEXT_TYPES_REFERENCE.md +512 -0
- data/docs/TERRAFORM_EXPORT.md +447 -0
- data/docs/TIME_TRAVEL.md +507 -0
- data/docs/USAGE_GUIDE.md +980 -0
- data/docs/WEBHOOK_SIMULATOR.md +579 -0
- data/docs/advanced_features.md +183 -0
- data/docs/api_reference.md +153 -0
- data/docs/behaviors.md +138 -0
- data/docs/best_practices.md +191 -0
- data/docs/dsl_reference.md +172 -0
- data/docs/examples.md +162 -0
- data/docs/generation_modes.md +125 -0
- data/docs/getting_started.md +92 -0
- data/docs/index.html +177 -0
- data/docs/strategic/CLOUD_REGISTRY_ARCHITECTURE.md +268 -0
- data/docs/strategic/SIMD_OPTIMIZATION_STRATEGY.md +153 -0
- data/docs/troubleshooting.md +215 -0
- data/docs/type_reference.md +224 -0
- data/examples/hostile_mode_demo.rb +64 -0
- data/exe/synthra +6 -0
- data/ext/synthra_native/Cargo.lock +972 -0
- data/ext/synthra_native/Cargo.toml +46 -0
- data/ext/synthra_native/extconf.rb +41 -0
- data/ext/synthra_native/src/generator.rs +397 -0
- data/ext/synthra_native/src/lib.rs +229 -0
- data/ext/synthra_native/src/types.rs +1485 -0
- data/lib/generators/synthra/install_generator.rb +86 -0
- data/lib/generators/synthra/templates/api_response.dsl +15 -0
- data/lib/generators/synthra/templates/fake_data.rake +116 -0
- data/lib/generators/synthra/templates/synthra.yml +31 -0
- data/lib/generators/synthra/templates/synthra_support.rb +19 -0
- data/lib/generators/synthra/templates/user.dsl +11 -0
- data/lib/synthra/activerecord_inference.rb +394 -0
- data/lib/synthra/api.rb +381 -0
- data/lib/synthra/api_server.rb +718 -0
- data/lib/synthra/behaviors/applicator.rb +165 -0
- data/lib/synthra/behaviors/base.rb +155 -0
- data/lib/synthra/behaviors/close_connection.rb +60 -0
- data/lib/synthra/behaviors/deprecated.rb +100 -0
- data/lib/synthra/behaviors/failure.rb +69 -0
- data/lib/synthra/behaviors/latency.rb +103 -0
- data/lib/synthra/behaviors/partial_data.rb +83 -0
- data/lib/synthra/behaviors/randomize_order.rb +74 -0
- data/lib/synthra/behaviors/registry.rb +238 -0
- data/lib/synthra/behaviors/simulate_error.rb +79 -0
- data/lib/synthra/cli/commands/base.rb +105 -0
- data/lib/synthra/cli/commands/diff.rb +227 -0
- data/lib/synthra/cli/commands/docs.rb +67 -0
- data/lib/synthra/cli/commands/export.rb +150 -0
- data/lib/synthra/cli/commands/generate.rb +72 -0
- data/lib/synthra/cli/commands/import.rb +80 -0
- data/lib/synthra/cli/commands/lint.rb +53 -0
- data/lib/synthra/cli/commands/live.rb +57 -0
- data/lib/synthra/cli/commands/seed.rb +99 -0
- data/lib/synthra/cli/commands/validate.rb +28 -0
- data/lib/synthra/cli.rb +2471 -0
- data/lib/synthra/config_file.rb +129 -0
- data/lib/synthra/configuration.rb +281 -0
- data/lib/synthra/contracts_registry.rb +408 -0
- data/lib/synthra/database_seeder.rb +268 -0
- data/lib/synthra/deterministic_ids.rb +218 -0
- data/lib/synthra/documentation_generator.rb +414 -0
- data/lib/synthra/engine.rb +251 -0
- data/lib/synthra/errors.rb +1169 -0
- data/lib/synthra/export/base.rb +85 -0
- data/lib/synthra/export/csv.rb +101 -0
- data/lib/synthra/export/graphql.rb +266 -0
- data/lib/synthra/export/graphql_federation.rb +377 -0
- data/lib/synthra/export/graphviz.rb +258 -0
- data/lib/synthra/export/javascript.rb +327 -0
- data/lib/synthra/export/json_data.rb +61 -0
- data/lib/synthra/export/json_schema.rb +290 -0
- data/lib/synthra/export/openapi.rb +514 -0
- data/lib/synthra/export/protobuf.rb +483 -0
- data/lib/synthra/export/python.rb +560 -0
- data/lib/synthra/export/sql.rb +381 -0
- data/lib/synthra/export/sql_insert.rb +152 -0
- data/lib/synthra/export/terraform.rb +501 -0
- data/lib/synthra/export/type_mapping.rb +389 -0
- data/lib/synthra/export/typescript.rb +300 -0
- data/lib/synthra/export/xml_data.rb +104 -0
- data/lib/synthra/export/yaml_data.rb +56 -0
- data/lib/synthra/export.rb +404 -0
- data/lib/synthra/factory_bot_integration.rb +157 -0
- data/lib/synthra/field.rb +440 -0
- data/lib/synthra/functions/registry.rb +104 -0
- data/lib/synthra/generator/context.rb +372 -0
- data/lib/synthra/generator/engine.rb +336 -0
- data/lib/synthra/generator/faker_adapter.rb +425 -0
- data/lib/synthra/generator/modes.rb +444 -0
- data/lib/synthra/generator/resolver.rb +256 -0
- data/lib/synthra/generator/rng.rb +273 -0
- data/lib/synthra/generator/streamer.rb +63 -0
- data/lib/synthra/generator/uniqueness.rb +118 -0
- data/lib/synthra/initializer.rb +97 -0
- data/lib/synthra/limits.rb +279 -0
- data/lib/synthra/live_preview.rb +518 -0
- data/lib/synthra/loader_config.rb +78 -0
- data/lib/synthra/lsp/server.rb +689 -0
- data/lib/synthra/migration_generator.rb +301 -0
- data/lib/synthra/mixin.rb +174 -0
- data/lib/synthra/mock_server.rb +458 -0
- data/lib/synthra/native_engine.rb +304 -0
- data/lib/synthra/openapi_importer.rb +228 -0
- data/lib/synthra/output/json_formatter.rb +40 -0
- data/lib/synthra/output/ndjson_formatter.rb +55 -0
- data/lib/synthra/parser/ast.rb +1287 -0
- data/lib/synthra/parser/lexer.rb +1152 -0
- data/lib/synthra/parser/parser.rb +1664 -0
- data/lib/synthra/parser/tokens.rb +461 -0
- data/lib/synthra/performance_mode.rb +364 -0
- data/lib/synthra/personas.rb +397 -0
- data/lib/synthra/property_testing.rb +172 -0
- data/lib/synthra/quality_metrics.rb +405 -0
- data/lib/synthra/rails_test_helper.rb +248 -0
- data/lib/synthra/registry.rb +533 -0
- data/lib/synthra/relationships.rb +193 -0
- data/lib/synthra/repl/enhanced_repl.rb +607 -0
- data/lib/synthra/repl/formatter.rb +191 -0
- data/lib/synthra/scenarios.rb +423 -0
- data/lib/synthra/schema.rb +605 -0
- data/lib/synthra/schema_inheritance.rb +104 -0
- data/lib/synthra/schema_versioning.rb +212 -0
- data/lib/synthra/snapshot_testing.rb +199 -0
- data/lib/synthra/time_travel.rb +338 -0
- data/lib/synthra/type_definitions.rb +274 -0
- data/lib/synthra/types/address_location/addresses.rb +125 -0
- data/lib/synthra/types/address_location/airports.rb +200 -0
- data/lib/synthra/types/address_location/banks_hospitals.rb +207 -0
- data/lib/synthra/types/address_location/locations.rb +406 -0
- data/lib/synthra/types/base.rb +48 -0
- data/lib/synthra/types/commerce_products/commerce.rb +119 -0
- data/lib/synthra/types/commerce_products/companies.rb +71 -0
- data/lib/synthra/types/commerce_products/construction.rb +126 -0
- data/lib/synthra/types/commerce_products/products.rb +205 -0
- data/lib/synthra/types/core/collections.rb +513 -0
- data/lib/synthra/types/core/defaults.rb +76 -0
- data/lib/synthra/types/core/enums.rb +102 -0
- data/lib/synthra/types/core/identifiers.rb +445 -0
- data/lib/synthra/types/core/primitives.rb +586 -0
- data/lib/synthra/types/core/references.rb +466 -0
- data/lib/synthra/types/core/sequences.rb +304 -0
- data/lib/synthra/types/crypto/crypto.rb +162 -0
- data/lib/synthra/types/date_time/dates.rb +387 -0
- data/lib/synthra/types/finance_banking/banking.rb +424 -0
- data/lib/synthra/types/finance_banking/credit_cards.rb +24 -0
- data/lib/synthra/types/finance_banking/identifiers.rb +93 -0
- data/lib/synthra/types/formula.rb +184 -0
- data/lib/synthra/types/health_medical/medical.rb +150 -0
- data/lib/synthra/types/hostile_payloads.rb +122 -0
- data/lib/synthra/types/json_array.rb +51 -0
- data/lib/synthra/types/media_entertainment/media.rb +62 -0
- data/lib/synthra/types/naughty_string.rb +30 -0
- data/lib/synthra/types/personal_names/chinese.rb +41 -0
- data/lib/synthra/types/personal_names/identifiers.rb +187 -0
- data/lib/synthra/types/personal_names/names.rb +246 -0
- data/lib/synthra/types/personal_names/national_id.rb +89 -0
- data/lib/synthra/types/personal_names/titles_suffixes.rb +41 -0
- data/lib/synthra/types/regex.rb +248 -0
- data/lib/synthra/types/registry.rb +182 -0
- data/lib/synthra/types/repeating_element.rb +50 -0
- data/lib/synthra/types/scenario.rb +29 -0
- data/lib/synthra/types/technology_internet/apps.rb +67 -0
- data/lib/synthra/types/technology_internet/communication.rb +162 -0
- data/lib/synthra/types/technology_internet/devices.rb +80 -0
- data/lib/synthra/types/technology_internet/formats.rb +139 -0
- data/lib/synthra/types/technology_internet/networking.rb +143 -0
- data/lib/synthra/types/template.rb +92 -0
- data/lib/synthra/types/text_content/business.rb +128 -0
- data/lib/synthra/types/text_content/colors.rb +70 -0
- data/lib/synthra/types/text_content/misc.rb +237 -0
- data/lib/synthra/types/text_content/security.rb +130 -0
- data/lib/synthra/types/text_content/text_generation.rb +532 -0
- data/lib/synthra/types/travel/travel.rb +150 -0
- data/lib/synthra/utils/string_distance.rb +133 -0
- data/lib/synthra/validator/dsl_validator.rb +339 -0
- data/lib/synthra/validator/path_validator.rb +615 -0
- data/lib/synthra/version.rb +35 -0
- data/lib/synthra/webhook_simulator.rb +341 -0
- data/lib/synthra.rb +259 -0
- data/schemas/address.dsl +16 -0
- data/schemas/api_response.dsl +8 -0
- data/schemas/error_payload.dsl +9 -0
- data/schemas/order.dsl +10 -0
- data/schemas/order_item.dsl +8 -0
- data/schemas/payment.dsl +11 -0
- data/schemas/social_post.dsl +13 -0
- data/schemas/user.dsl +10 -0
- data/scripts/batch_fix_all.rb +96 -0
- data/scripts/delete_old_files.rb +32 -0
- data/scripts/fix_all_grouped_files.rb +137 -0
- data/scripts/fix_all_indentation.rb +48 -0
- data/scripts/fix_all_syntax.rb +124 -0
- data/scripts/fix_grouped_files.rb +184 -0
- data/scripts/fix_syntax_errors.rb +108 -0
- data/scripts/group_domain_types.rb +112 -0
- data/scripts/group_domain_types_fixed.rb +150 -0
- data/scripts/merge_domains_to_one_file.rb +68 -0
- data/scripts/move_existing_types.rb +130 -0
- data/scripts/split_grouped_types.rb +142 -0
- data/tech_docs/README.md +134 -0
- data/tech_docs/advanced/streaming.md +405 -0
- data/tech_docs/advanced/thread_safety.md +253 -0
- data/tech_docs/api/overview.md +485 -0
- data/tech_docs/appendices/type_chart.md +193 -0
- data/tech_docs/basic_concepts.md +383 -0
- data/tech_docs/behaviors/overview.md +346 -0
- data/tech_docs/dsl/complex_types.md +790 -0
- data/tech_docs/dsl/core_types.md +464 -0
- data/tech_docs/dsl/datetime_types.md +325 -0
- data/tech_docs/dsl/field_modifiers.md +414 -0
- data/tech_docs/dsl/grammar.md +431 -0
- data/tech_docs/dsl/schema_definition.md +399 -0
- data/tech_docs/export/README.md +276 -0
- data/tech_docs/installation.md +273 -0
- data/tech_docs/integration/ci_cd.md +707 -0
- data/tech_docs/integration/ci_cd_guide.md +579 -0
- data/tech_docs/integration/factory_bot.md +485 -0
- data/tech_docs/integration/rails.md +630 -0
- data/tech_docs/integration/rspec.md +449 -0
- data/tech_docs/modes/overview.md +350 -0
- data/tech_docs/performance/NATIVE_RUST_EXTENSION.md +1270 -0
- data/tech_docs/performance/OPTIMIZATION_GUIDE.md +901 -0
- data/tech_docs/quick_start.md +256 -0
- data/tech_docs/templates/README.md +805 -0
- data/tech_docs/tutorials/advanced.md +371 -0
- data/tech_docs/tutorials/getting_started.md +189 -0
- data/tech_docs/tutorials/intermediate.md +231 -0
- data/tech_docs/tutorials/template_gallery.md +569 -0
- data/tech_example/01_basic_usage.rb +238 -0
- data/tech_example/02_types_demo.rb +336 -0
- data/tech_example/04_cli_usage.md +429 -0
- data/tech_example/05_database_seeding.rb +283 -0
- data/tech_example/07_rspec_integration.rb +359 -0
- data/tech_example/10_custom_types.rb +387 -0
- data/tech_example/12_behaviors.rb +372 -0
- data/tech_example/13_twitter_dm_example.rb +120 -0
- data/tech_example/14_exact_json_structure.rb +253 -0
- data/tech_example/16_lsp_server.rb +56 -0
- data/tech_example/17_property_testing.rb +92 -0
- data/tech_example/18_enhanced_repl.rb +120 -0
- data/tech_example/19_security_fuzzing.rb +145 -0
- data/tech_example/NEW_FEATURES.md +169 -0
- data/tech_example/README.md +118 -0
- data/tech_example/schemas/api_response.dsl +123 -0
- data/tech_example/schemas/ecommerce.dsl +121 -0
- data/tech_example/schemas/twitter_dm.dsl +109 -0
- data/tech_example/schemas/user.dsl +36 -0
- data/vscode-extension/README.md +246 -0
- data/vscode-extension/language-configuration.json +31 -0
- data/vscode-extension/package.json +55 -0
- data/vscode-extension/snippets/fakedatadsl.json +198 -0
- data/vscode-extension/syntaxes/fakedatadsl.tmLanguage.json +128 -0
- metadata +478 -0
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "rails/generators"
|
|
4
|
+
|
|
5
|
+
module Synthra
|
|
6
|
+
module Generators
|
|
7
|
+
# Rails generator for setting up Synthra in a Rails application
|
|
8
|
+
#
|
|
9
|
+
# @example Install Synthra
|
|
10
|
+
# rails generate synthra:install
|
|
11
|
+
#
|
|
12
|
+
class InstallGenerator < Rails::Generators::Base
|
|
13
|
+
source_root File.expand_path("templates", __dir__)
|
|
14
|
+
|
|
15
|
+
desc "Sets up Synthra for your Rails application"
|
|
16
|
+
|
|
17
|
+
def create_config_file
|
|
18
|
+
template "synthra.yml", "config/synthra.yml"
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
def create_schemas_directory
|
|
22
|
+
empty_directory "schemas"
|
|
23
|
+
template "user.dsl", "schemas/user.dsl"
|
|
24
|
+
template "api_response.dsl", "schemas/api_response.dsl"
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
def create_generated_directory
|
|
28
|
+
empty_directory "generated"
|
|
29
|
+
empty_directory "generated/types"
|
|
30
|
+
empty_directory "generated/mocks"
|
|
31
|
+
empty_directory "generated/docs"
|
|
32
|
+
|
|
33
|
+
create_file "generated/.gitkeep"
|
|
34
|
+
create_file "generated/types/.gitkeep"
|
|
35
|
+
create_file "generated/mocks/.gitkeep"
|
|
36
|
+
create_file "generated/docs/.gitkeep"
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
def create_rspec_support
|
|
40
|
+
return unless File.exist?("spec/spec_helper.rb")
|
|
41
|
+
|
|
42
|
+
template "synthra_support.rb", "spec/support/synthra.rb"
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
def create_rake_tasks
|
|
46
|
+
template "fake_data.rake", "lib/tasks/fake_data.rake"
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
def add_gitignore_entries
|
|
50
|
+
append_to_file ".gitignore", <<~GITIGNORE
|
|
51
|
+
|
|
52
|
+
# Synthra generated files
|
|
53
|
+
/generated/types/*
|
|
54
|
+
/generated/mocks/*
|
|
55
|
+
/generated/docs/*
|
|
56
|
+
!/generated/*/.gitkeep
|
|
57
|
+
GITIGNORE
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
def show_readme
|
|
61
|
+
say ""
|
|
62
|
+
say "=" * 60, :green
|
|
63
|
+
say " Synthra installed successfully! 🎉", :green
|
|
64
|
+
say "=" * 60, :green
|
|
65
|
+
say ""
|
|
66
|
+
say "Next steps:", :yellow
|
|
67
|
+
say ""
|
|
68
|
+
say " 1. Edit your schemas in schemas/"
|
|
69
|
+
say " 2. Generate TypeScript types:"
|
|
70
|
+
say " rails fake_data:types", :cyan
|
|
71
|
+
say ""
|
|
72
|
+
say " 3. Generate mock data:"
|
|
73
|
+
say " rails fake_data:mocks", :cyan
|
|
74
|
+
say ""
|
|
75
|
+
say " 4. Start live preview server:"
|
|
76
|
+
say " rails fake_data:live", :cyan
|
|
77
|
+
say ""
|
|
78
|
+
say " 5. Run schema linter:"
|
|
79
|
+
say " rails fake_data:lint", :cyan
|
|
80
|
+
say ""
|
|
81
|
+
say "Documentation: https://github.com/talaatmagdyx/synthra"
|
|
82
|
+
say ""
|
|
83
|
+
end
|
|
84
|
+
end
|
|
85
|
+
end
|
|
86
|
+
end
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
# API Response Schema
|
|
2
|
+
# Use this for simulating API responses with behaviors
|
|
3
|
+
|
|
4
|
+
APIResponse:
|
|
5
|
+
@latency 50..200ms # Simulate network latency
|
|
6
|
+
@failure 2% # 2% chance of failure
|
|
7
|
+
|
|
8
|
+
success: boolean(true:95%)
|
|
9
|
+
data: object
|
|
10
|
+
meta:
|
|
11
|
+
page: number(1..100)
|
|
12
|
+
per_page: const(20)
|
|
13
|
+
total: number(0..10000)
|
|
14
|
+
timestamp: now
|
|
15
|
+
request_id: uuid
|
|
@@ -0,0 +1,116 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
# Synthra Rake Tasks
|
|
4
|
+
# Generated by: rails generate synthra:install
|
|
5
|
+
|
|
6
|
+
namespace :fake_data do
|
|
7
|
+
desc "Generate TypeScript types from schemas"
|
|
8
|
+
task types: :environment do
|
|
9
|
+
require "synthra"
|
|
10
|
+
|
|
11
|
+
output_file = "generated/types/api.ts"
|
|
12
|
+
|
|
13
|
+
puts "📝 Generating TypeScript types..."
|
|
14
|
+
system("synthra export --all -d schemas -f typescript -o #{output_file}")
|
|
15
|
+
puts "✅ Generated: #{output_file}"
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
desc "Generate mock JSON data"
|
|
19
|
+
task mocks: :environment do
|
|
20
|
+
require "synthra"
|
|
21
|
+
|
|
22
|
+
registry = Synthra::Registry.new
|
|
23
|
+
registry.load_dir("schemas")
|
|
24
|
+
|
|
25
|
+
puts "📦 Generating mock data..."
|
|
26
|
+
|
|
27
|
+
registry.names.each do |name|
|
|
28
|
+
schema = registry.schema(name)
|
|
29
|
+
output_file = "generated/mocks/#{name.downcase}.json"
|
|
30
|
+
|
|
31
|
+
data = schema.generate_many(50, seed: 42)
|
|
32
|
+
File.write(output_file, JSON.pretty_generate(data))
|
|
33
|
+
|
|
34
|
+
puts " ✅ #{output_file} (50 records)"
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
puts "✅ Mock generation complete!"
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
desc "Generate HTML documentation"
|
|
41
|
+
task docs: :environment do
|
|
42
|
+
require "synthra"
|
|
43
|
+
|
|
44
|
+
puts "📚 Generating documentation..."
|
|
45
|
+
system("synthra docs schemas/ --output generated/docs/")
|
|
46
|
+
puts "✅ Documentation generated: generated/docs/index.html"
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
desc "Start live preview server"
|
|
50
|
+
task live: :environment do
|
|
51
|
+
require "synthra"
|
|
52
|
+
|
|
53
|
+
port = ENV.fetch("PORT", 4567)
|
|
54
|
+
puts "🚀 Starting live preview server on http://localhost:#{port}"
|
|
55
|
+
exec("synthra live schemas/ --port #{port}")
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
desc "Lint schemas for errors"
|
|
59
|
+
task lint: :environment do
|
|
60
|
+
require "synthra"
|
|
61
|
+
|
|
62
|
+
puts "🔍 Linting schemas..."
|
|
63
|
+
system("synthra lint schemas/ --strict")
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
desc "Seed database with fake data"
|
|
67
|
+
task seed: :environment do
|
|
68
|
+
require "synthra"
|
|
69
|
+
|
|
70
|
+
count = ENV.fetch("COUNT", 100).to_i
|
|
71
|
+
|
|
72
|
+
puts "🌱 Seeding database with #{count} records per schema..."
|
|
73
|
+
|
|
74
|
+
Synthra.seed_database do
|
|
75
|
+
# Add your models here:
|
|
76
|
+
# create User, count: count
|
|
77
|
+
# create Product, count: count * 5
|
|
78
|
+
# create Order, count: count * 10
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
puts "✅ Database seeded!"
|
|
82
|
+
end
|
|
83
|
+
|
|
84
|
+
desc "Generate all outputs (types, mocks, docs)"
|
|
85
|
+
task all: [:types, :mocks, :docs] do
|
|
86
|
+
puts ""
|
|
87
|
+
puts "🎉 All outputs generated!"
|
|
88
|
+
puts ""
|
|
89
|
+
puts " TypeScript types: generated/types/api.ts"
|
|
90
|
+
puts " Mock data: generated/mocks/"
|
|
91
|
+
puts " Documentation: generated/docs/index.html"
|
|
92
|
+
end
|
|
93
|
+
|
|
94
|
+
desc "Watch schemas for changes and regenerate"
|
|
95
|
+
task watch: :environment do
|
|
96
|
+
require "synthra"
|
|
97
|
+
|
|
98
|
+
puts "👁 Watching schemas/ for changes..."
|
|
99
|
+
puts " Press Ctrl+C to stop"
|
|
100
|
+
|
|
101
|
+
last_mtime = Dir.glob("schemas/*.dsl").map { |f| File.mtime(f) }.max
|
|
102
|
+
|
|
103
|
+
loop do
|
|
104
|
+
sleep 1
|
|
105
|
+
current_mtime = Dir.glob("schemas/*.dsl").map { |f| File.mtime(f) }.max rescue nil
|
|
106
|
+
|
|
107
|
+
if current_mtime && last_mtime && current_mtime > last_mtime
|
|
108
|
+
puts "♻️ Changes detected, regenerating..."
|
|
109
|
+
Rake::Task["fake_data:all"].execute
|
|
110
|
+
last_mtime = current_mtime
|
|
111
|
+
end
|
|
112
|
+
end
|
|
113
|
+
rescue Interrupt
|
|
114
|
+
puts "\n👋 Stopped watching"
|
|
115
|
+
end
|
|
116
|
+
end
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
# Synthra Configuration
|
|
2
|
+
# https://github.com/talaatmagdyx/synthra
|
|
3
|
+
|
|
4
|
+
# Default generation mode: random, edge, invalid, hostile, mixed
|
|
5
|
+
default_mode: random
|
|
6
|
+
|
|
7
|
+
# Maximum retries for unique field generation
|
|
8
|
+
max_unique_retries: 1000
|
|
9
|
+
|
|
10
|
+
# Resource limits
|
|
11
|
+
limits:
|
|
12
|
+
max_array_size: 100
|
|
13
|
+
max_recursion: 5
|
|
14
|
+
max_text_length: 10000
|
|
15
|
+
max_latency_ms: 5000
|
|
16
|
+
|
|
17
|
+
# Environment-specific overrides
|
|
18
|
+
test:
|
|
19
|
+
default_mode: edge # Use edge cases in tests
|
|
20
|
+
seed: 42 # Deterministic output for CI
|
|
21
|
+
|
|
22
|
+
development:
|
|
23
|
+
default_mode: random
|
|
24
|
+
|
|
25
|
+
staging:
|
|
26
|
+
default_mode: random
|
|
27
|
+
seed: 12345 # Consistent staging data
|
|
28
|
+
|
|
29
|
+
production:
|
|
30
|
+
# Production should NOT use this gem
|
|
31
|
+
# Keep this section empty or commented out
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
# Synthra RSpec Support
|
|
4
|
+
# Generated by: rails generate synthra:install
|
|
5
|
+
|
|
6
|
+
require "synthra"
|
|
7
|
+
require "synthra/rails_test_helper"
|
|
8
|
+
require "synthra/snapshot_testing"
|
|
9
|
+
|
|
10
|
+
RSpec.configure do |config|
|
|
11
|
+
# Include test helpers
|
|
12
|
+
config.include Synthra::RailsTestHelper
|
|
13
|
+
config.include Synthra::SnapshotTesting
|
|
14
|
+
|
|
15
|
+
# Load schemas once before the test suite
|
|
16
|
+
config.before(:suite) do
|
|
17
|
+
Synthra.configure_test_helper!
|
|
18
|
+
end
|
|
19
|
+
end
|
|
@@ -0,0 +1,394 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Synthra
|
|
4
|
+
# ActiveRecord Schema Inference
|
|
5
|
+
#
|
|
6
|
+
# Automatically generates DSL schemas from ActiveRecord models by
|
|
7
|
+
# analyzing column types, validations, and associations.
|
|
8
|
+
#
|
|
9
|
+
# @example Infer schema from model
|
|
10
|
+
# schema = Synthra::ActiveRecordInference.infer(User)
|
|
11
|
+
# schema.generate
|
|
12
|
+
#
|
|
13
|
+
# @example Discover all models
|
|
14
|
+
# Synthra::ActiveRecordInference.discover_all!
|
|
15
|
+
#
|
|
16
|
+
# @example Export inferred schema to DSL file
|
|
17
|
+
# Synthra::ActiveRecordInference.export(User, "schemas/user.dsl")
|
|
18
|
+
#
|
|
19
|
+
class ActiveRecordInference
|
|
20
|
+
# Column type to Synthra type mapping
|
|
21
|
+
COLUMN_TYPE_MAP = {
|
|
22
|
+
# Strings
|
|
23
|
+
string: "text",
|
|
24
|
+
text: "paragraph",
|
|
25
|
+
citext: "text",
|
|
26
|
+
|
|
27
|
+
# Numbers
|
|
28
|
+
integer: "number",
|
|
29
|
+
bigint: "number",
|
|
30
|
+
smallint: "number",
|
|
31
|
+
float: "float",
|
|
32
|
+
decimal: "money",
|
|
33
|
+
numeric: "money",
|
|
34
|
+
|
|
35
|
+
# Boolean
|
|
36
|
+
boolean: "boolean",
|
|
37
|
+
|
|
38
|
+
# Date/Time
|
|
39
|
+
date: "date",
|
|
40
|
+
datetime: "timestamp",
|
|
41
|
+
timestamp: "timestamp",
|
|
42
|
+
time: "time",
|
|
43
|
+
|
|
44
|
+
# Binary
|
|
45
|
+
binary: "binary",
|
|
46
|
+
blob: "binary",
|
|
47
|
+
|
|
48
|
+
# Special
|
|
49
|
+
uuid: "uuid",
|
|
50
|
+
json: "object",
|
|
51
|
+
jsonb: "object",
|
|
52
|
+
hstore: "object",
|
|
53
|
+
array: "array(text)"
|
|
54
|
+
}.freeze
|
|
55
|
+
|
|
56
|
+
# Column name patterns for smart type inference
|
|
57
|
+
NAME_PATTERNS = {
|
|
58
|
+
/\Aemail\z/i => "email",
|
|
59
|
+
/email/i => "email",
|
|
60
|
+
/\Aphone\z/i => "phone",
|
|
61
|
+
/phone|mobile|cell/i => "phone",
|
|
62
|
+
/\Aurl\z/i => "url",
|
|
63
|
+
/url|website|link|href/i => "url",
|
|
64
|
+
/\Aname\z/i => "name",
|
|
65
|
+
/first_name/i => "first_name",
|
|
66
|
+
/last_name/i => "last_name",
|
|
67
|
+
/full_name/i => "full_name",
|
|
68
|
+
/username/i => "username",
|
|
69
|
+
/password/i => "password",
|
|
70
|
+
/\Aaddress\z/i => "address",
|
|
71
|
+
/street/i => "street",
|
|
72
|
+
/city/i => "city",
|
|
73
|
+
/state|province/i => "state",
|
|
74
|
+
/country/i => "country",
|
|
75
|
+
/zip|postal/i => "postal_code",
|
|
76
|
+
/latitude|lat\z/i => "latitude",
|
|
77
|
+
/longitude|lng|lon\z/i => "longitude",
|
|
78
|
+
/ip_address|ip\z/i => "ip",
|
|
79
|
+
/user_agent/i => "user_agent",
|
|
80
|
+
/color/i => "hex_color",
|
|
81
|
+
/avatar|image|photo|picture/i => "url",
|
|
82
|
+
/title/i => "sentence",
|
|
83
|
+
/description|bio|about/i => "paragraph",
|
|
84
|
+
/slug/i => "slug",
|
|
85
|
+
/token|secret|api_key/i => "uuid",
|
|
86
|
+
/amount|price|cost|total/i => "money",
|
|
87
|
+
/count|quantity|qty/i => "number(1..100)",
|
|
88
|
+
/age/i => "number(1..120)",
|
|
89
|
+
/ssn/i => "ssn",
|
|
90
|
+
/credit_card|card_number/i => "credit_card",
|
|
91
|
+
/company|organization/i => "company",
|
|
92
|
+
/job_title|position/i => "job_title"
|
|
93
|
+
}.freeze
|
|
94
|
+
|
|
95
|
+
class << self
|
|
96
|
+
# Infer schema from an ActiveRecord model
|
|
97
|
+
#
|
|
98
|
+
# @param model [Class] ActiveRecord model class
|
|
99
|
+
# @param options [Hash] inference options
|
|
100
|
+
# @option options [Boolean] :include_associations include associations
|
|
101
|
+
# @option options [Boolean] :include_validations infer from validations
|
|
102
|
+
# @option options [Array<String>] :skip_columns columns to skip
|
|
103
|
+
# @return [Schema] generated schema
|
|
104
|
+
#
|
|
105
|
+
def infer(model, **options)
|
|
106
|
+
options = {
|
|
107
|
+
include_associations: true,
|
|
108
|
+
include_validations: true,
|
|
109
|
+
skip_columns: %w[id created_at updated_at]
|
|
110
|
+
}.merge(options)
|
|
111
|
+
|
|
112
|
+
builder = SchemaBuilder.new(model.name)
|
|
113
|
+
|
|
114
|
+
# Process columns
|
|
115
|
+
model.columns.each do |column|
|
|
116
|
+
next if options[:skip_columns].include?(column.name)
|
|
117
|
+
|
|
118
|
+
field_type = infer_column_type(column, model, options)
|
|
119
|
+
field_options = infer_field_options(column, model, options)
|
|
120
|
+
|
|
121
|
+
builder.add_field(column.name, field_type, **field_options)
|
|
122
|
+
end
|
|
123
|
+
|
|
124
|
+
# Process associations
|
|
125
|
+
if options[:include_associations]
|
|
126
|
+
model.reflect_on_all_associations.each do |assoc|
|
|
127
|
+
process_association(builder, assoc)
|
|
128
|
+
end
|
|
129
|
+
end
|
|
130
|
+
|
|
131
|
+
# Process validations
|
|
132
|
+
if options[:include_validations]
|
|
133
|
+
process_validations(builder, model)
|
|
134
|
+
end
|
|
135
|
+
|
|
136
|
+
builder.build
|
|
137
|
+
end
|
|
138
|
+
|
|
139
|
+
# Discover and infer schemas for all ActiveRecord models
|
|
140
|
+
#
|
|
141
|
+
# @param options [Hash] inference options
|
|
142
|
+
# @return [Array<Schema>] list of generated schemas
|
|
143
|
+
#
|
|
144
|
+
def discover_all!(**options)
|
|
145
|
+
# Ensure models are loaded
|
|
146
|
+
Rails.application.eager_load! if defined?(Rails) && Rails.env.development?
|
|
147
|
+
|
|
148
|
+
schemas = []
|
|
149
|
+
|
|
150
|
+
ActiveRecord::Base.descendants.each do |model|
|
|
151
|
+
next if model.abstract_class?
|
|
152
|
+
next if model.name.nil?
|
|
153
|
+
next if model.name.start_with?("ActiveRecord::")
|
|
154
|
+
next if model.name.start_with?("HABTM_")
|
|
155
|
+
|
|
156
|
+
begin
|
|
157
|
+
schema = infer(model, **options)
|
|
158
|
+
Synthra.registry.register_schema(model.name, schema)
|
|
159
|
+
schemas << schema
|
|
160
|
+
rescue StandardError => e
|
|
161
|
+
warn "[Synthra] Could not infer #{model.name}: #{e.message}"
|
|
162
|
+
end
|
|
163
|
+
end
|
|
164
|
+
|
|
165
|
+
schemas
|
|
166
|
+
end
|
|
167
|
+
|
|
168
|
+
# Export inferred schema to a DSL file
|
|
169
|
+
#
|
|
170
|
+
# @param model [Class] ActiveRecord model class
|
|
171
|
+
# @param path [String] output file path
|
|
172
|
+
# @param options [Hash] inference options
|
|
173
|
+
# @return [String] generated DSL content
|
|
174
|
+
#
|
|
175
|
+
def export(model, path = nil, **options)
|
|
176
|
+
schema = infer(model, **options)
|
|
177
|
+
dsl_content = schema_to_dsl(schema)
|
|
178
|
+
|
|
179
|
+
if path
|
|
180
|
+
FileUtils.mkdir_p(File.dirname(path))
|
|
181
|
+
File.write(path, dsl_content)
|
|
182
|
+
end
|
|
183
|
+
|
|
184
|
+
dsl_content
|
|
185
|
+
end
|
|
186
|
+
|
|
187
|
+
# Export all discovered models to DSL files
|
|
188
|
+
#
|
|
189
|
+
# @param output_dir [String] output directory
|
|
190
|
+
# @param options [Hash] inference options
|
|
191
|
+
#
|
|
192
|
+
def export_all(output_dir = "db/schemas", **options)
|
|
193
|
+
FileUtils.mkdir_p(output_dir)
|
|
194
|
+
|
|
195
|
+
discover_all!(**options).each do |schema|
|
|
196
|
+
filename = schema.name.underscore + ".dsl"
|
|
197
|
+
path = File.join(output_dir, filename)
|
|
198
|
+
dsl_content = schema_to_dsl(schema)
|
|
199
|
+
File.write(path, dsl_content)
|
|
200
|
+
end
|
|
201
|
+
end
|
|
202
|
+
|
|
203
|
+
private
|
|
204
|
+
|
|
205
|
+
def infer_column_type(column, model, options)
|
|
206
|
+
# First, check name patterns
|
|
207
|
+
NAME_PATTERNS.each do |pattern, type|
|
|
208
|
+
return type if column.name.match?(pattern)
|
|
209
|
+
end
|
|
210
|
+
|
|
211
|
+
# Check for enums
|
|
212
|
+
if model.defined_enums.key?(column.name)
|
|
213
|
+
values = model.defined_enums[column.name].keys
|
|
214
|
+
return "enum(#{values.join(', ')})"
|
|
215
|
+
end
|
|
216
|
+
|
|
217
|
+
# Check validations for format hints
|
|
218
|
+
if options[:include_validations]
|
|
219
|
+
type_from_validations = infer_from_validations(column, model)
|
|
220
|
+
return type_from_validations if type_from_validations
|
|
221
|
+
end
|
|
222
|
+
|
|
223
|
+
# Fall back to column type mapping
|
|
224
|
+
COLUMN_TYPE_MAP[column.type] || "text"
|
|
225
|
+
end
|
|
226
|
+
|
|
227
|
+
def infer_field_options(column, model, _options)
|
|
228
|
+
opts = {}
|
|
229
|
+
|
|
230
|
+
# Check nullability
|
|
231
|
+
opts[:nullable] = column.null
|
|
232
|
+
|
|
233
|
+
# Check default value
|
|
234
|
+
opts[:default] = column.default if column.default
|
|
235
|
+
|
|
236
|
+
# Check for unique constraint
|
|
237
|
+
if model.validators_on(column.name).any? { |v| v.is_a?(ActiveRecord::Validations::UniquenessValidator) }
|
|
238
|
+
opts[:unique] = true
|
|
239
|
+
end
|
|
240
|
+
|
|
241
|
+
opts
|
|
242
|
+
end
|
|
243
|
+
|
|
244
|
+
def process_association(builder, assoc)
|
|
245
|
+
case assoc.macro
|
|
246
|
+
when :belongs_to
|
|
247
|
+
# Add foreign key reference
|
|
248
|
+
fk_column = assoc.foreign_key
|
|
249
|
+
unless builder.has_field?(fk_column)
|
|
250
|
+
ref_type = "Ref(#{assoc.class_name}.id)"
|
|
251
|
+
builder.add_field(fk_column, ref_type, optional: assoc.options[:optional])
|
|
252
|
+
end
|
|
253
|
+
|
|
254
|
+
when :has_one
|
|
255
|
+
# Record the relationship for potential use
|
|
256
|
+
builder.add_relationship(:has_one, assoc.name, assoc.class_name)
|
|
257
|
+
|
|
258
|
+
when :has_many
|
|
259
|
+
# Record the relationship
|
|
260
|
+
builder.add_relationship(:has_many, assoc.name, assoc.class_name)
|
|
261
|
+
end
|
|
262
|
+
end
|
|
263
|
+
|
|
264
|
+
def process_validations(builder, model)
|
|
265
|
+
model.validators.each do |validator|
|
|
266
|
+
validator.attributes.each do |attr|
|
|
267
|
+
case validator
|
|
268
|
+
when ActiveModel::Validations::PresenceValidator
|
|
269
|
+
builder.set_required(attr.to_s)
|
|
270
|
+
|
|
271
|
+
when ActiveModel::Validations::LengthValidator
|
|
272
|
+
if validator.options[:maximum]
|
|
273
|
+
builder.set_max_length(attr.to_s, validator.options[:maximum])
|
|
274
|
+
end
|
|
275
|
+
if validator.options[:minimum]
|
|
276
|
+
builder.set_min_length(attr.to_s, validator.options[:minimum])
|
|
277
|
+
end
|
|
278
|
+
|
|
279
|
+
when ActiveModel::Validations::InclusionValidator
|
|
280
|
+
if validator.options[:in].is_a?(Array)
|
|
281
|
+
values = validator.options[:in]
|
|
282
|
+
builder.update_type(attr.to_s, "enum(#{values.join(', ')})")
|
|
283
|
+
end
|
|
284
|
+
|
|
285
|
+
when ActiveModel::Validations::NumericalityValidator
|
|
286
|
+
opts = validator.options
|
|
287
|
+
if opts[:greater_than_or_equal_to] && opts[:less_than_or_equal_to]
|
|
288
|
+
min = opts[:greater_than_or_equal_to]
|
|
289
|
+
max = opts[:less_than_or_equal_to]
|
|
290
|
+
builder.update_type(attr.to_s, "number(#{min}..#{max})")
|
|
291
|
+
end
|
|
292
|
+
end
|
|
293
|
+
end
|
|
294
|
+
end
|
|
295
|
+
end
|
|
296
|
+
|
|
297
|
+
def infer_from_validations(column, model)
|
|
298
|
+
model.validators_on(column.name).each do |validator|
|
|
299
|
+
case validator
|
|
300
|
+
when ActiveModel::Validations::FormatValidator
|
|
301
|
+
pattern = validator.options[:with]
|
|
302
|
+
return "email" if pattern.to_s.include?("@")
|
|
303
|
+
return "url" if pattern.to_s.include?("http")
|
|
304
|
+
|
|
305
|
+
when ActiveModel::Validations::InclusionValidator
|
|
306
|
+
if validator.options[:in].is_a?(Array)
|
|
307
|
+
values = validator.options[:in]
|
|
308
|
+
return "enum(#{values.join(', ')})"
|
|
309
|
+
end
|
|
310
|
+
end
|
|
311
|
+
end
|
|
312
|
+
|
|
313
|
+
nil
|
|
314
|
+
end
|
|
315
|
+
|
|
316
|
+
def schema_to_dsl(schema)
|
|
317
|
+
lines = ["#{schema.name}:"]
|
|
318
|
+
|
|
319
|
+
schema.fields.each do |field|
|
|
320
|
+
optional_marker = field.optional? ? "?" : ""
|
|
321
|
+
lines << " #{field.name}#{optional_marker}: #{field.type_name}"
|
|
322
|
+
end
|
|
323
|
+
|
|
324
|
+
lines.join("\n")
|
|
325
|
+
end
|
|
326
|
+
end
|
|
327
|
+
|
|
328
|
+
# Internal schema builder
|
|
329
|
+
class SchemaBuilder
|
|
330
|
+
def initialize(name)
|
|
331
|
+
@name = name
|
|
332
|
+
@fields = {}
|
|
333
|
+
@relationships = []
|
|
334
|
+
end
|
|
335
|
+
|
|
336
|
+
def add_field(name, type, **options)
|
|
337
|
+
@fields[name] = {
|
|
338
|
+
type: type,
|
|
339
|
+
options: options
|
|
340
|
+
}
|
|
341
|
+
end
|
|
342
|
+
|
|
343
|
+
def has_field?(name)
|
|
344
|
+
@fields.key?(name)
|
|
345
|
+
end
|
|
346
|
+
|
|
347
|
+
def update_type(name, type)
|
|
348
|
+
@fields[name][:type] = type if @fields[name]
|
|
349
|
+
end
|
|
350
|
+
|
|
351
|
+
def set_required(name)
|
|
352
|
+
@fields[name][:options][:optional] = false if @fields[name]
|
|
353
|
+
end
|
|
354
|
+
|
|
355
|
+
def set_max_length(name, max)
|
|
356
|
+
return unless @fields[name]
|
|
357
|
+
|
|
358
|
+
current_type = @fields[name][:type]
|
|
359
|
+
if current_type == "text"
|
|
360
|
+
@fields[name][:type] = "text(1..#{max})"
|
|
361
|
+
end
|
|
362
|
+
end
|
|
363
|
+
|
|
364
|
+
def set_min_length(name, min)
|
|
365
|
+
return unless @fields[name]
|
|
366
|
+
|
|
367
|
+
current_type = @fields[name][:type]
|
|
368
|
+
if current_type.start_with?("text")
|
|
369
|
+
max = current_type.match(/\.\.(\d+)/)&.[](1) || 255
|
|
370
|
+
@fields[name][:type] = "text(#{min}..#{max})"
|
|
371
|
+
end
|
|
372
|
+
end
|
|
373
|
+
|
|
374
|
+
def add_relationship(type, name, target)
|
|
375
|
+
@relationships << { type: type, name: name, target: target }
|
|
376
|
+
end
|
|
377
|
+
|
|
378
|
+
def build
|
|
379
|
+
# Create DSL string
|
|
380
|
+
dsl_lines = ["#{@name}:"]
|
|
381
|
+
|
|
382
|
+
@fields.each do |name, config|
|
|
383
|
+
optional = config[:options][:optional] ? "?" : ""
|
|
384
|
+
dsl_lines << " #{name}#{optional}: #{config[:type]}"
|
|
385
|
+
end
|
|
386
|
+
|
|
387
|
+
dsl_string = dsl_lines.join("\n")
|
|
388
|
+
|
|
389
|
+
# Parse and return schema
|
|
390
|
+
Synthra::Parser.parse(dsl_string).first
|
|
391
|
+
end
|
|
392
|
+
end
|
|
393
|
+
end
|
|
394
|
+
end
|