structured_store 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: 5e104dd0bcd9069ad670b176be0ad3e56231f8a2a02b2237b9d76cd8e1504302
4
+ data.tar.gz: b14d540d205c843a34355444f0893b7190c7df870d616c07357bb484e23105ad
5
+ SHA512:
6
+ metadata.gz: f946faaa8dc5fa672e765f6a9b5261f499b12c164324e8df7144d3a821009f5dbdb4b3c572d1dc4903578ae20a5802f4f7d0f42d52fd7125a36e445e86357f45
7
+ data.tar.gz: eae79479eff4abc7a24ac1966f76b24d6b4b8ede45e00d899136f93fec3f714ec4e4e9afe33362c386f9289db6b87dd46ca64c6fa98b54dd4b28788411773ff7
data/MIT-LICENSE ADDED
@@ -0,0 +1,20 @@
1
+ Copyright Tim Gentry
2
+
3
+ Permission is hereby granted, free of charge, to any person obtaining
4
+ a copy of this software and associated documentation files (the
5
+ "Software"), to deal in the Software without restriction, including
6
+ without limitation the rights to use, copy, modify, merge, publish,
7
+ distribute, sublicense, and/or sell copies of the Software, and to
8
+ permit persons to whom the Software is furnished to do so, subject to
9
+ the following conditions:
10
+
11
+ The above copyright notice and this permission notice shall be
12
+ included in all copies or substantial portions of the Software.
13
+
14
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
15
+ EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF
16
+ MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
17
+ NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE
18
+ LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION
19
+ OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
20
+ WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
data/README.md ADDED
@@ -0,0 +1,381 @@
1
+ # StructuredStore
2
+
3
+ StructuredStore is a Ruby gem designed for Rails applications that provides a robust system for managing JSON data using versioned JSON schemas. The library enables developers to store structured data in a JSON database column while maintaining strict schema validation through the json_schemer gem.
4
+
5
+ It features a VersionedSchema model that tracks different versions of JSON schemas using semantic versioning, and a Storable concern that can be included in ActiveRecord models to automatically define accessor methods for schema properties. The gem supports schema evolution by allowing multiple versions of the same schema to coexist, making it ideal for applications that need to maintain backward compatibility while evolving their data structures.
6
+
7
+ With a built-in Rails generator for reliable setup and dynamic property resolution through a configurable resolver registry, StructuredStore simplifies the management of complex, schema-validated JSON data in database applications.
8
+
9
+ ## Installation
10
+
11
+ Add this line to your application's Gemfile:
12
+
13
+ ```ruby
14
+ gem "structured_store"
15
+ ```
16
+
17
+ And then execute:
18
+
19
+ ```bash
20
+ bundle
21
+ ```
22
+
23
+ Or install it yourself as:
24
+
25
+ ```bash
26
+ gem install structured_store
27
+ ```
28
+
29
+ ## Usage
30
+
31
+ StructuredStore provides a robust way to manage JSON data with versioned schemas in Rails applications. Here's how to use it:
32
+
33
+ ### Basic Setup
34
+
35
+ After installation, create the necessary database tables:
36
+
37
+ ```bash
38
+ $ rails generate structured_store:install
39
+ $ rails db:migrate
40
+ ```
41
+
42
+ This creates a `structured_store_versioned_schemas` table and a `db/structured_store_versioned_schemas/` directory for your schema files.
43
+
44
+ ### 1. Creating a JSON Schema
45
+
46
+ First, define your JSON schema by creating a JSON file in the `db/structured_store_versioned_schemas/` directory. The file should be named using the pattern `{name}-{version}.json`.
47
+
48
+ Create `db/structured_store_versioned_schemas/UserPreferences-1.0.0.json`:
49
+
50
+ ```json
51
+ {
52
+ "$schema": "https://json-schema.org/draft/2019-09/schema",
53
+ "type": "object",
54
+ "properties": {
55
+ "theme": {
56
+ "type": "string",
57
+ "description": "User interface theme preference"
58
+ "examples": ["light", "dark", "system"]
59
+ },
60
+ "notifications": {
61
+ "type": "boolean",
62
+ "description": "Whether user notifications are enabled"
63
+ },
64
+ "language": {
65
+ "type": "string",
66
+ "description": "Preferred language"
67
+ }
68
+ },
69
+ "required": ["theme"],
70
+ "additionalProperties": false
71
+ }
72
+ ```
73
+
74
+ Then create a migration to install the schema:
75
+
76
+ ```ruby
77
+ # Generate a new migration
78
+ $ rails generate migration InstallUserPreferencesSchema
79
+
80
+ # Edit the migration file
81
+ class InstallUserPreferencesSchema < ActiveRecord::Migration[7.0]
82
+ include StructuredStore::MigrationHelper
83
+
84
+ def change
85
+ create_versioned_schema("UserPreferences", "1.0.0")
86
+ end
87
+ end
88
+ ```
89
+
90
+ Run the migration:
91
+
92
+ ```bash
93
+ $ rails db:migrate
94
+ ```
95
+
96
+ ### 2. Creating a Model with Structured Storage
97
+
98
+ Create a model that includes the `StructuredStore::Storable` concern:
99
+
100
+ ```ruby
101
+ # app/models/user_preference.rb
102
+ class UserPreference < ApplicationRecord
103
+ include StructuredStore::Storable
104
+ end
105
+ ```
106
+
107
+ Generate and run a migration for your model:
108
+
109
+ ```ruby
110
+ # db/migrate/xxx_create_user_preferences.rb
111
+ class CreateUserPreferences < ActiveRecord::Migration[7.0]
112
+ def change
113
+ create_table :user_preferences do |t|
114
+ t.references :structured_store_versioned_schema, null: false, foreign_key: true
115
+ t.json :store
116
+ t.timestamps
117
+ end
118
+ end
119
+ end
120
+ ```
121
+
122
+ ### 3. Using the Structured Store
123
+
124
+ Once your model includes `StructuredStore::Storable`, it automatically gets accessor methods for all properties defined in the associated JSON schema:
125
+
126
+ ```ruby
127
+ # Find the latest version of your schema
128
+ schema = StructuredStore::VersionedSchema.latest("UserPreferences")
129
+
130
+ # Create a new record
131
+ preference = UserPreference.create!(
132
+ versioned_schema: schema,
133
+ theme: "dark",
134
+ notifications: false,
135
+ language: "es"
136
+ )
137
+
138
+ # Access the structured data
139
+ preference.theme # => "dark"
140
+ preference.notifications # => false
141
+ preference.language # => "es"
142
+
143
+ # Update structured data
144
+ preference.update!(theme: "light", notifications: true)
145
+
146
+ # The data is stored in the JSON `store` column
147
+ preference.store # => {"theme"=>"light", "notifications"=>true, "language"=>"es"}
148
+ ```
149
+
150
+ ### 4. Schema Versioning
151
+
152
+ StructuredStore supports schema evolution. You can create new versions of your schema by adding new JSON files and creating migrations to install them.
153
+
154
+ Create `db/structured_store_versioned_schemas/UserPreferences-1.1.0.json`:
155
+
156
+ ```json
157
+ {
158
+ "$schema": "https://json-schema.org/draft/2019-09/schema",
159
+ "type": "object",
160
+ "properties": {
161
+ "theme": {
162
+ "type": "string",
163
+ "description": "User interface theme preference",
164
+ "examples": ["light", "dark", "system"]
165
+ },
166
+ "notifications": {
167
+ "type": "boolean",
168
+ "description": "Whether user notifications are enabled"
169
+ },
170
+ "language": {
171
+ "type": "string",
172
+ "description": "Preferred language"
173
+ },
174
+ "timezone": {
175
+ "type": "string",
176
+ "description": "User's timezone"
177
+ }
178
+ },
179
+ "required": ["theme"],
180
+ "additionalProperties": false
181
+ }
182
+ ```
183
+
184
+ Create a migration to install the new schema version:
185
+
186
+ ```ruby
187
+ class InstallUserPreferencesSchemaV110 < ActiveRecord::Migration[7.0]
188
+ include StructuredStore::MigrationHelper
189
+
190
+ def change
191
+ create_versioned_schema("UserPreferences", "1.1.0")
192
+ end
193
+ end
194
+ ```
195
+
196
+ Existing records continue to work with their original schema, while new records can use the latest version:
197
+
198
+ ```ruby
199
+ # Get the latest schema version
200
+ latest_schema = StructuredStore::VersionedSchema.latest("UserPreferences")
201
+
202
+ # Create a record with the new schema
203
+ new_preference = UserPreference.create!(
204
+ versioned_schema: latest_schema,
205
+ theme: "system",
206
+ timezone: "America/New_York"
207
+ )
208
+
209
+ new_preference.timezone # => "America/New_York"
210
+ ```
211
+
212
+ ### 5. Schema Validation
213
+
214
+ All data is automatically validated against the associated JSON schema:
215
+
216
+ ```ruby
217
+ preference = UserPreference.new(versioned_schema: schema)
218
+
219
+ # This will fail validation because 'theme' is required
220
+ preference.valid? # => false
221
+
222
+ # This will pass validation
223
+ preference.theme = "light"
224
+ preference.valid? # => true
225
+
226
+ # Invalid data types are rejected
227
+ preference.notifications = "invalid" # Will cause validation error
228
+ ```
229
+
230
+ ### 6. Working with Complex Data Types
231
+
232
+ StructuredStore includes a `JsonDateRangeResolver` for handling date ranges through JSON schema references. This resolver works with date range converters to transform natural language date strings into structured date ranges.
233
+
234
+ #### Using Date Ranges
235
+
236
+ To use date ranges, define a property in your JSON schema with the custom reference:
237
+
238
+ ```json
239
+ {
240
+ "$schema": "https://json-schema.org/draft/2019-09/schema",
241
+ "type": "object",
242
+ "properties": {
243
+ "event_period": {
244
+ "$ref": "external://structured_store/json_date_range/"
245
+ }
246
+ }
247
+ }
248
+ ```
249
+
250
+ Then implement a `date_range_converter` method in your model and require the optional `JsonDateRangeResolver`:
251
+
252
+ ```ruby
253
+ require 'structured_store/ref_resolvers/defaults'
254
+ require 'structured_store/ref_resolvers/json_date_range_resolver'
255
+
256
+ class EventRecord < ApplicationRecord
257
+ include StructuredStore::Storable
258
+
259
+ def date_range_converter
260
+ @date_range_converter ||= StructuredStore::Converters::ChronicDateRangeConverter.new
261
+ end
262
+ end
263
+ ```
264
+
265
+ If you choose to use the `ChronicDateRangeConverter`, you will also need to add `chronic` to your application's Gemfile.
266
+
267
+ #### Working with Date Range Data
268
+
269
+ ```ruby
270
+ # Create a record with a natural language date range
271
+ event = EventRecord.create!(
272
+ versioned_schema: schema,
273
+ event_period: "January 2024"
274
+ )
275
+
276
+ # The converter transforms this to structured data internally
277
+ event.store['event_period']
278
+ # => {"date1"=>"2024-01-01 00:00:00", "date2"=>"2024-01-31 00:00:00"}
279
+
280
+ # When accessed, it's converted back to the natural language format
281
+ event.event_period # => "Jan 2024"
282
+
283
+ # Other date range examples
284
+ event.update!(event_period: "between 1st and 15th January 2024")
285
+ event.update!(event_period: "2024") # Full year
286
+ ```
287
+
288
+ #### Using Alternative Converters
289
+
290
+ The `ChronicDateRangeConverter` is the default, but you can implement custom converters that respond to `convert_to_dates` and `convert_to_string`:
291
+
292
+ ```ruby
293
+ class CustomDateRangeConverter
294
+ def convert_to_dates(value)
295
+ # Your custom logic to parse date ranges
296
+ # Should return [start_date, end_date]
297
+ end
298
+
299
+ def convert_to_string(date1, date2)
300
+ # Your custom logic to format date ranges
301
+ # Should return a string representation
302
+ end
303
+ end
304
+
305
+ class MyModel < ApplicationRecord
306
+ include StructuredStore::Storable
307
+
308
+ def date_range_converter
309
+ @date_range_converter ||= CustomDateRangeConverter.new
310
+ end
311
+ end
312
+ ```
313
+
314
+ ### 7. Finding and Querying Schemas
315
+
316
+ ```ruby
317
+ # Find the latest version of a schema
318
+ latest = StructuredStore::VersionedSchema.latest("UserPreferences")
319
+
320
+ # Find a specific version
321
+ specific = StructuredStore::VersionedSchema.find_by(name: "UserPreferences", version: "1.0.0")
322
+
323
+ # Get all versions of a schema
324
+ all_versions = StructuredStore::VersionedSchema.where(name: "UserPreferences")
325
+ .order(:version)
326
+ ```
327
+
328
+ ### 8. Advanced Usage: Custom Reference Resolvers
329
+
330
+ StructuredStore includes a resolver system for handling JSON schema references. You can create custom resolvers by extending `StructuredStore::RefResolvers::Base`:
331
+
332
+ ```ruby
333
+ class CustomResolver < StructuredStore::RefResolvers::Base
334
+ def self.matching_ref_pattern
335
+ /^#\/custom\//
336
+ end
337
+
338
+ def define_attribute
339
+ lambda do |instance|
340
+ # Define custom attribute behavior
341
+ end
342
+ end
343
+ end
344
+
345
+ # Register your custom resolver
346
+ CustomResolver.register
347
+ ```
348
+
349
+ ### Best Practices
350
+
351
+ 1. **Version your schemas semantically**: Use semantic versioning (e.g., "1.0.0", "1.1.0", "2.0.0") to track schema changes.
352
+
353
+ 2. **Use migrations for schema management**: Always use the `StructuredStore::MigrationHelper` and migrations to install schemas. This ensures proper version control and rollback capabilities.
354
+
355
+ 3. **Organize schema files consistently**: Keep your JSON schema files in `db/structured_store_versioned_schemas/` with clear naming: `{SchemaName}-{version}.json`.
356
+
357
+ 4. **Plan for backward compatibility**: When creating new schema versions, consider how existing data will be handled.
358
+
359
+ 5. **Use meaningful schema names**: Choose descriptive names for your schemas that indicate their purpose.
360
+
361
+ 6. **Validate your JSON schemas**: Test your JSON schema files before creating migrations to ensure they're valid.
362
+
363
+ 7. **Document your schemas**: Include clear descriptions for all properties in your JSON schemas.
364
+
365
+ 8. **JSON schema defaults**: Given the attribute lifecycle, StructuredStore does not support JSON Schema property defaults.
366
+
367
+ 9. **Version control schema files**: Keep your `.json` schema files in version control alongside your migrations.
368
+
369
+ 10. **Test schema migrations**: Always test your schema installation migrations in development before deploying to production.
370
+
371
+ ## Contributing
372
+
373
+ Bug reports and pull requests are welcome on GitHub at https://github.com/HealthDataInsight/structured_store. This project is intended to be a safe, welcoming space for collaboration, and contributors are expected to adhere to the [code of conduct](https://github.com/HealthDataInsight/structured_store/blob/main/CODE_OF_CONDUCT.md).
374
+
375
+ ## License
376
+
377
+ The gem is available as open source under the terms of the [MIT License](https://opensource.org/licenses/MIT).
378
+
379
+ ## Code of Conduct
380
+
381
+ Everyone interacting in the structured_store project's codebases, issue trackers, chat rooms and mailing lists is expected to follow the [code of conduct](https://github.com/HealthDataInsight/structured_store/blob/main/CODE_OF_CONDUCT.md).
data/Rakefile ADDED
@@ -0,0 +1,22 @@
1
+ require 'bundler/setup'
2
+
3
+ APP_RAKEFILE = File.expand_path('test/dummy/Rakefile', __dir__)
4
+ load 'rails/tasks/engine.rake'
5
+
6
+ load 'rails/tasks/statistics.rake'
7
+
8
+ require 'bundler/gem_tasks'
9
+ require 'ndr_dev_support/tasks'
10
+ require 'rubocop/rake_task'
11
+
12
+ # Running tests via rail will ensure that the dummy app tests are run
13
+ namespace :test do
14
+ desc 'Run tests via Rails'
15
+ task :via_rails do
16
+ system('bin/rails test')
17
+ end
18
+ end
19
+
20
+ # Override the default rake test task
21
+ desc 'Rake test will run tests via Rails'
22
+ task test: 'test:via_rails'
@@ -0,0 +1,82 @@
1
+ module StructuredStore
2
+ # This module is included in models that need to be stored in a structured way.
3
+ # It provides the necessary methods and attributes for structured storage.
4
+ # The `storeable_attributes` method defines the attributes that will be stored.
5
+ # The `to_s` method is overridden to return the name of the object or the default string representation.
6
+ module Storable
7
+ extend ActiveSupport::Concern
8
+
9
+ included do
10
+ after_initialize :define_store_accessors!
11
+
12
+ belongs_to :versioned_schema, # rubocop:disable Rails/InverseOf
13
+ class_name: 'StructuredStore::VersionedSchema',
14
+ foreign_key: 'structured_store_versioned_schema_id'
15
+
16
+ delegate :json_schema, to: :versioned_schema
17
+ end
18
+
19
+ # Dynamically define accessors for the properties defined in the
20
+ # JSON schema that this record has.
21
+ #
22
+ # This method is run automatically as an `after_initialize` callback, but can be called at
23
+ # any time for debugging and testing purposes.
24
+ #
25
+ # It skips defining the accessors if there is insufficient information to do so.
26
+ def define_store_accessors!
27
+ return unless sufficient_info_to_define_store_accessors?
28
+
29
+ singleton_class.store_accessor(:store, json_schema_properties.keys)
30
+
31
+ property_resolvers.each_value do |resolver|
32
+ resolver.define_attribute.call(self)
33
+ end
34
+ end
35
+
36
+ # Returns an array of property resolvers for each property in the JSON schema.
37
+ # The resolvers are responsible for handling references and defining attributes
38
+ # for each property defined in the schema.
39
+ #
40
+ # @return [Array<StructuredStore::RefResolvers::Base>] Array of resolver instances
41
+ def property_resolvers
42
+ @property_resolvers ||= json_schema_properties.keys.index_with do |property_name|
43
+ StructuredStore::RefResolvers::Registry.matching_resolver(schema_inspector,
44
+ property_name)
45
+ end
46
+ end
47
+
48
+ private
49
+
50
+ # Returns a SchemaInspector instance for the current JSON schema.
51
+ def schema_inspector
52
+ @schema_inspector ||= StructuredStore::SchemaInspector.new(json_schema)
53
+ end
54
+
55
+ # Retrieves the properties from the JSON schema
56
+ #
57
+ # @return [Hash] a hash containing the properties defined in the JSON schema,
58
+ # or an empty hash if no properties exist
59
+ def json_schema_properties
60
+ json_schema.fetch('properties', {})
61
+ end
62
+
63
+ # Returns true if there is sufficient information to define accessors for this audit_store,
64
+ # and false otherwise.
65
+ #
66
+ # The JSON schema must be defined, containing property definitions.
67
+ def sufficient_info_to_define_store_accessors?
68
+ if json_schema.nil?
69
+ Rails.logger.info('This storable instance has no JSON schema')
70
+ return false
71
+ end
72
+
73
+ unless json_schema_properties.is_a?(Hash)
74
+ Rails.logger.warn 'The JSON schema for this storable instance does not contain ' \
75
+ "a valid 'properties' hash: #{json_schema_properties.inspect}"
76
+ return false
77
+ end
78
+
79
+ true
80
+ end
81
+ end
82
+ end
@@ -0,0 +1,41 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'json'
4
+
5
+ module StructuredStore
6
+ # This model stores individual versions of each structured store schema
7
+ class VersionedSchema < ApplicationRecord
8
+ validates :json_schema, json_schema: { allow_blank: true, schema: :draft201909 }
9
+ validates :name, presence: true, uniqueness: { scope: :version, case_sensitive: true }
10
+ validates :version, presence: true, format: { with: Gem::Version::ANCHORED_VERSION_PATTERN }
11
+
12
+ store_accessor :json_schema, :definitions, :properties
13
+
14
+ def self.table_name_prefix
15
+ 'structured_store_'
16
+ end
17
+
18
+ def self.latest(name)
19
+ schemas = where(name: name)
20
+
21
+ # Return nil if no schemas with this name exist
22
+ return nil if schemas.empty?
23
+
24
+ # Sort by version using gem_version and return the last one (highest version)
25
+ schemas.max_by(&:gem_version)
26
+ end
27
+
28
+ def json_schema=(json)
29
+ case json
30
+ when String
31
+ super(JSON.parse(json))
32
+ else
33
+ super
34
+ end
35
+ end
36
+
37
+ def gem_version
38
+ Gem::Version.new(version)
39
+ end
40
+ end
41
+ end
@@ -0,0 +1,49 @@
1
+ require 'json_schemer'
2
+
3
+ # This Rails validator checks that an attribute is either a valid JSON schema
4
+ # or that it complies with a given schema.
5
+ class JsonSchemaValidator < ActiveModel::EachValidator
6
+ NAMED_SCHEMA_VERSIONS = %i[draft201909 draft202012 draft4 draft6 draft7 openapi30 openapi31].freeze
7
+
8
+ def validate_each(record, attribute, value)
9
+ # Convert value to hash if it's a string
10
+ json_data = value.is_a?(String) ? JSON.parse(value) : value
11
+
12
+ # Get the schema from options
13
+ schema = options[:schema] || options
14
+
15
+ # Initialize JSONSchemer with proper handling based on schema type
16
+ schemer = json_schemer(schema)
17
+
18
+ # Collect validation errors
19
+ validation_errors = schemer.validate(json_data).to_a
20
+
21
+ # Add errors to the record using json_schemer's built-in I18n support
22
+ validation_errors.each do |error|
23
+ record.errors.add(attribute, error['error'])
24
+ end
25
+ rescue JSON::ParserError
26
+ record.errors.add(attribute, :invalid_json)
27
+ end
28
+
29
+ private
30
+
31
+ # Converts given schema to a JSONSchemer::Schema object.
32
+ #
33
+ # Accepts either a symbol referencing a known schema (e.g. :draft7), a string
34
+ # or hash representing a schema, or a JSONSchemer::Schema object directly.
35
+ #
36
+ # Raises an ArgumentError if schema is in an unsupported format.
37
+ def json_schemer(schema)
38
+ case schema
39
+ when *NAMED_SCHEMA_VERSIONS
40
+ JSONSchemer.send(schema)
41
+ when String, Hash
42
+ JSONSchemer.schema(schema)
43
+ when JSONSchemer::Schema
44
+ schema
45
+ else
46
+ raise ArgumentError, "Invalid schema format: #{schema.class}"
47
+ end
48
+ end
49
+ end
@@ -0,0 +1,88 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'chronic'
4
+
5
+ module StructuredStore
6
+ module Converters
7
+ # This class is responsible for converting date ranges to and from a string format.
8
+ class ChronicDateRangeConverter
9
+ # Converts a natural language date range string into an array containing start and end dates
10
+ #
11
+ # @param value [String] A natural language date range string (e.g., "between 1st and 15th January 2024")
12
+ # @return [Array<Time>] An array containing two Time objects: [start_date, end_date]
13
+ # @raise [NoMethodError] If the input string cannot be parsed into a valid date range
14
+ def convert_to_dates(value)
15
+ return [nil, nil] if value.blank?
16
+
17
+ if /\A\d{4}\z/.match?(value.strip)
18
+ # If the value is a year, return the start and end of that year
19
+ year = value.strip.to_i
20
+ return [Time.new(year, 1, 1), Time.new(year, 12, 31)]
21
+ end
22
+
23
+ parsed_date_range = Chronic.parse(value, endian_precedence: :little, guess: false)
24
+
25
+ [parsed_date_range&.begin, parsed_date_range&.end&.days_ago(1)]
26
+ end
27
+
28
+ # Formats two dates into a human readable date range string
29
+ #
30
+ # @param date1 [Date] The start date of the range
31
+ # @param date2 [Date] The end date of the range
32
+ # @return [String] A formatted date range string in one of these formats:
33
+ # - "D MMM YYYY" (when dates are equal)
34
+ # - "MMM YYYY" (when dates span a full month in the same year)
35
+ # - "YYYY" (when dates span a full year)
36
+ # - "D MMM YYYY to D MMM YYYY" (for all other date ranges)
37
+ # @example
38
+ # convert_to_string(Date.new(2023,1,1), Date.new(2023,1,1)) #=> "1 Jan 2023"
39
+ # convert_to_string(Date.new(2023,1,1), Date.new(2023,1,31)) #=> "Jan 2023"
40
+ # convert_to_string(Date.new(2023,1,1), Date.new(2023,12,31)) #=> "2023"
41
+ # convert_to_string(Date.new(2023,1,1), Date.new(2023,2,1)) #=> "1 Jan 2023 to 1 Feb 2023"
42
+ def convert_to_string(date1, date2)
43
+ return format_single_date(date1) if date1 == date2
44
+ return format_full_month(date1) if full_month_range?(date1, date2)
45
+ return format_full_year(date1) if full_year_range?(date1, date2)
46
+
47
+ format_date_range(date1, date2)
48
+ end
49
+
50
+ private
51
+
52
+ # Formats a single date
53
+ def format_single_date(date)
54
+ date.strftime('%e %b %Y').strip
55
+ end
56
+
57
+ # Formats a full month
58
+ def format_full_month(date)
59
+ date.strftime('%b %Y')
60
+ end
61
+
62
+ # Formats a full year
63
+ def format_full_year(date)
64
+ date.strftime('%Y')
65
+ end
66
+
67
+ # Formats a date range
68
+ def format_date_range(date1, date2)
69
+ "#{date1.strftime('%e %b %Y').strip} to #{date2.strftime('%e %b %Y').strip}"
70
+ end
71
+
72
+ # Checks if the date range spans a full month
73
+ def full_month_range?(date1, date2)
74
+ date1.year == date2.year &&
75
+ date1.month == date2.month &&
76
+ date1 == date1.beginning_of_month &&
77
+ date2 == date2.end_of_month
78
+ end
79
+
80
+ # Checks if the date range spans a full year
81
+ def full_year_range?(date1, date2)
82
+ date1.year == date2.year &&
83
+ date1 == date1.beginning_of_year &&
84
+ date2 == date2.end_of_year
85
+ end
86
+ end
87
+ end
88
+ end
@@ -0,0 +1,7 @@
1
+ # frozen_string_literal: true
2
+
3
+ # This defines the StructuredStore engine for Rails.
4
+ module StructuredStore
5
+ class Engine < ::Rails::Engine
6
+ end
7
+ end
@@ -0,0 +1,41 @@
1
+ require 'rails/generators'
2
+ require 'rails/generators/base'
3
+
4
+ module StructuredStore
5
+ module Generators
6
+ # This generator creates a migration for the structured store versioned schemas table
7
+ class InstallGenerator < Rails::Generators::Base
8
+ include Rails::Generators::Migration
9
+
10
+ source_root File.expand_path('templates', __dir__)
11
+
12
+ desc 'Creates a migration for the structured store tables'
13
+
14
+ # This method is required when including Rails::Generators::Migration
15
+ def self.next_migration_number(_dirname)
16
+ Time.now.utc.strftime('%Y%m%d%H%M%S')
17
+ end
18
+
19
+ def create_migration_file
20
+ migration_template 'create_structured_store.rb', 'db/migrate/create_structured_store.rb'
21
+ end
22
+
23
+ def create_schemas_directory
24
+ directory_path = 'db/structured_store_versioned_schemas'
25
+ keep_file_path = File.join(directory_path, '.keep')
26
+
27
+ # Create the directory if it doesn't exist
28
+ empty_directory directory_path
29
+
30
+ # Create the .keep file
31
+ create_file keep_file_path
32
+ end
33
+
34
+ private
35
+
36
+ def migration_version
37
+ "[#{ActiveRecord::Migration.current_version}]"
38
+ end
39
+ end
40
+ end
41
+ end
@@ -0,0 +1,17 @@
1
+ # frozen_string_literal: true
2
+
3
+ # This migration creates the underlying table for the structured store
4
+ class CreateStructuredStore < ActiveRecord::Migration<%= migration_version %>
5
+ def change
6
+ create_table :structured_store_versioned_schemas do |t|
7
+ t.string :name, null: false
8
+ t.string :version, null: false
9
+ t.json :json_schema
10
+
11
+ t.timestamps
12
+ end
13
+
14
+ # Add a unique index on the combination of name and version in versioned_schemas
15
+ add_index :structured_store_versioned_schemas, %i[name version], unique: true
16
+ end
17
+ end
@@ -0,0 +1,21 @@
1
+ module StructuredStore
2
+ # Helper method for creating versioned schemas
3
+ module MigrationHelper
4
+ def create_versioned_schema(name, version)
5
+ reversible do |dir|
6
+ dir.up do
7
+ json_schema_string = Rails.root.join("db/structured_store_versioned_schemas/#{name}-#{version}.json").read
8
+ json_schema = JSON.parse(json_schema_string)
9
+ ::StructuredStore::VersionedSchema.create(
10
+ name: name,
11
+ version: version,
12
+ json_schema: json_schema
13
+ )
14
+ end
15
+ dir.down do
16
+ ::StructuredStore::VersionedSchema.where(name: name, version: version).destroy_all
17
+ end
18
+ end
19
+ end
20
+ end
21
+ end
@@ -0,0 +1,65 @@
1
+ # frozen_string_literal: true
2
+
3
+ module StructuredStore
4
+ module RefResolvers
5
+ # This is the base class for all JSON Schema $ref resolvers.
6
+ class Base
7
+ attr_reader :context,
8
+ :property_name,
9
+ :ref_string,
10
+ :schema_inspector
11
+
12
+ class << self
13
+ def matching_ref_pattern
14
+ raise NotImplementedError, 'Subclasses must implement the matching_ref_pattern method'
15
+ end
16
+
17
+ def register
18
+ StructuredStore::RefResolvers::Registry.register(self)
19
+ end
20
+
21
+ def unregister
22
+ StructuredStore::RefResolvers::Registry.unregister(self)
23
+ end
24
+ end
25
+
26
+ # Initialize method for the base reference resolver
27
+ #
28
+ # @param schema [Hash] JSON Schema for the resolver
29
+ # @param options [Hash] Additional options for the resolver
30
+ def initialize(schema_inspector, property_name, ref_string, context = {})
31
+ @schema_inspector = schema_inspector
32
+ @property_name = property_name
33
+ @ref_string = ref_string
34
+ @context = context
35
+ end
36
+
37
+ # Defines the rails attribute(s) on the given singleton class
38
+ #
39
+ # @abstract Subclasses must implement this method
40
+ # @return [Proc] a lambda that defines the attribute on the singleton class
41
+ # @raise [NotImplementedError] if the method is not implemented in a subclass
42
+ def define_attribute
43
+ raise NotImplementedError, 'Subclasses must implement the define_attribute method'
44
+ end
45
+
46
+ # Returns a two dimensional array of HTML select box options
47
+ #
48
+ # This method must be implemented by subclasses to provide specific options
49
+ # for reference resolution.
50
+ #
51
+ # @abstract Subclasses must implement this method
52
+ # @return [Array<Array>] Array of arrays containing id, value option pairs
53
+ # @raise [NotImplementedError] if the method is not implemented by a subclass
54
+ def options_array
55
+ raise NotImplementedError, 'Subclasses must implement the options_array method'
56
+ end
57
+
58
+ private
59
+
60
+ def json_property_schema
61
+ @json_property_schema ||= schema_inspector.property_schema(property_name) || {}
62
+ end
63
+ end
64
+ end
65
+ end
@@ -0,0 +1,45 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'structured_store/ref_resolvers/base'
4
+
5
+ module StructuredStore
6
+ # This is the namespace for all reference resolvers used in StructuredStore.
7
+ module RefResolvers
8
+ # This class resolves properties where no $ref is defined.
9
+ class BlankRefResolver < Base
10
+ def self.matching_ref_pattern
11
+ /\A\z/
12
+ end
13
+
14
+ # Defines the rails attribute(s) on the given singleton class
15
+ #
16
+ # @return [Proc] a lambda that defines the attribute on the singleton class
17
+ # @raise [RuntimeError] if the property type is unsupported
18
+ def define_attribute
19
+ type = json_property_schema['type']
20
+
21
+ unless %w[boolean integer string].include?(type)
22
+ raise "Unsupported attribute type: #{type.inspect} for property '#{property_name}'"
23
+ end
24
+
25
+ # Define the attribute on the singleton class of the object
26
+ lambda do |object|
27
+ object.singleton_class.attribute(property_name, type.to_sym)
28
+ end
29
+ end
30
+
31
+ # Returns a two dimensional array of options from the 'enum' property definition
32
+ # Each element contains a duplicate of the enum option for both the label and value
33
+ #
34
+ # @return [Array<Array>] Array of arrays containing id, value option pairs
35
+ def options_array
36
+ enum = json_property_schema['enum']
37
+
38
+ enum.map { |option| [option, option] }
39
+ end
40
+ end
41
+
42
+ # Register the BlankRefResolver with the registry
43
+ BlankRefResolver.register
44
+ end
45
+ end
@@ -0,0 +1,5 @@
1
+ # This file will require the registry and default resolvers.
2
+ require 'structured_store/ref_resolvers/registry'
3
+
4
+ require 'structured_store/ref_resolvers/blank_ref_resolver'
5
+ require 'structured_store/ref_resolvers/definitions_resolver'
@@ -0,0 +1,67 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'structured_store/ref_resolvers/base'
4
+ require 'structured_store/ref_resolvers/registry'
5
+
6
+ module StructuredStore
7
+ # This is the namespace for all reference resolvers used in StructuredStore.
8
+ module RefResolvers
9
+ # This class resolves $ref strings that point to definitions within the schema.
10
+ class DefinitionsResolver < Base
11
+ def self.matching_ref_pattern
12
+ %r{\A#/definitions/}
13
+ end
14
+
15
+ def initialize(schema, property_name, ref_string, context = {})
16
+ super
17
+ end
18
+
19
+ # Defines the rails attribute(s) on the given singleton class
20
+ #
21
+ # @return [Proc] a lambda that defines the attribute on the singleton class
22
+ # @raise [RuntimeError] if the property type is unsupported
23
+ def define_attribute
24
+ type = local_definition['type']
25
+
26
+ unless %w[boolean integer string].include?(type)
27
+ raise "Unsupported attribute type: #{type.inspect} for property '#{property_name}'"
28
+ end
29
+
30
+ # Define the attribute on the singleton class of the object
31
+ lambda do |object|
32
+ object.singleton_class.attribute(property_name, type.to_sym)
33
+ end
34
+ end
35
+
36
+ # Returns a two dimensional array of options from the 'enum' definition
37
+ # Each element contains a duplicate of the enum option for both the label and value
38
+ #
39
+ # @return [Array<Array>] Array of arrays containing id, value option pairs
40
+ def options_array
41
+ enum = local_definition['enum']
42
+
43
+ enum.map { |option| [option, option] }
44
+ end
45
+
46
+ private
47
+
48
+ # Retrieves a local definition from the schema based on the reference string
49
+ #
50
+ # @return [Hash] The local definition hash from the schema's definitions
51
+ # @raise [RuntimeError] If no definition is found for the given reference string
52
+ # @example
53
+ # resolver.local_definition # => { "type" => "string" }
54
+ def local_definition
55
+ definition_name = ref_string.sub('#/definitions/', '')
56
+ local_definition = schema_inspector.definition_schema(definition_name)
57
+
58
+ raise "No definition for #{ref_string}" if local_definition.nil?
59
+
60
+ local_definition
61
+ end
62
+ end
63
+
64
+ # Register the DefinitionsResolver with the registry
65
+ DefinitionsResolver.register
66
+ end
67
+ end
@@ -0,0 +1,86 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'structured_store/ref_resolvers/base'
4
+
5
+ module StructuredStore
6
+ # This is the namespace for all reference resolvers used in StructuredStore.
7
+ module RefResolvers
8
+ # This class resolves properties where no $ref is defined.
9
+ class JsonDateRangeResolver < Base
10
+ def self.matching_ref_pattern
11
+ %r{\Aexternal://structured_store/json_date_range/}
12
+ end
13
+
14
+ # Defines the rails attribute(s) on the given singleton class
15
+ #
16
+ # @return [Proc] a lambda that defines the attribute on the singleton class
17
+ # @raise [RuntimeError] if the property type is unsupported
18
+ def define_attribute
19
+ # Capture the property name in a local variable for closure
20
+ prop_name = property_name
21
+ resolver = self
22
+
23
+ # Define the attribute on the singleton class of the object
24
+ lambda do |object|
25
+ converter = object.date_range_converter
26
+
27
+ # Define custom getter and setter methods
28
+ object.singleton_class.define_method(prop_name) do
29
+ resolver.send(:cast_stored_value, store, prop_name, converter)
30
+ end
31
+
32
+ object.singleton_class.define_method("#{prop_name}=") do |value|
33
+ resolver.send(:serialize_value_to_store, self, prop_name, value, converter)
34
+ end
35
+ end
36
+ end
37
+
38
+ # Returns an empty array of options for date ranges
39
+ #
40
+ # @return [Array<Array>] Array of arrays containing id, value option pairs
41
+ def options_array
42
+ []
43
+ end
44
+
45
+ private
46
+
47
+ # Casts the stored value from hash to formatted string
48
+ def cast_stored_value(store_hash, prop_name, converter)
49
+ stored_value = store_hash&.[](prop_name)
50
+ return nil if stored_value.blank?
51
+
52
+ case stored_value
53
+ when String
54
+ stored_value
55
+ when Hash
56
+ cast_hash_to_string(stored_value, converter)
57
+ end
58
+ end
59
+
60
+ # Converts a hash with date1/date2 to a formatted string
61
+ def cast_hash_to_string(stored_value, converter)
62
+ return nil unless stored_value['date1']
63
+
64
+ date1 = Date.parse(stored_value['date1'])
65
+ date2 = Date.parse(stored_value['date2'])
66
+ converter.convert_to_string(date1, date2)
67
+ end
68
+
69
+ # Serializes an input value to the store as a hash
70
+ def serialize_value_to_store(object, prop_name, value, converter)
71
+ # Initialize store as empty hash if nil
72
+ object.store ||= {}
73
+ return object.store[prop_name] = nil if value.blank?
74
+
75
+ date1, date2 = converter.convert_to_dates(value)
76
+ object.store[prop_name] = {
77
+ 'date1' => date1&.to_fs(:db),
78
+ 'date2' => date2&.to_fs(:db)
79
+ }
80
+ end
81
+ end
82
+
83
+ # Register the JsonDateRangeResolver with the registry
84
+ JsonDateRangeResolver.register
85
+ end
86
+ end
@@ -0,0 +1,62 @@
1
+ # frozen_string_literal: true
2
+
3
+ module StructuredStore
4
+ module RefResolvers
5
+ # This is the registry for JSON Schema $ref resolvers.
6
+ module Registry
7
+ class << self
8
+ # Returns the Hash of registered resolvers
9
+ # If no resolvers have been registered, returns an empty Hash
10
+ # @return [Hash] Registered resolvers
11
+ def resolvers
12
+ @resolvers || {}
13
+ end
14
+
15
+ # Registers a resolver class with a specific regular expression pattern.
16
+ #
17
+ # @param klass [Class] The resolver class to register.
18
+ # @param regexp [Regexp] The regular expression pattern to match against references.
19
+ def register(klass)
20
+ @resolvers ||= {}
21
+ @resolvers[klass] = klass.matching_ref_pattern
22
+ end
23
+
24
+ # Unregisters a resolver class from the registry
25
+ #
26
+ # @param klass [Class] The resolver class to remove from the registry
27
+ # @return [Class, nil] The removed resolver class or nil if not found
28
+ def unregister(klass)
29
+ @resolvers.delete(klass)
30
+ end
31
+
32
+ # Returns a resolver instance for the given schema property reference
33
+ #
34
+ # @param [Hash] schema The JSON schema containing the property reference
35
+ # @param [String, Symbol] property_name The name of the property containing the reference
36
+ # @param [Hash] context Optional context hash (default: {})
37
+ # @return [RefResolver] An instance of the appropriate resolver class for the reference
38
+ # @raise [RuntimeError] If no matching resolver can be found for the reference
39
+ def matching_resolver(schema_inspector, property_name, context = {})
40
+ ref_string = schema_inspector.property_schema(property_name)['$ref']
41
+
42
+ klass_factory(ref_string).new(schema_inspector, property_name, ref_string, context)
43
+ end
44
+
45
+ private
46
+
47
+ # Creates a new resolver instance based on the provided reference string
48
+ #
49
+ # @param ref_string [String] The $ref string to resolve
50
+ # @return [Object] An instance of the matching resolver class or NoRefResolver
51
+ # @raise [RuntimeError] If no matching resolver class is found for the reference string
52
+ def klass_factory(ref_string)
53
+ # Find the first registered resolver class that matches the ref_string
54
+ klass = resolvers.find { |_, regexp| ref_string.to_s.match?(regexp) }&.then { |(klass, _)| klass }
55
+ return klass if klass
56
+
57
+ raise "Error: No matching $ref resolver pattern for #{ref_string.inspect}"
58
+ end
59
+ end
60
+ end
61
+ end
62
+ end
@@ -0,0 +1,59 @@
1
+ require 'json_schemer'
2
+ require 'active_support'
3
+ require 'active_support/core_ext/hash'
4
+
5
+ module StructuredStore
6
+ # This class inspects a JSON Schema and provides methods to retrieve
7
+ # property and definition schemas.
8
+ #
9
+ # It allows us to abstract away the implementation details of JSONSchemer
10
+ # and provides a clean interface for working with JSON Schemas.
11
+ class SchemaInspector
12
+ MAX_JSON_INPUT_SIZE_BYTES = 1_048_576
13
+
14
+ def initialize(schema)
15
+ @original_schema = schema
16
+ end
17
+
18
+ def valid_schema?
19
+ JSONSchemer.draft201909.valid?(schema_hash)
20
+ rescue ArgumentError
21
+ false
22
+ end
23
+
24
+ def property_schema(property_name)
25
+ schema_hash.dig('properties', property_name.to_s)
26
+ end
27
+
28
+ def definition_schema(definition_name)
29
+ schema_hash.dig('definitions', definition_name.to_s)
30
+ end
31
+
32
+ private
33
+
34
+ def safe_parse_json(json_string)
35
+ # Ensure the schema is a valid JSON object
36
+ if json_string.bytesize > MAX_JSON_INPUT_SIZE_BYTES
37
+ raise ArgumentError, "Schema size exceeds maximum limit of #{MAX_JSON_INPUT_SIZE_BYTES} bytes"
38
+ end
39
+
40
+ JSON.parse(json_string)
41
+ rescue JSON::ParserError
42
+ raise ArgumentError, "Invalid JSON schema: #{json_string.inspect}"
43
+ end
44
+
45
+ def schema_hash
46
+ @schema_hash =
47
+ case @original_schema
48
+ when Hash
49
+ # TODO: ensure the hash is safe to use (e.g. not too large)
50
+ @original_schema.deep_stringify_keys
51
+ when String
52
+ safe_parse_json(@original_schema)
53
+ else
54
+ raise ArgumentError, "Unsupported schema type: #{@original_schema.class}"
55
+ end
56
+ end
57
+ public :schema_hash
58
+ end
59
+ end
@@ -0,0 +1,3 @@
1
+ module StructuredStore
2
+ VERSION = '0.1.0'.freeze
3
+ end
@@ -0,0 +1,18 @@
1
+ require 'structured_store/version'
2
+ require 'zeitwerk'
3
+
4
+ loader = Zeitwerk::Loader.for_gem
5
+
6
+ # Avoid loading default resolvers by default
7
+ loader.ignore("#{__dir__}/structured_store/ref_resolvers/defaults.rb")
8
+
9
+ loader.setup
10
+
11
+ require 'structured_store/engine'
12
+ require 'structured_store/generators/install_generator' if defined?(Rails::Generators)
13
+ require 'structured_store/ref_resolvers/defaults'
14
+
15
+ # This module serves as a namespace for the StructuredStore gem
16
+ module StructuredStore
17
+ # Your code goes here...
18
+ end
@@ -0,0 +1,4 @@
1
+ # desc "Explaining what the task does"
2
+ # task :structured_store do
3
+ # # Task goes here
4
+ # end
metadata ADDED
@@ -0,0 +1,108 @@
1
+ --- !ruby/object:Gem::Specification
2
+ name: structured_store
3
+ version: !ruby/object:Gem::Version
4
+ version: 0.1.0
5
+ platform: ruby
6
+ authors:
7
+ - Tim Gentry
8
+ bindir: bin
9
+ cert_chain: []
10
+ date: 2025-07-01 00:00:00.000000000 Z
11
+ dependencies:
12
+ - !ruby/object:Gem::Dependency
13
+ name: json_schemer
14
+ requirement: !ruby/object:Gem::Requirement
15
+ requirements:
16
+ - - "~>"
17
+ - !ruby/object:Gem::Version
18
+ version: '2.4'
19
+ type: :runtime
20
+ prerelease: false
21
+ version_requirements: !ruby/object:Gem::Requirement
22
+ requirements:
23
+ - - "~>"
24
+ - !ruby/object:Gem::Version
25
+ version: '2.4'
26
+ - !ruby/object:Gem::Dependency
27
+ name: rails
28
+ requirement: !ruby/object:Gem::Requirement
29
+ requirements:
30
+ - - ">="
31
+ - !ruby/object:Gem::Version
32
+ version: '7.0'
33
+ type: :runtime
34
+ prerelease: false
35
+ version_requirements: !ruby/object:Gem::Requirement
36
+ requirements:
37
+ - - ">="
38
+ - !ruby/object:Gem::Version
39
+ version: '7.0'
40
+ - !ruby/object:Gem::Dependency
41
+ name: zeitwerk
42
+ requirement: !ruby/object:Gem::Requirement
43
+ requirements:
44
+ - - "~>"
45
+ - !ruby/object:Gem::Version
46
+ version: '2.6'
47
+ type: :runtime
48
+ prerelease: false
49
+ version_requirements: !ruby/object:Gem::Requirement
50
+ requirements:
51
+ - - "~>"
52
+ - !ruby/object:Gem::Version
53
+ version: '2.6'
54
+ description: StructuredStore is a gem for managing JSON data with versioned schemas.
55
+ email:
56
+ - 52189+timgentry@users.noreply.github.com
57
+ executables: []
58
+ extensions: []
59
+ extra_rdoc_files: []
60
+ files:
61
+ - MIT-LICENSE
62
+ - README.md
63
+ - Rakefile
64
+ - app/models/concerns/structured_store/storable.rb
65
+ - app/models/structured_store/versioned_schema.rb
66
+ - app/validators/json_schema_validator.rb
67
+ - lib/structured_store.rb
68
+ - lib/structured_store/converters/chronic_date_range_converter.rb
69
+ - lib/structured_store/engine.rb
70
+ - lib/structured_store/generators/install_generator.rb
71
+ - lib/structured_store/generators/templates/create_structured_store.rb.tt
72
+ - lib/structured_store/migration_helper.rb
73
+ - lib/structured_store/ref_resolvers/base.rb
74
+ - lib/structured_store/ref_resolvers/blank_ref_resolver.rb
75
+ - lib/structured_store/ref_resolvers/defaults.rb
76
+ - lib/structured_store/ref_resolvers/definitions_resolver.rb
77
+ - lib/structured_store/ref_resolvers/json_date_range_resolver.rb
78
+ - lib/structured_store/ref_resolvers/registry.rb
79
+ - lib/structured_store/schema_inspector.rb
80
+ - lib/structured_store/version.rb
81
+ - lib/tasks/structured_store_tasks.rake
82
+ homepage: https://github.com/HealthDataInsight/structured_store
83
+ licenses:
84
+ - MIT
85
+ metadata:
86
+ allowed_push_host: https://rubygems.org
87
+ homepage_uri: https://github.com/HealthDataInsight/structured_store
88
+ source_code_uri: https://github.com/HealthDataInsight/structured_store.git
89
+ changelog_uri: https://github.com/HealthDataInsight/structured_store.git/blob/main/CHANGELOG.md
90
+ rubygems_mfa_required: 'true'
91
+ rdoc_options: []
92
+ require_paths:
93
+ - lib
94
+ required_ruby_version: !ruby/object:Gem::Requirement
95
+ requirements:
96
+ - - ">="
97
+ - !ruby/object:Gem::Version
98
+ version: 3.1.0
99
+ required_rubygems_version: !ruby/object:Gem::Requirement
100
+ requirements:
101
+ - - ">="
102
+ - !ruby/object:Gem::Version
103
+ version: '0'
104
+ requirements: []
105
+ rubygems_version: 3.6.2
106
+ specification_version: 4
107
+ summary: Store JSON structured using versioned JSON Schemas.
108
+ test_files: []