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,408 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "json"
|
|
4
|
+
require "fileutils"
|
|
5
|
+
require "digest"
|
|
6
|
+
require "monitor" # reentrant lock: read methods can be called from within locked write methods
|
|
7
|
+
|
|
8
|
+
module Synthra
|
|
9
|
+
# Data Contracts Registry
|
|
10
|
+
#
|
|
11
|
+
# Centralized registry for versioned schemas with:
|
|
12
|
+
# - Semantic versioning support
|
|
13
|
+
# - Publishing and deprecation
|
|
14
|
+
# - Compatibility checking
|
|
15
|
+
# - Change history tracking
|
|
16
|
+
#
|
|
17
|
+
# @example Publish a schema
|
|
18
|
+
# registry = Synthra::ContractsRegistry.new("contracts/")
|
|
19
|
+
# registry.publish("User", version: "1.0.0", schema: user_schema)
|
|
20
|
+
#
|
|
21
|
+
# @example Check compatibility
|
|
22
|
+
# registry.compatible?("User", "1.0.0", "2.0.0")
|
|
23
|
+
#
|
|
24
|
+
class ContractsRegistry
|
|
25
|
+
attr_reader :base_dir, :contracts
|
|
26
|
+
|
|
27
|
+
# Contract states
|
|
28
|
+
STATES = %i[draft published deprecated retired].freeze
|
|
29
|
+
|
|
30
|
+
# Create a new contracts registry
|
|
31
|
+
#
|
|
32
|
+
# @param base_dir [String] directory for storing contracts
|
|
33
|
+
#
|
|
34
|
+
def initialize(base_dir = "contracts")
|
|
35
|
+
@base_dir = base_dir
|
|
36
|
+
@contracts = {}
|
|
37
|
+
# Monitor (reentrant): read methods now lock too, and the already-locked write methods call
|
|
38
|
+
# them — a plain Mutex would self-deadlock on that nesting.
|
|
39
|
+
@mutex = Monitor.new
|
|
40
|
+
|
|
41
|
+
FileUtils.mkdir_p(@base_dir)
|
|
42
|
+
load_contracts
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
# Publish a new schema version
|
|
46
|
+
#
|
|
47
|
+
# @param name [String] schema name
|
|
48
|
+
# @param version [String] semantic version (e.g., "1.0.0")
|
|
49
|
+
# @param schema [Schema] the schema to publish
|
|
50
|
+
# @param changelog [String] optional changelog entry
|
|
51
|
+
# @return [Hash] published contract metadata
|
|
52
|
+
#
|
|
53
|
+
def publish(name, version:, schema:, changelog: nil)
|
|
54
|
+
@mutex.synchronize do
|
|
55
|
+
key = contract_key(name, version)
|
|
56
|
+
|
|
57
|
+
# Validate version format
|
|
58
|
+
validate_version!(version)
|
|
59
|
+
|
|
60
|
+
# Check if already published
|
|
61
|
+
if @contracts[key] && @contracts[key][:state] == :published
|
|
62
|
+
raise ContractError, "Contract #{key} is already published"
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
# Create contract
|
|
66
|
+
contract = {
|
|
67
|
+
name: name,
|
|
68
|
+
version: version,
|
|
69
|
+
state: :published,
|
|
70
|
+
schema_hash: hash_schema(schema),
|
|
71
|
+
fields: schema.fields.map { |f| field_signature(f) },
|
|
72
|
+
behaviors: schema.behaviors,
|
|
73
|
+
published_at: Time.now.iso8601,
|
|
74
|
+
changelog: changelog,
|
|
75
|
+
dsl_content: schema.respond_to?(:dsl_content) ? schema.dsl_content : nil
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
@contracts[key] = contract
|
|
79
|
+
save_contract(key, contract)
|
|
80
|
+
|
|
81
|
+
# Record history
|
|
82
|
+
record_history(name, version, :published, changelog)
|
|
83
|
+
|
|
84
|
+
contract
|
|
85
|
+
end
|
|
86
|
+
end
|
|
87
|
+
|
|
88
|
+
# Deprecate a schema version
|
|
89
|
+
#
|
|
90
|
+
# @param name [String] schema name
|
|
91
|
+
# @param version [String] version to deprecate
|
|
92
|
+
# @param sunset_date [Date] when the version will be retired
|
|
93
|
+
# @param message [String] deprecation message
|
|
94
|
+
#
|
|
95
|
+
def deprecate(name, version:, sunset_date: nil, message: nil)
|
|
96
|
+
@mutex.synchronize do
|
|
97
|
+
key = contract_key(name, version)
|
|
98
|
+
contract = @contracts[key]
|
|
99
|
+
|
|
100
|
+
raise ContractError, "Contract #{key} not found" unless contract
|
|
101
|
+
|
|
102
|
+
contract[:state] = :deprecated
|
|
103
|
+
contract[:deprecated_at] = Time.now.iso8601
|
|
104
|
+
contract[:sunset_date] = sunset_date&.iso8601
|
|
105
|
+
contract[:deprecation_message] = message
|
|
106
|
+
|
|
107
|
+
save_contract(key, contract)
|
|
108
|
+
record_history(name, version, :deprecated, message)
|
|
109
|
+
end
|
|
110
|
+
end
|
|
111
|
+
|
|
112
|
+
# Retire a schema version (no longer available)
|
|
113
|
+
#
|
|
114
|
+
# @param name [String] schema name
|
|
115
|
+
# @param version [String] version to retire
|
|
116
|
+
#
|
|
117
|
+
def retire(name, version:)
|
|
118
|
+
@mutex.synchronize do
|
|
119
|
+
key = contract_key(name, version)
|
|
120
|
+
contract = @contracts[key]
|
|
121
|
+
|
|
122
|
+
raise ContractError, "Contract #{key} not found" unless contract
|
|
123
|
+
|
|
124
|
+
contract[:state] = :retired
|
|
125
|
+
contract[:retired_at] = Time.now.iso8601
|
|
126
|
+
|
|
127
|
+
save_contract(key, contract)
|
|
128
|
+
record_history(name, version, :retired)
|
|
129
|
+
end
|
|
130
|
+
end
|
|
131
|
+
|
|
132
|
+
# Get a specific contract
|
|
133
|
+
#
|
|
134
|
+
# @param name [String] schema name
|
|
135
|
+
# @param version [String] version (nil for latest)
|
|
136
|
+
# @return [Hash, nil] contract or nil
|
|
137
|
+
#
|
|
138
|
+
def get(name, version: nil)
|
|
139
|
+
@mutex.synchronize do
|
|
140
|
+
if version
|
|
141
|
+
@contracts[contract_key(name, version)]
|
|
142
|
+
else
|
|
143
|
+
latest_version(name)
|
|
144
|
+
end
|
|
145
|
+
end
|
|
146
|
+
end
|
|
147
|
+
|
|
148
|
+
# Get the latest published version
|
|
149
|
+
#
|
|
150
|
+
# @param name [String] schema name
|
|
151
|
+
# @return [Hash, nil] latest contract or nil
|
|
152
|
+
#
|
|
153
|
+
def latest_version(name)
|
|
154
|
+
@mutex.synchronize do
|
|
155
|
+
versions = versions_for(name).select { |v| @contracts[contract_key(name, v)][:state] == :published }
|
|
156
|
+
next nil if versions.empty?
|
|
157
|
+
|
|
158
|
+
sorted = versions.sort_by { |v| Gem::Version.new(v) }
|
|
159
|
+
@contracts[contract_key(name, sorted.last)]
|
|
160
|
+
end
|
|
161
|
+
end
|
|
162
|
+
|
|
163
|
+
# Get all versions for a schema
|
|
164
|
+
#
|
|
165
|
+
# @param name [String] schema name
|
|
166
|
+
# @return [Array<String>] versions
|
|
167
|
+
#
|
|
168
|
+
def versions_for(name)
|
|
169
|
+
@mutex.synchronize do
|
|
170
|
+
@contracts.keys
|
|
171
|
+
.select { |k| k.start_with?("#{name}@") }
|
|
172
|
+
.map { |k| k.split("@").last }
|
|
173
|
+
.sort_by { |v| Gem::Version.new(v) rescue v }
|
|
174
|
+
end
|
|
175
|
+
end
|
|
176
|
+
|
|
177
|
+
# Check if two versions are compatible
|
|
178
|
+
#
|
|
179
|
+
# @param name [String] schema name
|
|
180
|
+
# @param old_version [String] old version
|
|
181
|
+
# @param new_version [String] new version
|
|
182
|
+
# @return [Hash] compatibility report
|
|
183
|
+
#
|
|
184
|
+
def compatible?(name, old_version, new_version)
|
|
185
|
+
@mutex.synchronize do
|
|
186
|
+
old_contract = @contracts[contract_key(name, old_version)]
|
|
187
|
+
new_contract = @contracts[contract_key(name, new_version)]
|
|
188
|
+
|
|
189
|
+
raise ContractError, "Contract #{name}@#{old_version} not found" unless old_contract
|
|
190
|
+
raise ContractError, "Contract #{name}@#{new_version} not found" unless new_contract
|
|
191
|
+
|
|
192
|
+
analyze_compatibility(old_contract, new_contract)
|
|
193
|
+
end
|
|
194
|
+
end
|
|
195
|
+
|
|
196
|
+
# List all contracts
|
|
197
|
+
#
|
|
198
|
+
# @param state [Symbol] filter by state (nil for all)
|
|
199
|
+
# @return [Array<Hash>] contracts
|
|
200
|
+
#
|
|
201
|
+
def list(state: nil)
|
|
202
|
+
@mutex.synchronize do
|
|
203
|
+
contracts = @contracts.values
|
|
204
|
+
contracts = contracts.select { |c| c[:state] == state } if state
|
|
205
|
+
contracts.sort_by { |c| [c[:name], Gem::Version.new(c[:version])] }
|
|
206
|
+
end
|
|
207
|
+
end
|
|
208
|
+
|
|
209
|
+
# List all unique schema names
|
|
210
|
+
#
|
|
211
|
+
# @return [Array<String>] schema names
|
|
212
|
+
#
|
|
213
|
+
def schema_names
|
|
214
|
+
@mutex.synchronize { @contracts.values.map { |c| c[:name] }.uniq.sort }
|
|
215
|
+
end
|
|
216
|
+
|
|
217
|
+
# Get change history for a schema
|
|
218
|
+
#
|
|
219
|
+
# @param name [String] schema name
|
|
220
|
+
# @return [Array<Hash>] history entries
|
|
221
|
+
#
|
|
222
|
+
def history(name)
|
|
223
|
+
@mutex.synchronize do
|
|
224
|
+
history_file = File.join(@base_dir, "history", "#{name}.json")
|
|
225
|
+
next [] unless File.exist?(history_file)
|
|
226
|
+
|
|
227
|
+
JSON.parse(File.read(history_file), symbolize_names: true)
|
|
228
|
+
end
|
|
229
|
+
end
|
|
230
|
+
|
|
231
|
+
# Validate a schema against a published contract
|
|
232
|
+
#
|
|
233
|
+
# @param name [String] schema name
|
|
234
|
+
# @param version [String] version
|
|
235
|
+
# @param schema [Schema] schema to validate
|
|
236
|
+
# @return [Hash] validation result
|
|
237
|
+
#
|
|
238
|
+
def validate(name, version:, schema:)
|
|
239
|
+
@mutex.synchronize do
|
|
240
|
+
contract = @contracts[contract_key(name, version)]
|
|
241
|
+
raise ContractError, "Contract #{name}@#{version} not found" unless contract
|
|
242
|
+
|
|
243
|
+
current_fields = schema.fields.map { |f| field_signature(f) }
|
|
244
|
+
contract_fields = contract[:fields]
|
|
245
|
+
|
|
246
|
+
{
|
|
247
|
+
valid: current_fields == contract_fields,
|
|
248
|
+
hash_match: hash_schema(schema) == contract[:schema_hash],
|
|
249
|
+
missing_fields: contract_fields - current_fields,
|
|
250
|
+
extra_fields: current_fields - contract_fields
|
|
251
|
+
}
|
|
252
|
+
end
|
|
253
|
+
end
|
|
254
|
+
|
|
255
|
+
# Export all contracts to a directory
|
|
256
|
+
#
|
|
257
|
+
# @param output_dir [String] output directory
|
|
258
|
+
#
|
|
259
|
+
def export(output_dir)
|
|
260
|
+
FileUtils.mkdir_p(output_dir)
|
|
261
|
+
|
|
262
|
+
@mutex.synchronize do
|
|
263
|
+
@contracts.each do |key, contract|
|
|
264
|
+
File.write(
|
|
265
|
+
File.join(output_dir, "#{key.gsub('@', '_v')}.json"),
|
|
266
|
+
JSON.pretty_generate(contract)
|
|
267
|
+
)
|
|
268
|
+
end
|
|
269
|
+
end
|
|
270
|
+
end
|
|
271
|
+
|
|
272
|
+
# Import contracts from a directory
|
|
273
|
+
#
|
|
274
|
+
# @param input_dir [String] input directory
|
|
275
|
+
#
|
|
276
|
+
def import(input_dir)
|
|
277
|
+
@mutex.synchronize do
|
|
278
|
+
Dir.glob(File.join(input_dir, "*.json")).each do |file|
|
|
279
|
+
contract = JSON.parse(File.read(file), symbolize_names: true)
|
|
280
|
+
key = contract_key(contract[:name], contract[:version])
|
|
281
|
+
@contracts[key] = contract
|
|
282
|
+
save_contract(key, contract)
|
|
283
|
+
end
|
|
284
|
+
end
|
|
285
|
+
end
|
|
286
|
+
|
|
287
|
+
private
|
|
288
|
+
|
|
289
|
+
def contract_key(name, version)
|
|
290
|
+
"#{name}@#{version}"
|
|
291
|
+
end
|
|
292
|
+
|
|
293
|
+
def validate_version!(version)
|
|
294
|
+
# \A..\z (not ^..$): ^/$ are line anchors in Ruby, so "1.0.0\n../../evil" would pass and then
|
|
295
|
+
# be used as a path fragment. \A..\z anchor the whole string and reject the embedded newline.
|
|
296
|
+
unless version.match?(/\A\d+\.\d+\.\d+(-[a-zA-Z0-9.]+)?\z/)
|
|
297
|
+
raise ContractError, "Invalid version format: #{version}. Use semantic versioning (e.g., 1.0.0)"
|
|
298
|
+
end
|
|
299
|
+
end
|
|
300
|
+
|
|
301
|
+
def hash_schema(schema)
|
|
302
|
+
content = schema.fields.map { |f| "#{f.name}:#{f.type_name}:#{f.optional?}:#{f.nullable?}" }.join("|")
|
|
303
|
+
Digest::SHA256.hexdigest(content)[0, 16]
|
|
304
|
+
end
|
|
305
|
+
|
|
306
|
+
def field_signature(field)
|
|
307
|
+
{
|
|
308
|
+
name: field.name,
|
|
309
|
+
type: field.type_name,
|
|
310
|
+
optional: field.optional?,
|
|
311
|
+
nullable: field.nullable?,
|
|
312
|
+
args_hash: Digest::MD5.hexdigest(field.type_args.to_s)[0, 8]
|
|
313
|
+
}
|
|
314
|
+
end
|
|
315
|
+
|
|
316
|
+
def load_contracts
|
|
317
|
+
contracts_dir = File.join(@base_dir, "schemas")
|
|
318
|
+
return unless Dir.exist?(contracts_dir)
|
|
319
|
+
|
|
320
|
+
Dir.glob(File.join(contracts_dir, "*.json")).each do |file|
|
|
321
|
+
contract = JSON.parse(File.read(file), symbolize_names: true)
|
|
322
|
+
key = contract_key(contract[:name], contract[:version])
|
|
323
|
+
@contracts[key] = contract
|
|
324
|
+
end
|
|
325
|
+
end
|
|
326
|
+
|
|
327
|
+
def save_contract(key, contract)
|
|
328
|
+
contracts_dir = File.join(@base_dir, "schemas")
|
|
329
|
+
FileUtils.mkdir_p(contracts_dir)
|
|
330
|
+
|
|
331
|
+
filename = key.gsub("@", "_v") + ".json"
|
|
332
|
+
File.write(File.join(contracts_dir, filename), JSON.pretty_generate(contract))
|
|
333
|
+
end
|
|
334
|
+
|
|
335
|
+
def record_history(name, version, action, message = nil)
|
|
336
|
+
history_dir = File.join(@base_dir, "history")
|
|
337
|
+
FileUtils.mkdir_p(history_dir)
|
|
338
|
+
|
|
339
|
+
history_file = File.join(history_dir, "#{name}.json")
|
|
340
|
+
history = File.exist?(history_file) ? JSON.parse(File.read(history_file)) : []
|
|
341
|
+
|
|
342
|
+
history << {
|
|
343
|
+
"version" => version,
|
|
344
|
+
"action" => action.to_s,
|
|
345
|
+
"message" => message,
|
|
346
|
+
"timestamp" => Time.now.iso8601
|
|
347
|
+
}
|
|
348
|
+
|
|
349
|
+
File.write(history_file, JSON.pretty_generate(history))
|
|
350
|
+
end
|
|
351
|
+
|
|
352
|
+
def analyze_compatibility(old_contract, new_contract)
|
|
353
|
+
old_fields = old_contract[:fields].map { |f| f[:name] }
|
|
354
|
+
new_fields = new_contract[:fields].map { |f| f[:name] }
|
|
355
|
+
|
|
356
|
+
removed = old_fields - new_fields
|
|
357
|
+
added = new_fields - old_fields
|
|
358
|
+
|
|
359
|
+
# Check type changes
|
|
360
|
+
type_changes = []
|
|
361
|
+
(old_fields & new_fields).each do |name|
|
|
362
|
+
old_field = old_contract[:fields].find { |f| f[:name] == name }
|
|
363
|
+
new_field = new_contract[:fields].find { |f| f[:name] == name }
|
|
364
|
+
|
|
365
|
+
if old_field[:type] != new_field[:type]
|
|
366
|
+
type_changes << { field: name, from: old_field[:type], to: new_field[:type] }
|
|
367
|
+
end
|
|
368
|
+
end
|
|
369
|
+
|
|
370
|
+
# Check required changes
|
|
371
|
+
required_changes = []
|
|
372
|
+
(old_fields & new_fields).each do |name|
|
|
373
|
+
old_field = old_contract[:fields].find { |f| f[:name] == name }
|
|
374
|
+
new_field = new_contract[:fields].find { |f| f[:name] == name }
|
|
375
|
+
|
|
376
|
+
if old_field[:optional] && !new_field[:optional]
|
|
377
|
+
required_changes << { field: name, change: "optional → required" }
|
|
378
|
+
end
|
|
379
|
+
end
|
|
380
|
+
|
|
381
|
+
breaking = removed.any? || type_changes.any? || required_changes.any?
|
|
382
|
+
|
|
383
|
+
{
|
|
384
|
+
compatible: !breaking,
|
|
385
|
+
breaking: breaking,
|
|
386
|
+
changes: {
|
|
387
|
+
removed_fields: removed,
|
|
388
|
+
added_fields: added,
|
|
389
|
+
type_changes: type_changes,
|
|
390
|
+
required_changes: required_changes
|
|
391
|
+
},
|
|
392
|
+
summary: generate_compatibility_summary(removed, added, type_changes, required_changes)
|
|
393
|
+
}
|
|
394
|
+
end
|
|
395
|
+
|
|
396
|
+
def generate_compatibility_summary(removed, added, type_changes, required_changes)
|
|
397
|
+
parts = []
|
|
398
|
+
parts << "Removed fields: #{removed.join(', ')}" if removed.any?
|
|
399
|
+
parts << "Added fields: #{added.join(', ')}" if added.any?
|
|
400
|
+
type_changes.each { |c| parts << "Type changed: #{c[:field]} (#{c[:from]} → #{c[:to]})" }
|
|
401
|
+
required_changes.each { |c| parts << "Required changed: #{c[:field]} (#{c[:change]})" }
|
|
402
|
+
parts.empty? ? "No changes" : parts.join("; ")
|
|
403
|
+
end
|
|
404
|
+
end
|
|
405
|
+
|
|
406
|
+
# Contract error
|
|
407
|
+
class ContractError < StandardError; end
|
|
408
|
+
end
|
|
@@ -0,0 +1,268 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "ostruct" # OpenStruct is used below; not a default gem on Ruby 3.5+/4.0, so require it.
|
|
4
|
+
|
|
5
|
+
module Synthra
|
|
6
|
+
# Database Seeder with Foreign Key Awareness
|
|
7
|
+
#
|
|
8
|
+
# Intelligently seeds databases respecting foreign key constraints
|
|
9
|
+
# and relationships between tables.
|
|
10
|
+
#
|
|
11
|
+
# @example Basic usage
|
|
12
|
+
# Synthra.seed_database do
|
|
13
|
+
# create User, count: 100
|
|
14
|
+
# create Order, count: 500 # Links to Users
|
|
15
|
+
# create OrderItem, count: 2000 # Links to Orders
|
|
16
|
+
# end
|
|
17
|
+
#
|
|
18
|
+
# @example With options
|
|
19
|
+
# Synthra.seed_database(truncate: true) do
|
|
20
|
+
# create User, count: 50, mode: :edge
|
|
21
|
+
# end
|
|
22
|
+
#
|
|
23
|
+
class DatabaseSeeder
|
|
24
|
+
attr_reader :registry, :created_records, :options
|
|
25
|
+
|
|
26
|
+
# Create a new seeder
|
|
27
|
+
#
|
|
28
|
+
# @param registry [Registry] schema registry
|
|
29
|
+
# @param options [Hash] seeder options
|
|
30
|
+
# @option options [Boolean] :truncate Truncate tables before seeding
|
|
31
|
+
# @option options [Boolean] :disable_callbacks Disable ActiveRecord callbacks
|
|
32
|
+
# @option options [Integer] :batch_size Batch insert size (default: 1000)
|
|
33
|
+
#
|
|
34
|
+
def initialize(registry: nil, **options)
|
|
35
|
+
@registry = registry || Synthra.instance_variable_get(:@default_registry) || Registry.new
|
|
36
|
+
@options = {
|
|
37
|
+
truncate: false,
|
|
38
|
+
disable_callbacks: false,
|
|
39
|
+
batch_size: 1000
|
|
40
|
+
}.merge(options)
|
|
41
|
+
@created_records = Hash.new { |h, k| h[k] = [] }
|
|
42
|
+
@foreign_key_cache = {}
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
# Execute seeding with DSL
|
|
46
|
+
#
|
|
47
|
+
# @yield seeding DSL block
|
|
48
|
+
#
|
|
49
|
+
def seed(&block)
|
|
50
|
+
refuse_in_production!
|
|
51
|
+
setup_environment
|
|
52
|
+
instance_eval(&block)
|
|
53
|
+
|
|
54
|
+
summary
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
# Create records for a model
|
|
58
|
+
#
|
|
59
|
+
# @param model [Class] ActiveRecord model class
|
|
60
|
+
# @param count [Integer] number of records
|
|
61
|
+
# @param mode [Symbol] generation mode
|
|
62
|
+
# @param overrides [Hash] field overrides
|
|
63
|
+
#
|
|
64
|
+
def create(model, count:, mode: :random, **overrides)
|
|
65
|
+
schema_name = model.respond_to?(:name) ? model.name : model.to_s
|
|
66
|
+
schema = find_or_infer_schema(schema_name, model)
|
|
67
|
+
|
|
68
|
+
puts "Creating #{count} #{schema_name} records..."
|
|
69
|
+
|
|
70
|
+
records = []
|
|
71
|
+
count.times do
|
|
72
|
+
data = generate_with_fk_resolution(schema, model, mode, overrides)
|
|
73
|
+
records << data
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
# Batch insert for performance — also the path for disable_callbacks: insert_all skips AR
|
|
77
|
+
# callbacks at the DB layer per-model, so we never mutate the global ActiveRecord::Base chain.
|
|
78
|
+
if model.respond_to?(:insert_all) && (@options[:batch_size] > 1 || @options[:disable_callbacks])
|
|
79
|
+
insert_in_batches(model, records)
|
|
80
|
+
else
|
|
81
|
+
records.each do |data|
|
|
82
|
+
record = model.create!(data)
|
|
83
|
+
@created_records[schema_name] << record
|
|
84
|
+
end
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
puts " ✓ Created #{count} #{schema_name} records"
|
|
88
|
+
end
|
|
89
|
+
|
|
90
|
+
# Create a single record and return it
|
|
91
|
+
#
|
|
92
|
+
# @param model [Class] ActiveRecord model
|
|
93
|
+
# @param mode [Symbol] generation mode
|
|
94
|
+
# @param overrides [Hash] field overrides
|
|
95
|
+
# @return [Object] created record
|
|
96
|
+
#
|
|
97
|
+
def create_one(model, mode: :random, **overrides)
|
|
98
|
+
schema_name = model.name
|
|
99
|
+
schema = find_or_infer_schema(schema_name, model)
|
|
100
|
+
|
|
101
|
+
data = generate_with_fk_resolution(schema, model, mode, overrides)
|
|
102
|
+
record = model.create!(data)
|
|
103
|
+
@created_records[schema_name] << record
|
|
104
|
+
|
|
105
|
+
record
|
|
106
|
+
end
|
|
107
|
+
|
|
108
|
+
# Get a random record from already created records
|
|
109
|
+
#
|
|
110
|
+
# @param model [Class] model class
|
|
111
|
+
# @return [Object, nil] random record or nil
|
|
112
|
+
#
|
|
113
|
+
def random_record(model)
|
|
114
|
+
records = @created_records[model.name]
|
|
115
|
+
records.sample if records.any?
|
|
116
|
+
end
|
|
117
|
+
|
|
118
|
+
# Get the ID of a random record
|
|
119
|
+
#
|
|
120
|
+
# @param model [Class] model class
|
|
121
|
+
# @return [Object, nil] random ID or nil
|
|
122
|
+
#
|
|
123
|
+
def random_id(model)
|
|
124
|
+
record = random_record(model)
|
|
125
|
+
record&.id
|
|
126
|
+
end
|
|
127
|
+
|
|
128
|
+
private
|
|
129
|
+
|
|
130
|
+
# SECURITY: the seeder truncates tables and (with disable_callbacks) flips
|
|
131
|
+
# ActiveRecord callbacks process-wide — catastrophic in production, especially
|
|
132
|
+
# multi-tenant where suppressed before_create callbacks (tenant scoping, audit)
|
|
133
|
+
# affect every concurrent request. Refuse to run in the production Rails env
|
|
134
|
+
# unless an operator explicitly overrides. Fail closed.
|
|
135
|
+
def refuse_in_production!
|
|
136
|
+
return unless defined?(Rails) && Rails.respond_to?(:env) && Rails.env.production?
|
|
137
|
+
return if ENV["ALLOW_PRODUCTION_SEED"] == "true"
|
|
138
|
+
|
|
139
|
+
raise Synthra::Errors::SecurityError,
|
|
140
|
+
"DatabaseSeeder will not run in production: it truncates tables and disables " \
|
|
141
|
+
"ActiveRecord callbacks process-wide. Set ALLOW_PRODUCTION_SEED=true to override."
|
|
142
|
+
end
|
|
143
|
+
|
|
144
|
+
def setup_environment
|
|
145
|
+
truncate_tables if @options[:truncate] && defined?(ActiveRecord)
|
|
146
|
+
# disable_callbacks is now honoured per-model via insert_all in #create (skips callbacks at the
|
|
147
|
+
# DB layer). We no longer call ActiveRecord::Base.skip_callback: mutating the global callback
|
|
148
|
+
# chain suppresses callbacks (tenant scoping, audit, webhooks) for every concurrent request.
|
|
149
|
+
end
|
|
150
|
+
|
|
151
|
+
def truncate_tables
|
|
152
|
+
return unless defined?(ActiveRecord)
|
|
153
|
+
|
|
154
|
+
refuse_in_production! # defense in depth — never TRUNCATE prod tables
|
|
155
|
+
|
|
156
|
+
# Disable foreign key checks temporarily
|
|
157
|
+
connection = ActiveRecord::Base.connection
|
|
158
|
+
|
|
159
|
+
case connection.adapter_name
|
|
160
|
+
when "PostgreSQL"
|
|
161
|
+
connection.execute("SET CONSTRAINTS ALL DEFERRED")
|
|
162
|
+
when "MySQL", "Mysql2"
|
|
163
|
+
connection.execute("SET FOREIGN_KEY_CHECKS = 0")
|
|
164
|
+
end
|
|
165
|
+
|
|
166
|
+
@created_records.keys.each do |table|
|
|
167
|
+
connection.truncate(table.tableize) rescue nil
|
|
168
|
+
end
|
|
169
|
+
|
|
170
|
+
case connection.adapter_name
|
|
171
|
+
when "PostgreSQL"
|
|
172
|
+
connection.execute("SET CONSTRAINTS ALL IMMEDIATE")
|
|
173
|
+
when "MySQL", "Mysql2"
|
|
174
|
+
connection.execute("SET FOREIGN_KEY_CHECKS = 1")
|
|
175
|
+
end
|
|
176
|
+
end
|
|
177
|
+
|
|
178
|
+
def find_or_infer_schema(name, model)
|
|
179
|
+
if @registry.schema?(name)
|
|
180
|
+
@registry.schema(name)
|
|
181
|
+
elsif model.respond_to?(:columns)
|
|
182
|
+
dsl = SchemaInference.from_active_record(model)
|
|
183
|
+
Synthra.parse(dsl)
|
|
184
|
+
else
|
|
185
|
+
raise ArgumentError, "No schema found for #{name} and model doesn't support inference"
|
|
186
|
+
end
|
|
187
|
+
end
|
|
188
|
+
|
|
189
|
+
def generate_with_fk_resolution(schema, model, mode, overrides)
|
|
190
|
+
# Detect foreign keys
|
|
191
|
+
fk_overrides = resolve_foreign_keys(model)
|
|
192
|
+
|
|
193
|
+
# Generate data
|
|
194
|
+
data = schema.generate(mode: mode, overrides: overrides.merge(fk_overrides))
|
|
195
|
+
|
|
196
|
+
# Filter to only include valid columns
|
|
197
|
+
filter_columns(model, data)
|
|
198
|
+
end
|
|
199
|
+
|
|
200
|
+
def resolve_foreign_keys(model)
|
|
201
|
+
return {} unless model.respond_to?(:reflect_on_all_associations)
|
|
202
|
+
|
|
203
|
+
fk_overrides = {}
|
|
204
|
+
|
|
205
|
+
model.reflect_on_all_associations(:belongs_to).each do |assoc|
|
|
206
|
+
fk_column = assoc.foreign_key
|
|
207
|
+
related_model = assoc.klass rescue next
|
|
208
|
+
|
|
209
|
+
# Get a random ID from created records
|
|
210
|
+
if (id = random_id(related_model))
|
|
211
|
+
fk_overrides[fk_column] = id
|
|
212
|
+
end
|
|
213
|
+
end
|
|
214
|
+
|
|
215
|
+
fk_overrides
|
|
216
|
+
end
|
|
217
|
+
|
|
218
|
+
def filter_columns(model, data)
|
|
219
|
+
return data unless model.respond_to?(:column_names)
|
|
220
|
+
|
|
221
|
+
data.select { |k, _| model.column_names.include?(k.to_s) }
|
|
222
|
+
end
|
|
223
|
+
|
|
224
|
+
def insert_in_batches(model, records)
|
|
225
|
+
batch_size = @options[:batch_size]
|
|
226
|
+
|
|
227
|
+
records.each_slice(batch_size) do |batch|
|
|
228
|
+
model.insert_all(batch)
|
|
229
|
+
end
|
|
230
|
+
|
|
231
|
+
# Store records for FK resolution (simplified - just IDs)
|
|
232
|
+
schema_name = model.name
|
|
233
|
+
records.each do |data|
|
|
234
|
+
@created_records[schema_name] << OpenStruct.new(data)
|
|
235
|
+
end
|
|
236
|
+
end
|
|
237
|
+
|
|
238
|
+
def summary
|
|
239
|
+
total = @created_records.values.flatten.count
|
|
240
|
+
|
|
241
|
+
puts "\n" + "=" * 50
|
|
242
|
+
puts "Seeding Complete!"
|
|
243
|
+
puts "=" * 50
|
|
244
|
+
|
|
245
|
+
@created_records.each do |model, records|
|
|
246
|
+
puts " #{model}: #{records.count} records"
|
|
247
|
+
end
|
|
248
|
+
|
|
249
|
+
puts "-" * 50
|
|
250
|
+
puts " Total: #{total} records"
|
|
251
|
+
|
|
252
|
+
@created_records
|
|
253
|
+
end
|
|
254
|
+
end
|
|
255
|
+
|
|
256
|
+
class << self
|
|
257
|
+
# Convenience method for database seeding
|
|
258
|
+
#
|
|
259
|
+
# @param options [Hash] seeder options
|
|
260
|
+
# @yield seeding DSL
|
|
261
|
+
# @return [Hash] created records by model
|
|
262
|
+
#
|
|
263
|
+
def seed_database(**options, &block)
|
|
264
|
+
seeder = DatabaseSeeder.new(**options)
|
|
265
|
+
seeder.seed(&block)
|
|
266
|
+
end
|
|
267
|
+
end
|
|
268
|
+
end
|