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 +7 -0
- data/MIT-LICENSE +20 -0
- data/README.md +381 -0
- data/Rakefile +22 -0
- data/app/models/concerns/structured_store/storable.rb +82 -0
- data/app/models/structured_store/versioned_schema.rb +41 -0
- data/app/validators/json_schema_validator.rb +49 -0
- data/lib/structured_store/converters/chronic_date_range_converter.rb +88 -0
- data/lib/structured_store/engine.rb +7 -0
- data/lib/structured_store/generators/install_generator.rb +41 -0
- data/lib/structured_store/generators/templates/create_structured_store.rb.tt +17 -0
- data/lib/structured_store/migration_helper.rb +21 -0
- data/lib/structured_store/ref_resolvers/base.rb +65 -0
- data/lib/structured_store/ref_resolvers/blank_ref_resolver.rb +45 -0
- data/lib/structured_store/ref_resolvers/defaults.rb +5 -0
- data/lib/structured_store/ref_resolvers/definitions_resolver.rb +67 -0
- data/lib/structured_store/ref_resolvers/json_date_range_resolver.rb +86 -0
- data/lib/structured_store/ref_resolvers/registry.rb +62 -0
- data/lib/structured_store/schema_inspector.rb +59 -0
- data/lib/structured_store/version.rb +3 -0
- data/lib/structured_store.rb +18 -0
- data/lib/tasks/structured_store_tasks.rake +4 -0
- metadata +108 -0
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,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,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,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
|
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: []
|