schema-model 0.7.0 → 0.7.2
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 +4 -4
- data/.github/workflows/ci.yml +71 -31
- data/.github/workflows/release.yml +93 -0
- data/.release-please-manifest.json +3 -0
- data/.ruby-version +1 -1
- data/ARCHITECTURE.md +32 -6
- data/CHANGELOG.md +18 -0
- data/CLAUDE.md +15 -2
- data/Gemfile +1 -2
- data/Gemfile.lock +65 -36
- data/README.md +87 -16
- data/lib/schema/arrays.rb +1 -1
- data/lib/schema/associations/schema_creator.rb +7 -7
- data/lib/schema/model.rb +43 -34
- data/lib/schema/parsers/common.rb +14 -5
- data/lib/schema/utils.rb +11 -4
- data/lib/schema/version.rb +6 -0
- data/release-please-config.json +12 -0
- data/schema-model.gemspec +8 -4
- data/script/coverage_badge.rb +49 -0
- metadata +15 -6
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: c75de56a15a674efd32d16fdedda825e056c4b71e6e3a1d20c63761ae685c5c2
|
|
4
|
+
data.tar.gz: 25537f375a8da4d892bc4c6950e790d8db3b0859f925d302d27b9bef82a773f9
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: ec765c52a43b319a6e47879239f92f268a6c7c359163c001c2d9411f286fa41f7036a6739adf4e3f6531f7f9becc5c191c5ef51610f1128903d94a5f4ab6653e
|
|
7
|
+
data.tar.gz: 39dc1faf72f89f17dc75909421e1b1cc4c1d3e1b04db03661716f4f772a0ef5051ad8046e284ff34185b0da4dc27fdb44aaf720d7441a795fae18cd89c4f36a8
|
data/.github/workflows/ci.yml
CHANGED
|
@@ -3,51 +3,91 @@ name: CI
|
|
|
3
3
|
on:
|
|
4
4
|
push:
|
|
5
5
|
branches: [master]
|
|
6
|
+
# release-please PRs change only these files. Their runs are triggered by
|
|
7
|
+
# github-actions[bot], which GitHub holds for approval until they expire as
|
|
8
|
+
# failures; release.yml runs this workflow before publishing anyway. A PR is
|
|
9
|
+
# skipped only if every file it changes is listed here.
|
|
6
10
|
pull_request:
|
|
7
|
-
|
|
11
|
+
paths-ignore:
|
|
12
|
+
- CHANGELOG.md
|
|
13
|
+
- .release-please-manifest.json
|
|
14
|
+
- lib/schema/version.rb
|
|
15
|
+
workflow_call:
|
|
16
|
+
|
|
17
|
+
permissions:
|
|
18
|
+
contents: read
|
|
19
|
+
|
|
20
|
+
concurrency:
|
|
21
|
+
group: ci-${{ github.workflow }}-${{ github.ref }}
|
|
22
|
+
cancel-in-progress: ${{ github.event_name == 'pull_request' }}
|
|
8
23
|
|
|
9
24
|
jobs:
|
|
10
25
|
lint:
|
|
11
26
|
name: RuboCop
|
|
12
27
|
runs-on: ubuntu-latest
|
|
13
28
|
steps:
|
|
14
|
-
- uses: actions/checkout@
|
|
15
|
-
|
|
16
|
-
- name: Set up Ruby
|
|
17
|
-
uses: ruby/setup-ruby@v1
|
|
29
|
+
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
|
18
30
|
with:
|
|
19
|
-
|
|
31
|
+
persist-credentials: false
|
|
32
|
+
- uses: ruby/setup-ruby@14594264cd68ce8a2345dd349bc3d138a4ef85c8 # v1.327.0
|
|
33
|
+
with:
|
|
34
|
+
ruby-version: '4.0'
|
|
20
35
|
bundler-cache: true
|
|
36
|
+
- run: bundle exec rubocop --format github
|
|
21
37
|
|
|
22
|
-
|
|
23
|
-
run: bundle exec rubocop --format github
|
|
24
|
-
|
|
38
|
+
# Runs on the Ruby in .ruby-version, the version Gemfile.lock is resolved against
|
|
25
39
|
test:
|
|
26
|
-
name: Tests
|
|
40
|
+
name: Tests
|
|
27
41
|
runs-on: ubuntu-latest
|
|
28
|
-
strategy:
|
|
29
|
-
fail-fast: false
|
|
30
|
-
matrix:
|
|
31
|
-
ruby: ['3.2', '3.3', '3.4']
|
|
32
|
-
|
|
33
42
|
steps:
|
|
34
|
-
- uses: actions/checkout@
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
43
|
+
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
|
44
|
+
with:
|
|
45
|
+
persist-credentials: false
|
|
46
|
+
- uses: ruby/setup-ruby@14594264cd68ce8a2345dd349bc3d138a4ef85c8 # v1.327.0
|
|
38
47
|
with:
|
|
39
|
-
ruby-version:
|
|
48
|
+
# no ruby-version: setup-ruby reads .ruby-version
|
|
40
49
|
bundler-cache: true
|
|
50
|
+
- run: bundle exec rspec
|
|
51
|
+
# feeds the coverage badge
|
|
52
|
+
- name: Upload coverage report
|
|
53
|
+
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
|
|
54
|
+
with:
|
|
55
|
+
name: coverage
|
|
56
|
+
path: coverage/
|
|
57
|
+
# .last_run.json (read by the badge job) is a dotfile; upload-artifact skips those by default
|
|
58
|
+
include-hidden-files: true
|
|
59
|
+
retention-days: 14
|
|
41
60
|
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
61
|
+
# Publishes coverage.svg and branches.svg to the orphan `badges` branch; the README badges point at them.
|
|
62
|
+
# Skipped when called from release.yml (github.workflow is the caller's name there).
|
|
63
|
+
coverage-badge:
|
|
64
|
+
if: github.workflow == 'CI' && github.event_name == 'push' && github.ref == 'refs/heads/master'
|
|
65
|
+
needs: test
|
|
66
|
+
runs-on: ubuntu-latest
|
|
67
|
+
permissions:
|
|
68
|
+
contents: write
|
|
69
|
+
steps:
|
|
70
|
+
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
|
71
|
+
- uses: ruby/setup-ruby@14594264cd68ce8a2345dd349bc3d138a4ef85c8 # v1.327.0
|
|
72
|
+
with:
|
|
73
|
+
ruby-version: '4.0'
|
|
74
|
+
- uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
|
|
48
75
|
with:
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
76
|
+
name: coverage
|
|
77
|
+
path: coverage/
|
|
78
|
+
- run: |
|
|
79
|
+
ruby script/coverage_badge.rb coverage/.last_run.json "$RUNNER_TEMP/coverage.svg"
|
|
80
|
+
ruby script/coverage_badge.rb coverage/.last_run.json "$RUNNER_TEMP/branches.svg" branch
|
|
81
|
+
- name: Push badge to badges branch
|
|
82
|
+
run: |
|
|
83
|
+
git config user.name 'github-actions[bot]'
|
|
84
|
+
git config user.email '41898282+github-actions[bot]@users.noreply.github.com'
|
|
85
|
+
if git fetch --depth=1 origin badges; then
|
|
86
|
+
git switch badges
|
|
87
|
+
else
|
|
88
|
+
git switch --orphan badges
|
|
89
|
+
fi
|
|
90
|
+
cp "$RUNNER_TEMP/coverage.svg" "$RUNNER_TEMP/branches.svg" .
|
|
91
|
+
git add coverage.svg branches.svg
|
|
92
|
+
git diff --cached --quiet || git commit -m "coverage badge for ${GITHUB_SHA::7}"
|
|
93
|
+
git push origin badges
|
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
name: Release
|
|
2
|
+
|
|
3
|
+
# release-please keeps a release PR open that bumps lib/schema/version.rb
|
|
4
|
+
# and CHANGELOG.md from conventional commits. Merging it tags the release,
|
|
5
|
+
# creates the GitHub release and publishes the gem to RubyGems.
|
|
6
|
+
# Run it manually (workflow_dispatch) to retry publishing the current version.
|
|
7
|
+
on:
|
|
8
|
+
push:
|
|
9
|
+
branches: [master]
|
|
10
|
+
workflow_dispatch:
|
|
11
|
+
|
|
12
|
+
permissions:
|
|
13
|
+
contents: read
|
|
14
|
+
|
|
15
|
+
concurrency:
|
|
16
|
+
group: release
|
|
17
|
+
cancel-in-progress: false
|
|
18
|
+
|
|
19
|
+
jobs:
|
|
20
|
+
release-please:
|
|
21
|
+
runs-on: ubuntu-latest
|
|
22
|
+
permissions:
|
|
23
|
+
contents: write # release commits, tags and GitHub releases
|
|
24
|
+
issues: write # release labels
|
|
25
|
+
pull-requests: write # the release PR
|
|
26
|
+
outputs:
|
|
27
|
+
release_created: ${{ steps.release.outputs.release_created }}
|
|
28
|
+
tag_name: ${{ steps.release.outputs.tag_name }}
|
|
29
|
+
version: ${{ steps.release.outputs.version }}
|
|
30
|
+
pr_branch: ${{ steps.release.outputs.pr && fromJSON(steps.release.outputs.pr).headBranchName || '' }}
|
|
31
|
+
steps:
|
|
32
|
+
- uses: googleapis/release-please-action@45996ed1f6d02564a971a2fa1b5860e934307cf7 # v5.0.0
|
|
33
|
+
id: release
|
|
34
|
+
with:
|
|
35
|
+
config-file: release-please-config.json
|
|
36
|
+
manifest-file: .release-please-manifest.json
|
|
37
|
+
|
|
38
|
+
# The release PR is opened with GITHUB_TOKEN, which doesn't trigger CI on it,
|
|
39
|
+
# so run the suite here before anything is published.
|
|
40
|
+
ci:
|
|
41
|
+
needs: release-please
|
|
42
|
+
if: needs.release-please.outputs.release_created == 'true' || github.event_name == 'workflow_dispatch'
|
|
43
|
+
uses: ./.github/workflows/ci.yml
|
|
44
|
+
# ceiling for the called workflow: its coverage-badge job declares contents: write
|
|
45
|
+
# (skipped when called from here); lint/test still run with ci.yml's top-level contents: read
|
|
46
|
+
permissions:
|
|
47
|
+
contents: write
|
|
48
|
+
|
|
49
|
+
publish:
|
|
50
|
+
needs: [release-please, ci]
|
|
51
|
+
runs-on: ubuntu-latest
|
|
52
|
+
environment:
|
|
53
|
+
name: rubygems
|
|
54
|
+
url: https://rubygems.org/gems/schema-model
|
|
55
|
+
permissions:
|
|
56
|
+
contents: write # attach the gem to the GitHub release
|
|
57
|
+
id-token: write # RubyGems trusted publishing (OIDC)
|
|
58
|
+
steps:
|
|
59
|
+
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
|
60
|
+
with:
|
|
61
|
+
# the new tag on release; the dispatched commit on a manual retry
|
|
62
|
+
ref: ${{ needs.release-please.outputs.tag_name || github.sha }}
|
|
63
|
+
persist-credentials: false
|
|
64
|
+
- uses: ruby/setup-ruby@14594264cd68ce8a2345dd349bc3d138a4ef85c8 # v1.327.0
|
|
65
|
+
with:
|
|
66
|
+
ruby-version: '4.0'
|
|
67
|
+
|
|
68
|
+
- name: Read version
|
|
69
|
+
run: |
|
|
70
|
+
version=$(ruby -e 'puts Gem::Specification.load("schema-model.gemspec").version')
|
|
71
|
+
echo "VERSION=$version" >> "$GITHUB_ENV"
|
|
72
|
+
echo "TAG=v$version" >> "$GITHUB_ENV"
|
|
73
|
+
|
|
74
|
+
- name: Build gem
|
|
75
|
+
run: gem build schema-model.gemspec
|
|
76
|
+
|
|
77
|
+
- uses: rubygems/configure-rubygems-credentials@dc5a8d8553e6ee01fc26761a49e99e733d17954a # v2.1.0
|
|
78
|
+
|
|
79
|
+
# A version that's already on RubyGems is skipped, so a manual retry can finish a
|
|
80
|
+
# partially published release.
|
|
81
|
+
- name: Push gem
|
|
82
|
+
run: |
|
|
83
|
+
if curl -sf "https://rubygems.org/api/v2/rubygems/schema-model/versions/$VERSION.json" > /dev/null; then
|
|
84
|
+
echo "schema-model $VERSION is already published"
|
|
85
|
+
else
|
|
86
|
+
gem push "schema-model-$VERSION.gem"
|
|
87
|
+
fi
|
|
88
|
+
|
|
89
|
+
- name: Attach gem to GitHub release
|
|
90
|
+
env:
|
|
91
|
+
GH_TOKEN: ${{ github.token }}
|
|
92
|
+
run: |
|
|
93
|
+
gh release upload "$TAG" "schema-model-$VERSION.gem" --repo "$GITHUB_REPOSITORY" --clobber
|
data/.ruby-version
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
|
|
1
|
+
ruby-4.0.7
|
data/ARCHITECTURE.md
CHANGED
|
@@ -17,16 +17,26 @@ Hash Data → from_hash() → Parser Methods → Schema Instance
|
|
|
17
17
|
```
|
|
18
18
|
Schema::All (convenience bundle)
|
|
19
19
|
├── Schema::Model (core attribute system)
|
|
20
|
+
│ └── Schema::Parsers::Common
|
|
20
21
|
├── Schema::Associations::HasOne
|
|
21
22
|
├── Schema::Associations::HasMany
|
|
22
|
-
├── Schema::Parsers::Common
|
|
23
23
|
├── Schema::Parsers::American
|
|
24
24
|
├── Schema::Parsers::Array
|
|
25
25
|
├── Schema::Parsers::Hash
|
|
26
26
|
├── Schema::Parsers::Json
|
|
27
27
|
└── Schema::ActiveModelValidations
|
|
28
|
+
|
|
29
|
+
Opt-in (via schema_include):
|
|
30
|
+
├── Schema::ArrayHeaders (map CSV headers to attributes)
|
|
31
|
+
└── Schema::Arrays (models to/from flat arrays)
|
|
32
|
+
|
|
33
|
+
Standalone:
|
|
34
|
+
├── Schema::CSVParser (class; reads CSV rows into models)
|
|
35
|
+
└── SchemaValidator (ActiveModel validator for `schema: true`)
|
|
28
36
|
```
|
|
29
37
|
|
|
38
|
+
`schema_include` both includes a module and records it in `schema_config[:schema_includes]`, so it is also included in every nested association class, including ones defined later.
|
|
39
|
+
|
|
30
40
|
## Core Components
|
|
31
41
|
|
|
32
42
|
### Schema::Model (`lib/schema/model.rb`)
|
|
@@ -40,7 +50,9 @@ The foundation module providing:
|
|
|
40
50
|
|
|
41
51
|
- **`from_hash(data, skip_fields)`**: Class method that creates instance and calls `update_attributes`
|
|
42
52
|
|
|
43
|
-
- **`update_attributes(data, skip_fields)`**: Iterates
|
|
53
|
+
- **`update_attributes(data, skip_fields)`**: Iterates the data's key/value pairs, matches them against the schema, and invokes setters. Plain attributes are set before associations, so an association's `external_type_field` can read a sibling attribute. Each key is looked up on its own: symbol keys in `schema`, anything else in `schema_with_string_keys` (rebuilt whenever `schema` changes). `skip_fields` entries match a field by name or alias, as a symbol or string; a `{ association: [...] }` entry passes a nested list down.
|
|
54
|
+
|
|
55
|
+
- **Aliases**: each alias adds a second schema entry with `alias_of:` pointing at the real attribute, plus aliased getter/setter methods. Serialization and array conversion skip entries with `alias_of`.
|
|
44
56
|
|
|
45
57
|
- **`as_json` / `to_hash`**: Serialization back to hash format
|
|
46
58
|
|
|
@@ -66,10 +78,14 @@ has_one(:profile) { attribute :bio, :string }
|
|
|
66
78
|
has_many(:posts) { attribute :title, :string }
|
|
67
79
|
```
|
|
68
80
|
|
|
81
|
+
Each association gets a generated class (`SchemaHasOne<Name>` / `SchemaHasMany<Name>` by default, or `class_name:`), defined as a constant inside the parent class. `base_class:` subclasses an existing schema instead of creating a fresh model class.
|
|
82
|
+
|
|
69
83
|
Both use **SchemaCreator** (`schema_creator.rb`) to:
|
|
70
84
|
1. Determine which class to instantiate (static or dynamic)
|
|
71
85
|
2. Call `from_hash` on the nested class
|
|
72
|
-
3. Propagate parsing errors to parent
|
|
86
|
+
3. Propagate parsing errors to parent: `invalid` when the nested model has parsing errors, `incompatible` when the value isn't a hash (or, for has-many, an array), and `unknown` when no dynamic type matches
|
|
87
|
+
|
|
88
|
+
`has_many ..., from: :hash` accepts a hash keyed by id instead of an array; each key is written to `hash_key_field` (default `:id`) on the nested model.
|
|
73
89
|
|
|
74
90
|
**DynamicTypes** enables polymorphic associations:
|
|
75
91
|
|
|
@@ -81,7 +97,9 @@ has_many(:items, type_field: :kind) do
|
|
|
81
97
|
end
|
|
82
98
|
```
|
|
83
99
|
|
|
84
|
-
The `type_field` option tells SchemaCreator which data
|
|
100
|
+
The `type_field` option tells SchemaCreator which key in the nested data determines the subclass (the type field attribute's aliases are checked too); `external_type_field` reads the type from an attribute on the parent instead. `type_ignorecase: true` compares type names case-insensitively. Each `add_type` creates a subclass of the association class, defined on the parent class as `<Name>AssociationType<Type>`.
|
|
101
|
+
|
|
102
|
+
`Associations::Base` stores the parent class by name and resolves it with `Object.const_get`, so a parent class must be assigned to a constant before any association that uses dynamic types is declared.
|
|
85
103
|
|
|
86
104
|
### Schema::Utils (`lib/schema/utils.rb`)
|
|
87
105
|
|
|
@@ -89,7 +107,7 @@ Utility methods for:
|
|
|
89
107
|
- `classify_name`: String → ClassName conversion
|
|
90
108
|
- `create_schema_class`: Dynamically creates nested schema classes
|
|
91
109
|
- `add_association_class`: Wires up association with proper modules
|
|
92
|
-
- `add_attribute_default_methods` / `add_association_default_methods`: Default value handling
|
|
110
|
+
- `add_attribute_default_methods` / `add_association_default_methods`: Default value handling (`copy_default` hands each read a deep copy of the default)
|
|
93
111
|
|
|
94
112
|
### Error Handling
|
|
95
113
|
|
|
@@ -103,7 +121,15 @@ Parsing errors are distinct from validation errors:
|
|
|
103
121
|
- **Parsing errors**: Type conversion failures (string "abc" → integer)
|
|
104
122
|
- **Validation errors**: Business rule failures (via `validates` DSL)
|
|
105
123
|
|
|
106
|
-
|
|
124
|
+
`parsed!` raises `ParsingException` when there are parsing errors and `valid_model!` raises `ValidationException` when validations fail; `valid!` calls both. `valid?` runs validations only and does not look at parsing errors.
|
|
125
|
+
|
|
126
|
+
Parsing errors use these codes from `Schema::ParsingErrors`: `invalid`, `incompatible`, `unknown`, `unknown_attribute`, `unhandled_type`.
|
|
127
|
+
|
|
128
|
+
### CSV and Arrays
|
|
129
|
+
|
|
130
|
+
- **`Schema::ArrayHeaders`** (`lib/schema/array_headers.rb`): `map_headers_to_attributes(headers)` returns a nested hash of `{ field: { index: n } }` entries. Has-many fields are matched as `<prefix><n><key>` (prefix is the association name or an alias, `n` counts from 1) and map to `{ indexes: [...] }`.
|
|
131
|
+
- **`Schema::Arrays`** (`lib/schema/arrays.rb`): `to_headers`, `to_empty_array`, `to_a` and `from_array(array, mapped_headers)`. Has-many associations need a `size:` option for the fixed-width methods.
|
|
132
|
+
- **`Schema::CSVParser`** (`lib/schema/csv_parser.rb`): wraps a `CSV` object, maps its header row once, and yields a model per row (`each`, `shift`, `missing_fields`).
|
|
107
133
|
|
|
108
134
|
### Inheritance Helper Integration
|
|
109
135
|
|
data/CHANGELOG.md
ADDED
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
# Changelog
|
|
2
|
+
|
|
3
|
+
## [0.7.2](https://github.com/dougyouch/schema/compare/v0.7.1...v0.7.2) (2026-10-04)
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
### Bug Fixes
|
|
7
|
+
|
|
8
|
+
* **associations:** read type field aliases and handle bad hash-keyed entries ([d5bdb05](https://github.com/dougyouch/schema/commit/d5bdb05c4ce90cd40e50f1619829f0e29082cec6))
|
|
9
|
+
* **model:** honor skip_fields for any key style and fix key lookup edge cases ([3ca3450](https://github.com/dougyouch/schema/commit/3ca34500f71e62c9bfcf0c49419bac22bdfc56bd))
|
|
10
|
+
* **parsers:** anchor parser regexes and reject non-finite floats as integers ([0514bd6](https://github.com/dougyouch/schema/commit/0514bd6175031a146bbc3a5d178b9e0fd32b4c7f))
|
|
11
|
+
|
|
12
|
+
## [0.7.1](https://github.com/dougyouch/schema/compare/v0.7.0...v0.7.1) (2026-10-04)
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
### Bug Fixes
|
|
16
|
+
|
|
17
|
+
* **specs:** replace deprecated SimpleCov API calls ([dadae4a](https://github.com/dougyouch/schema/commit/dadae4aa2d0246e964fdb160e1b505187e3fadc6))
|
|
18
|
+
* **specs:** wrap trailing hash literals in braces per Style/HashAsLastArrayItem ([cf8f18a](https://github.com/dougyouch/schema/commit/cf8f18af5b7c472298c7c0cbd7b6176356b476fc))
|
data/CLAUDE.md
CHANGED
|
@@ -35,7 +35,7 @@ This is a Ruby gem (`schema-model`) for data transformation, validation, and typ
|
|
|
35
35
|
- **Schema::All** (`lib/schema/all.rb`) - Convenience module bundling Model + Associations + Parsers + ActiveModel validations. This is the typical include.
|
|
36
36
|
|
|
37
37
|
- **Schema::Parsers** - Type parsers in `lib/schema/parsers/`:
|
|
38
|
-
- `Common` - integer, string, float, time, date, boolean
|
|
38
|
+
- `Common` - integer, string, string_or_nil, float, time, date, boolean (included by `Schema::Model`)
|
|
39
39
|
- `American` - american_date, american_time (MM/DD/YYYY format)
|
|
40
40
|
- `Array` - array with optional separator and data_type
|
|
41
41
|
- `Hash` - hash/dictionary values
|
|
@@ -47,16 +47,28 @@ This is a Ruby gem (`schema-model`) for data transformation, validation, and typ
|
|
|
47
47
|
|
|
48
48
|
- **Schema::ArrayHeaders** (`lib/schema/array_headers.rb`) - Map CSV headers to schema attributes.
|
|
49
49
|
|
|
50
|
+
- **Schema::CSVParser** (`lib/schema/csv_parser.rb`) - Class that reads `CSV` rows into models (needs ArrayHeaders + Arrays on the model).
|
|
51
|
+
|
|
52
|
+
- **SchemaValidator** (`lib/schema_validator.rb`) - ActiveModel validator behind `validates :assoc, schema: true`.
|
|
53
|
+
|
|
50
54
|
### Key Patterns
|
|
51
55
|
|
|
52
56
|
**Attribute Definition**: Each `attribute` call generates getter, setter, and `<name>_was_set?` predicate. Setter invokes type-specific parser.
|
|
53
57
|
|
|
54
|
-
**Parsing Errors**: Stored in `parsing_errors`. Parsers add errors for invalid values rather than raising exceptions. With ActiveModelValidations, use `parsed?`/`parsed
|
|
58
|
+
**Parsing Errors**: Stored in `parsing_errors`. Parsers add errors for invalid values rather than raising exceptions. With ActiveModelValidations, use `parsed?`/`parsed!`; `valid?` does not check parsing errors.
|
|
55
59
|
|
|
56
60
|
**Schema Inheritance**: Uses `inheritance-helper` gem. Schema definitions accumulate via `add_value_to_class_method(:schema, ...)`.
|
|
57
61
|
|
|
58
62
|
**Dynamic Types**: For polymorphic associations, use `type_field` option with `add_type` blocks. Supports `external_type_field`, `type_ignorecase`, and `default_type`.
|
|
59
63
|
|
|
64
|
+
## CI
|
|
65
|
+
|
|
66
|
+
`.github/workflows/ci.yml` runs RuboCop and the specs on the `.ruby-version` Ruby. On pushes to `master`, it publishes `coverage.svg` (line) and `branches.svg` (branch) from `script/coverage_badge.rb` to the orphan `badges` branch for the README badges.
|
|
67
|
+
|
|
68
|
+
## Releases
|
|
69
|
+
|
|
70
|
+
Releases are automated by release-please (`.github/workflows/release.yml`). Conventional commits on `master` (`fix:` → patch, `feat:` → minor, `!`/`BREAKING CHANGE` → major) update an open release PR that bumps `lib/schema/version.rb` (`Schema::VERSION`, read by the gemspec) and `CHANGELOG.md`. Merging that PR tags `vX.Y.Z`, creates the GitHub release, and publishes the gem to RubyGems. Don't bump the version by hand.
|
|
71
|
+
|
|
60
72
|
## Code Commits
|
|
61
73
|
|
|
62
74
|
Format using angular formatting:
|
|
@@ -72,3 +84,4 @@ Format using angular formatting:
|
|
|
72
84
|
When modifying the codebase, keep documentation in sync:
|
|
73
85
|
- **ARCHITECTURE.md** - Update when adding/removing classes, changing component relationships, or altering data flow patterns
|
|
74
86
|
- **README.md** - Update when adding new features, changing public APIs, or modifying usage examples
|
|
87
|
+
- **CHANGELOG.md** - Generated by release-please from commit messages; don't edit by hand except to fix release notes
|
data/Gemfile
CHANGED
data/Gemfile.lock
CHANGED
|
@@ -1,9 +1,9 @@
|
|
|
1
1
|
GEM
|
|
2
|
-
remote:
|
|
2
|
+
remote: https://rubygems.org/
|
|
3
3
|
specs:
|
|
4
|
-
activemodel (8.1.
|
|
5
|
-
activesupport (= 8.1.
|
|
6
|
-
activesupport (8.1.
|
|
4
|
+
activemodel (8.1.4)
|
|
5
|
+
activesupport (= 8.1.4)
|
|
6
|
+
activesupport (8.1.4)
|
|
7
7
|
base64
|
|
8
8
|
bigdecimal
|
|
9
9
|
concurrent-ruby (~> 1.0, >= 1.3.1)
|
|
@@ -18,31 +18,30 @@ GEM
|
|
|
18
18
|
uri (>= 0.13.1)
|
|
19
19
|
ast (2.4.3)
|
|
20
20
|
base64 (0.3.0)
|
|
21
|
-
bigdecimal (4.
|
|
22
|
-
concurrent-ruby (1.3.
|
|
21
|
+
bigdecimal (4.1.3)
|
|
22
|
+
concurrent-ruby (1.3.8)
|
|
23
23
|
connection_pool (3.0.2)
|
|
24
|
-
csv (3.3.
|
|
24
|
+
csv (3.3.6)
|
|
25
25
|
diff-lcs (1.6.2)
|
|
26
|
-
docile (1.4.1)
|
|
27
26
|
drb (2.2.3)
|
|
28
|
-
i18n (1.
|
|
27
|
+
i18n (1.15.2)
|
|
29
28
|
concurrent-ruby (~> 1.0)
|
|
30
29
|
inheritance-helper (0.2.5)
|
|
31
|
-
json (
|
|
32
|
-
language_server-protocol (3.17.0.
|
|
30
|
+
json (3.0.2)
|
|
31
|
+
language_server-protocol (3.17.0.6)
|
|
33
32
|
lint_roller (1.1.0)
|
|
34
33
|
logger (1.7.0)
|
|
35
|
-
minitest (6.0.
|
|
34
|
+
minitest (6.0.6)
|
|
35
|
+
drb (~> 2.0)
|
|
36
36
|
prism (~> 1.5)
|
|
37
|
-
parallel (
|
|
38
|
-
parser (3.3.
|
|
37
|
+
parallel (2.3.0)
|
|
38
|
+
parser (3.3.12.0)
|
|
39
39
|
ast (~> 2.4.1)
|
|
40
40
|
racc
|
|
41
41
|
prism (1.9.0)
|
|
42
42
|
racc (1.8.1)
|
|
43
43
|
rainbow (3.1.1)
|
|
44
|
-
regexp_parser (2.
|
|
45
|
-
rexml (3.4.4)
|
|
44
|
+
regexp_parser (2.13.1)
|
|
46
45
|
rspec (3.13.2)
|
|
47
46
|
rspec-core (~> 3.13.0)
|
|
48
47
|
rspec-expectations (~> 3.13.0)
|
|
@@ -52,45 +51,37 @@ GEM
|
|
|
52
51
|
rspec-expectations (3.13.5)
|
|
53
52
|
diff-lcs (>= 1.2.0, < 2.0)
|
|
54
53
|
rspec-support (~> 3.13.0)
|
|
55
|
-
rspec-mocks (3.13.
|
|
54
|
+
rspec-mocks (3.13.8)
|
|
56
55
|
diff-lcs (>= 1.2.0, < 2.0)
|
|
57
56
|
rspec-support (~> 3.13.0)
|
|
58
57
|
rspec-support (3.13.7)
|
|
59
|
-
rubocop (1.
|
|
60
|
-
json (
|
|
58
|
+
rubocop (1.91.0)
|
|
59
|
+
json (>= 2.3)
|
|
61
60
|
language_server-protocol (~> 3.17.0.2)
|
|
62
61
|
lint_roller (~> 1.1.0)
|
|
63
|
-
parallel (
|
|
62
|
+
parallel (>= 1.10)
|
|
64
63
|
parser (>= 3.3.0.2)
|
|
65
64
|
rainbow (>= 2.2.2, < 4.0)
|
|
66
65
|
regexp_parser (>= 2.9.3, < 3.0)
|
|
67
66
|
rubocop-ast (>= 1.49.0, < 2.0)
|
|
68
67
|
ruby-progressbar (~> 1.7)
|
|
69
68
|
unicode-display_width (>= 2.4.0, < 4.0)
|
|
70
|
-
rubocop-ast (1.
|
|
69
|
+
rubocop-ast (1.50.0)
|
|
71
70
|
parser (>= 3.3.7.2)
|
|
72
71
|
prism (~> 1.7)
|
|
73
72
|
ruby-progressbar (1.13.0)
|
|
74
73
|
securerandom (0.4.1)
|
|
75
|
-
simplecov (
|
|
76
|
-
docile (~> 1.1)
|
|
77
|
-
simplecov-html (~> 0.11)
|
|
78
|
-
simplecov_json_formatter (~> 0.1)
|
|
79
|
-
simplecov-cobertura (3.1.0)
|
|
80
|
-
rexml
|
|
81
|
-
simplecov (~> 0.19)
|
|
82
|
-
simplecov-html (0.13.2)
|
|
83
|
-
simplecov_json_formatter (0.1.4)
|
|
74
|
+
simplecov (1.3.2)
|
|
84
75
|
tzinfo (2.0.6)
|
|
85
76
|
concurrent-ruby (~> 1.0)
|
|
86
|
-
unicode-display_width (3.
|
|
87
|
-
unicode-emoji (~> 4.
|
|
88
|
-
unicode-emoji (4.
|
|
77
|
+
unicode-display_width (3.3.0)
|
|
78
|
+
unicode-emoji (~> 4.3)
|
|
79
|
+
unicode-emoji (4.3.0)
|
|
89
80
|
uri (1.1.1)
|
|
90
81
|
|
|
91
82
|
PLATFORMS
|
|
92
|
-
arm64-darwin-24
|
|
93
83
|
ruby
|
|
84
|
+
x86_64-darwin-24
|
|
94
85
|
|
|
95
86
|
DEPENDENCIES
|
|
96
87
|
activemodel
|
|
@@ -99,7 +90,45 @@ DEPENDENCIES
|
|
|
99
90
|
rspec
|
|
100
91
|
rubocop
|
|
101
92
|
simplecov
|
|
102
|
-
|
|
93
|
+
|
|
94
|
+
CHECKSUMS
|
|
95
|
+
activemodel (8.1.4) sha256=9a0906c127bd118aa5f06157db12b8f750da83dccee72db2f658547a0ae7a0e8
|
|
96
|
+
activesupport (8.1.4) sha256=891dd3fe43c4fc8391934b237b1189bb1f661c932a84a840043db9d46a4e80df
|
|
97
|
+
ast (2.4.3) sha256=954615157c1d6a382bc27d690d973195e79db7f55e9765ac7c481c60bdb4d383
|
|
98
|
+
base64 (0.3.0) sha256=27337aeabad6ffae05c265c450490628ef3ebd4b67be58257393227588f5a97b
|
|
99
|
+
bigdecimal (4.1.3) sha256=61ebe1e5e559bdc3cc6f2c0ee7f427321fc838f59611c294356eb04d6e21cf66
|
|
100
|
+
concurrent-ruby (1.3.8) sha256=b2f1be836e968ccc78ccfce277ea79c72a88633f22306782c16ff23fb415d1e1
|
|
101
|
+
connection_pool (3.0.2) sha256=33fff5ba71a12d2aa26cb72b1db8bba2a1a01823559fb01d29eb74c286e62e0a
|
|
102
|
+
csv (3.3.6) sha256=aba61e7e507a66f03d45cb1f3c4b6359861c3504038b422962875dce099e4456
|
|
103
|
+
diff-lcs (1.6.2) sha256=9ae0d2cba7d4df3075fe8cd8602a8604993efc0dfa934cff568969efb1909962
|
|
104
|
+
drb (2.2.3) sha256=0b00d6fdb50995fe4a45dea13663493c841112e4068656854646f418fda13373
|
|
105
|
+
i18n (1.15.2) sha256=00f9eb62412fe593b2a65a97daa75300d37abb8f7202ec748e94b6d46a9dd1b5
|
|
106
|
+
inheritance-helper (0.2.5) sha256=e318d548d2e201910ab484b2139a11e61f3dfac012c4aba090bcfaa5f9687b85
|
|
107
|
+
json (3.0.2) sha256=8e6d7e7b11384c21230430cef90b71f14849a34a1f4452796670f7c981bd19df
|
|
108
|
+
language_server-protocol (3.17.0.6) sha256=5ef2c0c138f8267e1bc631d3328347d354f96724b0af22f2c79516120443b7f0
|
|
109
|
+
lint_roller (1.1.0) sha256=2c0c845b632a7d172cb849cc90c1bce937a28c5c8ccccb50dfd46a485003cc87
|
|
110
|
+
logger (1.7.0) sha256=196edec7cc44b66cfb40f9755ce11b392f21f7967696af15d274dde7edff0203
|
|
111
|
+
minitest (6.0.6) sha256=153ea36d1d987a62942382b61075745042a2b3123b1cd48f4c3675af9cc7d6f1
|
|
112
|
+
parallel (2.3.0) sha256=f75a3e904101ce6a1ccb6b8dc800cbafd42166d83330192438dcf29395167a6f
|
|
113
|
+
parser (3.3.12.0) sha256=21a6d7f755d5a24dfbdc6e6b772e4e879a52e7631a88bc5a3a134606052c9828
|
|
114
|
+
prism (1.9.0) sha256=7b530c6a9f92c24300014919c9dcbc055bf4cdf51ec30aed099b06cd6674ef85
|
|
115
|
+
racc (1.8.1) sha256=4a7f6929691dbec8b5209a0b373bc2614882b55fc5d2e447a21aaa691303d62f
|
|
116
|
+
rainbow (3.1.1) sha256=039491aa3a89f42efa1d6dec2fc4e62ede96eb6acd95e52f1ad581182b79bc6a
|
|
117
|
+
regexp_parser (2.13.1) sha256=5aedb6b7c35688f51e86eef17a15374dfd287c83f4ca644b2c5f3ec50a33b44b
|
|
118
|
+
rspec (3.13.2) sha256=206284a08ad798e61f86d7ca3e376718d52c0bc944626b2349266f239f820587
|
|
119
|
+
rspec-core (3.13.6) sha256=a8823c6411667b60a8bca135364351dda34cd55e44ff94c4be4633b37d828b2d
|
|
120
|
+
rspec-expectations (3.13.5) sha256=33a4d3a1d95060aea4c94e9f237030a8f9eae5615e9bd85718fe3a09e4b58836
|
|
121
|
+
rspec-mocks (3.13.8) sha256=086ad3d3d17533f4237643de0b5c42f04b66348c28bf6b9c2d3f4a3b01af1d47
|
|
122
|
+
rspec-support (3.13.7) sha256=0640e5570872aafefd79867901deeeeb40b0c9875a36b983d85f54fb7381c47c
|
|
123
|
+
rubocop (1.91.0) sha256=9c82b7bf391c5d7e3798c5b9996e22a1fe3bd7468e351dfdeb96140c058296d0
|
|
124
|
+
rubocop-ast (1.50.0) sha256=b9ca88300da0803ee222ad20cdb30494c0a784eed06fdc35d254b06d662788db
|
|
125
|
+
ruby-progressbar (1.13.0) sha256=80fc9c47a9b640d6834e0dc7b3c94c9df37f08cb072b7761e4a71e22cff29b33
|
|
126
|
+
securerandom (0.4.1) sha256=cc5193d414a4341b6e225f0cb4446aceca8e50d5e1888743fac16987638ea0b1
|
|
127
|
+
simplecov (1.3.2) sha256=2a22ec1759f1b8db60881a2e038758956fc69ef93ed669be0bde0678de6d0e85
|
|
128
|
+
tzinfo (2.0.6) sha256=8daf828cc77bcf7d63b0e3bdb6caa47e2272dcfaf4fbfe46f8c3a9df087a829b
|
|
129
|
+
unicode-display_width (3.3.0) sha256=4b7aa66a4b11db50f6f7e98411215cd0dd10eaecb665d36eec5bcacb3fa0b613
|
|
130
|
+
unicode-emoji (4.3.0) sha256=11c02fa73290378c066bb0562cd4c87d8e1b706fbbe0059ca12746a4244de8ce
|
|
131
|
+
uri (1.1.1) sha256=379fa58d27ffb1387eaada68c749d1426738bd0f654d812fcc07e7568f5c57c6
|
|
103
132
|
|
|
104
133
|
BUNDLED WITH
|
|
105
|
-
|
|
134
|
+
4.0.20
|
data/README.md
CHANGED
|
@@ -2,8 +2,9 @@
|
|
|
2
2
|
|
|
3
3
|
A powerful Ruby gem for data transformation, validation, and type safety. Schema provides a flexible and intuitive way to define data models with support for complex nested structures, dynamic associations, and robust validation.
|
|
4
4
|
|
|
5
|
-
[](https://github.com/dougyouch/schema/actions/workflows/ci.yml)
|
|
6
|
-
[](https://github.com/dougyouch/schema/actions/workflows/ci.yml)
|
|
6
|
+
[](https://github.com/dougyouch/schema/actions/workflows/ci.yml)
|
|
7
|
+
[](https://github.com/dougyouch/schema/actions/workflows/ci.yml)
|
|
7
8
|
|
|
8
9
|
## Installation
|
|
9
10
|
|
|
@@ -58,7 +59,7 @@ user.tags # => ["ruby", "rails", "developer"]
|
|
|
58
59
|
attribute :name, :string # String values
|
|
59
60
|
attribute :count, :integer # Integer values (parses "123" to 123)
|
|
60
61
|
attribute :price, :float # Float values (parses "9.99" to 9.99)
|
|
61
|
-
attribute :active, :boolean #
|
|
62
|
+
attribute :active, :boolean # true for 1, t, true, on, y, yes (any case); other strings are false; numbers are true unless 0
|
|
62
63
|
attribute :notes, :string_or_nil # String, but returns nil if empty
|
|
63
64
|
```
|
|
64
65
|
|
|
@@ -81,6 +82,8 @@ attribute :metadata, :hash # Hash/dictionary values
|
|
|
81
82
|
attribute :config, :json # Parse JSON strings
|
|
82
83
|
```
|
|
83
84
|
|
|
85
|
+
Unlike the basic types, `:array`, `:hash` and `:json` record an `incompatible` parsing error when given `nil`.
|
|
86
|
+
|
|
84
87
|
## Attribute Options
|
|
85
88
|
|
|
86
89
|
### Aliases
|
|
@@ -99,8 +102,11 @@ attribute :name, :string, aliases: [:full_name, :display_name]
|
|
|
99
102
|
attribute :status, :string, default: 'pending'
|
|
100
103
|
attribute :count, :integer, default: 0
|
|
101
104
|
attribute :tags, :array, default: []
|
|
105
|
+
attribute :starts_on, :date, default: Date.new(2024, 1, 1)
|
|
102
106
|
```
|
|
103
107
|
|
|
108
|
+
Each read of a default returns a fresh copy (frozen values are shared), so mutating it doesn't change the default for other instances.
|
|
109
|
+
|
|
104
110
|
### Checking If Attribute Was Set
|
|
105
111
|
|
|
106
112
|
Every attribute generates a `_was_set?` predicate method:
|
|
@@ -170,10 +176,19 @@ order.items.first.sku # => "ABC"
|
|
|
170
176
|
has_one :profile, default: true
|
|
171
177
|
has_many :tags, default: true
|
|
172
178
|
|
|
179
|
+
# Aliases for the association key
|
|
180
|
+
has_one :profile, alias: :user_profile
|
|
181
|
+
has_many :items, aliases: [:line_items]
|
|
182
|
+
|
|
173
183
|
# Reuse existing schema class
|
|
174
184
|
has_one :shipping_address, base_class: AddressSchema
|
|
175
185
|
has_one :billing_address, base_class: AddressSchema
|
|
176
186
|
|
|
187
|
+
# Name the generated class (defaults to SchemaHasOneProfile, SchemaHasManyItems, ...)
|
|
188
|
+
has_one :profile, class_name: 'ProfileSchema' do
|
|
189
|
+
attribute :bio, :string
|
|
190
|
+
end
|
|
191
|
+
|
|
177
192
|
# Has many from hash (keyed by field)
|
|
178
193
|
has_many :items, from: :hash, hash_key_field: :id do
|
|
179
194
|
attribute :id, :string
|
|
@@ -235,7 +250,8 @@ company.employees[1].team_size # => 5
|
|
|
235
250
|
```ruby
|
|
236
251
|
# Type field within nested data (default)
|
|
237
252
|
has_many :items, type_field: :kind do
|
|
238
|
-
# looks for :kind in each item's data
|
|
253
|
+
# looks for :kind in each item's data, or an alias of the kind attribute
|
|
254
|
+
attribute :kind, :string, alias: :category
|
|
239
255
|
end
|
|
240
256
|
|
|
241
257
|
# Type determined by parent field
|
|
@@ -249,6 +265,8 @@ has_many :items, type_field: :type, type_ignorecase: true do
|
|
|
249
265
|
end
|
|
250
266
|
```
|
|
251
267
|
|
|
268
|
+
When the type matches no `add_type` and there is no `default_type`, the association is `nil` and the parent records an `unknown` parsing error.
|
|
269
|
+
|
|
252
270
|
## Validation and Error Handling
|
|
253
271
|
|
|
254
272
|
### ActiveModel Validations
|
|
@@ -269,16 +287,36 @@ end
|
|
|
269
287
|
|
|
270
288
|
### Parsing Errors vs Validation Errors
|
|
271
289
|
|
|
290
|
+
Parsing errors and validation errors are tracked separately. `valid?` only runs validations, so check `parsed?` too (or call `valid!`, which checks both).
|
|
291
|
+
|
|
272
292
|
```ruby
|
|
273
|
-
user = UserSchema.from_hash(name: 'John', age: 'not-a-number')
|
|
293
|
+
user = UserSchema.from_hash(name: 'John', email: 'john@example.com', age: 'not-a-number')
|
|
274
294
|
|
|
275
295
|
# Parsing errors (type conversion failures)
|
|
276
296
|
user.parsing_errors.empty? # => false
|
|
277
297
|
user.parsed? # => false
|
|
298
|
+
user.age # => nil
|
|
278
299
|
|
|
279
300
|
# Validation errors (business rules)
|
|
280
|
-
user.valid? # =>
|
|
281
|
-
user.errors.full_messages # => [
|
|
301
|
+
user.valid? # => true (age is nil, which allow_nil permits)
|
|
302
|
+
user.errors.full_messages # => []
|
|
303
|
+
```
|
|
304
|
+
|
|
305
|
+
### Validating Nested Schemas
|
|
306
|
+
|
|
307
|
+
`validates ..., schema: true` (`SchemaValidator`) marks the parent invalid when a nested model, or any model in a has-many list, is invalid:
|
|
308
|
+
|
|
309
|
+
```ruby
|
|
310
|
+
class OrderSchema
|
|
311
|
+
include Schema::All
|
|
312
|
+
|
|
313
|
+
has_one :customer do
|
|
314
|
+
attribute :name, :string
|
|
315
|
+
validates :name, presence: true
|
|
316
|
+
end
|
|
317
|
+
|
|
318
|
+
validates :customer, presence: true, schema: true
|
|
319
|
+
end
|
|
282
320
|
```
|
|
283
321
|
|
|
284
322
|
### Raising Exceptions
|
|
@@ -311,12 +349,21 @@ user.parsing_errors[:unknown_field] # => ["unknown_attribute"]
|
|
|
311
349
|
UserSchema.capture_unknown_attributes = false
|
|
312
350
|
```
|
|
313
351
|
|
|
352
|
+
The setting is per class. Nested association classes keep their own setting, so an unknown key inside a nested hash still makes the parent record an `invalid` error for that association.
|
|
353
|
+
|
|
314
354
|
## Serialization
|
|
315
355
|
|
|
316
356
|
### to_hash / as_json
|
|
317
357
|
|
|
318
358
|
```ruby
|
|
319
|
-
|
|
359
|
+
class ContactSchema
|
|
360
|
+
include Schema::All
|
|
361
|
+
|
|
362
|
+
attribute :name, :string
|
|
363
|
+
attribute :email, :string
|
|
364
|
+
end
|
|
365
|
+
|
|
366
|
+
user = ContactSchema.from_hash(name: 'John', email: nil)
|
|
320
367
|
|
|
321
368
|
user.to_hash # => { name: "John", email: nil }
|
|
322
369
|
user.as_json # => { name: "John" } (excludes nils)
|
|
@@ -347,17 +394,23 @@ user.created_at # => nil (not set)
|
|
|
347
394
|
|
|
348
395
|
# Nested skip_fields for associations
|
|
349
396
|
order = OrderSchema.from_hash(data, [:id, { items: [:id] }])
|
|
397
|
+
|
|
398
|
+
# Skip an association entirely
|
|
399
|
+
order = OrderSchema.from_hash(data, [:items])
|
|
350
400
|
```
|
|
351
401
|
|
|
402
|
+
A skipped field is ignored whether the data uses symbol keys, string keys, or one of the field's aliases.
|
|
403
|
+
|
|
352
404
|
## Array and CSV Support
|
|
353
405
|
|
|
354
406
|
### Schema::Arrays Module
|
|
355
407
|
|
|
356
|
-
Convert models to/from flat arrays (useful for CSV/spreadsheet data):
|
|
408
|
+
Convert models to/from flat arrays (useful for CSV/spreadsheet data). `from_array` takes the header mapping built by `Schema::ArrayHeaders`, so include both:
|
|
357
409
|
|
|
358
410
|
```ruby
|
|
359
411
|
class UserSchema
|
|
360
412
|
include Schema::All
|
|
413
|
+
schema_include Schema::ArrayHeaders
|
|
361
414
|
schema_include Schema::Arrays
|
|
362
415
|
|
|
363
416
|
attribute :name, :string
|
|
@@ -377,21 +430,36 @@ mapped = UserSchema.map_headers_to_attributes(headers)
|
|
|
377
430
|
user = UserSchema.from_array(['Jane', 'jane@example.com'], mapped)
|
|
378
431
|
```
|
|
379
432
|
|
|
380
|
-
|
|
433
|
+
`to_headers`, `to_empty_array` and `to_a` need a fixed number of columns for each has-many association, so give it a `size`:
|
|
381
434
|
|
|
382
|
-
|
|
435
|
+
```ruby
|
|
436
|
+
has_many :phones, size: 3 do
|
|
437
|
+
attribute :number, :string
|
|
438
|
+
end
|
|
439
|
+
|
|
440
|
+
UserSchema.to_headers # => [..., "phones[1].number", "phones[2].number", "phones[3].number"]
|
|
441
|
+
```
|
|
442
|
+
|
|
443
|
+
### Schema::CSVParser
|
|
444
|
+
|
|
445
|
+
`Schema::CSVParser` is a class that reads rows from a `CSV` object into models. The model needs `Schema::ArrayHeaders` and `Schema::Arrays`. Pass a plain `CSV` (not one created with `headers: true`); the first row is used as the headers unless you pass them in:
|
|
383
446
|
|
|
384
447
|
```ruby
|
|
448
|
+
require 'csv'
|
|
449
|
+
|
|
385
450
|
class UserCSVSchema
|
|
386
|
-
include Schema::
|
|
387
|
-
|
|
451
|
+
include Schema::All
|
|
452
|
+
schema_include Schema::ArrayHeaders
|
|
453
|
+
schema_include Schema::Arrays
|
|
388
454
|
|
|
389
455
|
attribute :name, :string
|
|
390
456
|
attribute :email, :string
|
|
391
457
|
end
|
|
392
458
|
|
|
393
|
-
|
|
394
|
-
parser = Schema::CSVParser.new(
|
|
459
|
+
csv = CSV.new("name,email\nJohn,john@example.com\n")
|
|
460
|
+
parser = Schema::CSVParser.new(csv, UserCSVSchema)
|
|
461
|
+
|
|
462
|
+
parser.missing_fields(%w[name email phone]) # => ["phone"]
|
|
395
463
|
|
|
396
464
|
parser.each do |user|
|
|
397
465
|
puts user.name
|
|
@@ -415,10 +483,13 @@ headers = ['FullName', 'email', 'unknown_column']
|
|
|
415
483
|
mapped = UserSchema.map_headers_to_attributes(headers)
|
|
416
484
|
# => { name: { index: 0 }, email: { index: 1 } }
|
|
417
485
|
|
|
418
|
-
|
|
486
|
+
# Field names are reported by their first alias when they have one
|
|
487
|
+
UserSchema.get_mapped_field_names(mapped) # => ["FullName", "email"]
|
|
419
488
|
UserSchema.get_unmapped_field_names(mapped) # => []
|
|
420
489
|
```
|
|
421
490
|
|
|
491
|
+
Has-many columns are matched by the association name (or its aliases) followed by a 1-based index and the field key, e.g. `Phones1Number`, `Phones2Number` for `has_many :phones, alias: 'Phones'` with `attribute :number, :string, alias: 'Number'`.
|
|
492
|
+
|
|
422
493
|
## Extending Schemas
|
|
423
494
|
|
|
424
495
|
### schema_include
|
data/lib/schema/arrays.rb
CHANGED
|
@@ -137,7 +137,7 @@ module Schema
|
|
|
137
137
|
mapped_model.each_value do |info|
|
|
138
138
|
if info[:indexes]
|
|
139
139
|
size = info[:indexes].size if info[:indexes].size > size
|
|
140
|
-
|
|
140
|
+
else
|
|
141
141
|
new_size = largest_number_of_indexes_from_map(info)
|
|
142
142
|
size = new_size if new_size > size
|
|
143
143
|
end
|
|
@@ -10,7 +10,6 @@ module Schema
|
|
|
10
10
|
options = base_schema.class.schema[name]
|
|
11
11
|
@schema_name = name
|
|
12
12
|
@schema_class = base_schema.class.const_get(options[:class_name])
|
|
13
|
-
@aliases = options.fetch(:aliases, [])
|
|
14
13
|
@ignorecase = options[:type_ignorecase]
|
|
15
14
|
@is_list = options[:from] != :hash
|
|
16
15
|
@hash_key_field = options[:hash_key_field]
|
|
@@ -38,7 +37,7 @@ module Schema
|
|
|
38
37
|
elsif !is_list? && list.is_a?(Hash)
|
|
39
38
|
list.map do |(key, data)|
|
|
40
39
|
schema = create_schema(base_schema, data, "#{@schema_name}:#{key}", skip_fields)
|
|
41
|
-
schema
|
|
40
|
+
schema&.public_send(schema.class.schema[@hash_key_field][:setter], key)
|
|
42
41
|
schema
|
|
43
42
|
end
|
|
44
43
|
elsif !list.nil?
|
|
@@ -86,16 +85,17 @@ module Schema
|
|
|
86
85
|
return type if type
|
|
87
86
|
end
|
|
88
87
|
nil
|
|
89
|
-
|
|
88
|
+
else
|
|
90
89
|
base_schema.public_send(@external_type_field)
|
|
91
90
|
end
|
|
92
91
|
end
|
|
93
92
|
|
|
93
|
+
# the type field and its attribute aliases, as symbols and strings
|
|
94
94
|
def type_fields
|
|
95
|
-
@type_fields ||=
|
|
96
|
-
@type_field,
|
|
97
|
-
@type_field.to_s
|
|
98
|
-
|
|
95
|
+
@type_fields ||= begin
|
|
96
|
+
aliases = @schema_class.schema.dig(@type_field.to_sym, :aliases) || []
|
|
97
|
+
[@type_field, *aliases].flat_map { |name| [name.to_sym, name.to_s] }.uniq
|
|
98
|
+
end
|
|
99
99
|
end
|
|
100
100
|
|
|
101
101
|
def add_parsing_error(base_schema, error_name, error_msg)
|
data/lib/schema/model.rb
CHANGED
|
@@ -25,21 +25,17 @@ module Schema
|
|
|
25
25
|
|
|
26
26
|
# no-doc
|
|
27
27
|
module ClassMethods
|
|
28
|
-
def self.include(base)
|
|
29
|
-
base.capture_unknown_attributes = true
|
|
30
|
-
end
|
|
31
|
-
|
|
32
28
|
def schema
|
|
33
29
|
{}.freeze
|
|
34
30
|
end
|
|
35
31
|
|
|
32
|
+
# rebuilt whenever schema is redefined, e.g. by an attribute added after the first lookup
|
|
36
33
|
def schema_with_string_keys
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
end
|
|
34
|
+
current_schema = schema
|
|
35
|
+
return @schema_with_string_keys if @schema_with_string_keys_source.equal?(current_schema)
|
|
36
|
+
|
|
37
|
+
@schema_with_string_keys_source = current_schema
|
|
38
|
+
@schema_with_string_keys = current_schema.transform_keys(&:to_s).freeze
|
|
43
39
|
end
|
|
44
40
|
|
|
45
41
|
def schema_config
|
|
@@ -120,9 +116,10 @@ module Schema
|
|
|
120
116
|
end
|
|
121
117
|
|
|
122
118
|
def update_attributes(data = nil, skip_fields = [])
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
119
|
+
return self if data.nil?
|
|
120
|
+
|
|
121
|
+
update_model_attributes(data, skip_fields)
|
|
122
|
+
update_associations(data, skip_fields)
|
|
126
123
|
self
|
|
127
124
|
end
|
|
128
125
|
|
|
@@ -135,11 +132,7 @@ module Schema
|
|
|
135
132
|
next if opts[:select_filter] && !opts[:select_filter].call(field_name, value, field_options)
|
|
136
133
|
next if opts[:reject_filter]&.call(field_name, value, field_options)
|
|
137
134
|
|
|
138
|
-
memo[field_name] =
|
|
139
|
-
value.map { |e| e.as_json(opts) }
|
|
140
|
-
else
|
|
141
|
-
value.respond_to?(:as_json) ? value.as_json(opts) : value
|
|
142
|
-
end
|
|
135
|
+
memo[field_name] = value.is_a?(Array) ? value.map { |e| value_as_json(e, opts) } : value_as_json(value, opts)
|
|
143
136
|
end
|
|
144
137
|
end
|
|
145
138
|
|
|
@@ -160,38 +153,54 @@ module Schema
|
|
|
160
153
|
|
|
161
154
|
private
|
|
162
155
|
|
|
163
|
-
|
|
164
|
-
|
|
156
|
+
# values without as_json (no ActiveSupport JSON extension) are returned as is
|
|
157
|
+
def value_as_json(value, opts)
|
|
158
|
+
value.respond_to?(:as_json) ? value.as_json(opts) : value
|
|
159
|
+
end
|
|
165
160
|
|
|
166
|
-
|
|
167
|
-
|
|
161
|
+
# symbol keys use the schema directly, anything else is matched by its string form
|
|
162
|
+
def field_options_for_key(key)
|
|
163
|
+
key.is_a?(Symbol) ? self.class.schema[key] : self.class.schema_with_string_keys[key.to_s]
|
|
164
|
+
end
|
|
168
165
|
|
|
169
|
-
|
|
166
|
+
# names a field can be listed under in skip_fields: its name or the alias used in the data
|
|
167
|
+
def skip_field_names(field_options)
|
|
168
|
+
[field_options[:name], field_options[:key]].flat_map { |name| [name.to_sym, name.to_s] }
|
|
170
169
|
end
|
|
171
170
|
|
|
172
|
-
def update_model_attributes(
|
|
171
|
+
def update_model_attributes(data, skip_fields)
|
|
173
172
|
data.each do |key, value|
|
|
174
|
-
unless
|
|
173
|
+
unless (field_options = field_options_for_key(key))
|
|
175
174
|
parsing_errors.add(key, ::Schema::ParsingErrors::UNKNOWN_ATTRIBUTE) if self.class.capture_unknown_attributes?
|
|
176
175
|
next
|
|
177
176
|
end
|
|
178
177
|
|
|
179
|
-
next if
|
|
180
|
-
next if skip_fields.
|
|
178
|
+
next if field_options[:association]
|
|
179
|
+
next if skip_fields.intersect?(skip_field_names(field_options))
|
|
181
180
|
|
|
182
|
-
public_send(
|
|
181
|
+
public_send(field_options[:setter], value)
|
|
183
182
|
end
|
|
184
183
|
end
|
|
185
184
|
|
|
186
|
-
def update_associations(
|
|
185
|
+
def update_associations(data, skip_fields)
|
|
187
186
|
data.each do |key, value|
|
|
188
|
-
next unless
|
|
189
|
-
next unless
|
|
187
|
+
next unless (field_options = field_options_for_key(key))
|
|
188
|
+
next unless field_options[:association]
|
|
189
|
+
|
|
190
|
+
names = skip_field_names(field_options)
|
|
191
|
+
next if skip_fields.intersect?(names)
|
|
192
|
+
|
|
193
|
+
public_send(field_options[:setter], value, association_skip_fields(skip_fields, names))
|
|
194
|
+
end
|
|
195
|
+
end
|
|
196
|
+
|
|
197
|
+
def association_skip_fields(skip_fields, names)
|
|
198
|
+
skip_fields.each do |skip_field|
|
|
199
|
+
next unless skip_field.is_a?(Hash)
|
|
190
200
|
|
|
191
|
-
|
|
192
|
-
association_skip_fields = association_skip_fields ? association_skip_fields[key] : []
|
|
193
|
-
public_send(schema[key][:setter], value, association_skip_fields)
|
|
201
|
+
names.each { |name| return skip_field[name] if skip_field.key?(name) }
|
|
194
202
|
end
|
|
203
|
+
[]
|
|
195
204
|
end
|
|
196
205
|
end
|
|
197
206
|
end
|
|
@@ -6,9 +6,9 @@ module Schema
|
|
|
6
6
|
module Parsers
|
|
7
7
|
# Schema::Parsers::Common are parser methods for basic types
|
|
8
8
|
module Common
|
|
9
|
-
INTEGER_REGEX =
|
|
10
|
-
FLOAT_REGEX =
|
|
11
|
-
BOOLEAN_REGEX =
|
|
9
|
+
INTEGER_REGEX = /\A[-+]?(?:[1-9]\d*|0)(?:\.0+)?\z/
|
|
10
|
+
FLOAT_REGEX = /\A[-+]?(?:[1-9]\d*|0)(?:\.\d+)?([Ee]-?\d+)?\z/
|
|
11
|
+
BOOLEAN_REGEX = /\A(?:1|t|true|on|y|yes)\z/i
|
|
12
12
|
|
|
13
13
|
def parse_integer(field_name, parsing_errors, value)
|
|
14
14
|
case value
|
|
@@ -22,8 +22,7 @@ module Schema
|
|
|
22
22
|
nil
|
|
23
23
|
end
|
|
24
24
|
when Float
|
|
25
|
-
|
|
26
|
-
value.to_i
|
|
25
|
+
parse_float_as_integer(field_name, parsing_errors, value)
|
|
27
26
|
when nil
|
|
28
27
|
nil
|
|
29
28
|
else
|
|
@@ -32,6 +31,16 @@ module Schema
|
|
|
32
31
|
end
|
|
33
32
|
end
|
|
34
33
|
|
|
34
|
+
def parse_float_as_integer(field_name, parsing_errors, value)
|
|
35
|
+
unless value.finite?
|
|
36
|
+
parsing_errors.add(field_name, ::Schema::ParsingErrors::INCOMPATIBLE)
|
|
37
|
+
return nil
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
parsing_errors.add(field_name, ::Schema::ParsingErrors::INCOMPATIBLE) if (value % 1) > 0.0
|
|
41
|
+
value.to_i
|
|
42
|
+
end
|
|
43
|
+
|
|
35
44
|
def parse_string(field_name, parsing_errors, value)
|
|
36
45
|
case value
|
|
37
46
|
when String
|
data/lib/schema/utils.rb
CHANGED
|
@@ -56,13 +56,20 @@ module Schema
|
|
|
56
56
|
kls.send(:include, ::Schema::Associations::DynamicTypes)
|
|
57
57
|
end
|
|
58
58
|
|
|
59
|
+
# each call gets its own copy so callers can't mutate the shared default
|
|
60
|
+
def copy_default(value)
|
|
61
|
+
return value if value.frozen?
|
|
62
|
+
|
|
63
|
+
Marshal.load(Marshal.dump(value))
|
|
64
|
+
rescue TypeError
|
|
65
|
+
value.dup
|
|
66
|
+
end
|
|
67
|
+
|
|
59
68
|
def add_attribute_default_methods(kls, options)
|
|
69
|
+
default = options[:default]
|
|
70
|
+
kls.send(:define_method, options[:default_method]) { ::Schema::Utils.copy_default(default) }
|
|
60
71
|
kls.class_eval(
|
|
61
72
|
<<-STR, __FILE__, __LINE__ + 1
|
|
62
|
-
def #{options[:default_method]}
|
|
63
|
-
#{options[:default].inspect}
|
|
64
|
-
end
|
|
65
|
-
|
|
66
73
|
def #{options[:getter]}
|
|
67
74
|
if #{options[:instance_variable]}.nil?
|
|
68
75
|
#{options[:default_method]}
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
{
|
|
2
|
+
"$schema": "https://raw.githubusercontent.com/googleapis/release-please/main/schemas/config.json",
|
|
3
|
+
"bootstrap-sha": "de8a6c418c6b662696e48b7af3b2d8ad55fb3b4e",
|
|
4
|
+
"packages": {
|
|
5
|
+
".": {
|
|
6
|
+
"release-type": "ruby",
|
|
7
|
+
"package-name": "schema-model",
|
|
8
|
+
"version-file": "lib/schema/version.rb",
|
|
9
|
+
"include-component-in-tag": false
|
|
10
|
+
}
|
|
11
|
+
}
|
|
12
|
+
}
|
data/schema-model.gemspec
CHANGED
|
@@ -1,12 +1,16 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
|
+
require_relative 'lib/schema/version'
|
|
4
|
+
|
|
3
5
|
Gem::Specification.new do |s|
|
|
4
6
|
s.name = 'schema-model'
|
|
5
|
-
s.version =
|
|
7
|
+
s.version = Schema::VERSION
|
|
6
8
|
s.licenses = ['MIT']
|
|
7
|
-
s.summary = '
|
|
8
|
-
s.description = '
|
|
9
|
-
'
|
|
9
|
+
s.summary = 'Typed Ruby models built from hashes, JSON and CSV rows'
|
|
10
|
+
s.description = 'Declare typed attributes and Schema turns incoming hashes, JSON and CSV rows into Ruby objects. ' \
|
|
11
|
+
'Values are coerced to integers, floats, booleans, dates, times, arrays and hashes, and bad input ' \
|
|
12
|
+
'is recorded as parsing errors instead of raised. Supports nested has_one/has_many associations, ' \
|
|
13
|
+
'polymorphic types chosen by a type field, CSV header mapping, and ActiveModel validations.'
|
|
10
14
|
s.authors = ['Doug Youch']
|
|
11
15
|
s.email = 'dougyouch@gmail.com'
|
|
12
16
|
s.homepage = 'https://github.com/dougyouch/schema'
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
#!/usr/bin/env ruby
|
|
2
|
+
# frozen_string_literal: true
|
|
3
|
+
|
|
4
|
+
# Renders a shields-style SVG badge from SimpleCov's .last_run.json.
|
|
5
|
+
# script/coverage_badge.rb coverage/.last_run.json coverage.svg # line coverage
|
|
6
|
+
# script/coverage_badge.rb coverage/.last_run.json branches.svg branch # branch coverage
|
|
7
|
+
|
|
8
|
+
require 'json'
|
|
9
|
+
|
|
10
|
+
input, output, metric = ARGV
|
|
11
|
+
abort "usage: #{$PROGRAM_NAME} <.last_run.json> <out.svg> [line|branch]" unless input && output
|
|
12
|
+
|
|
13
|
+
result = JSON.parse(File.read(input)).fetch('result')
|
|
14
|
+
title = metric == 'branch' ? 'branches' : 'coverage'
|
|
15
|
+
percent = (metric == 'branch' ? result.fetch('branch') : result['line'] || result.fetch('covered_percent')).to_f
|
|
16
|
+
label = "#{percent.floor(1)}%"
|
|
17
|
+
|
|
18
|
+
color =
|
|
19
|
+
if percent >= 95 then '#4c1'
|
|
20
|
+
elsif percent >= 90 then '#97ca00'
|
|
21
|
+
elsif percent >= 80 then '#dfb317'
|
|
22
|
+
elsif percent >= 70 then '#fe7d37'
|
|
23
|
+
else '#e05d44'
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
# ~7px per character at 11px Verdana, plus padding
|
|
27
|
+
left_width = 63
|
|
28
|
+
right_width = (label.length * 7) + 10
|
|
29
|
+
width = left_width + right_width
|
|
30
|
+
|
|
31
|
+
File.write(output, <<~SVG)
|
|
32
|
+
<svg xmlns="http://www.w3.org/2000/svg" width="#{width}" height="20" role="img" aria-label="#{title}: #{label}">
|
|
33
|
+
<title>#{title}: #{label}</title>
|
|
34
|
+
<linearGradient id="s" x2="0" y2="100%">
|
|
35
|
+
<stop offset="0" stop-color="#bbb" stop-opacity=".1"/>
|
|
36
|
+
<stop offset="1" stop-opacity=".1"/>
|
|
37
|
+
</linearGradient>
|
|
38
|
+
<clipPath id="r"><rect width="#{width}" height="20" rx="3" fill="#fff"/></clipPath>
|
|
39
|
+
<g clip-path="url(#r)">
|
|
40
|
+
<rect width="#{left_width}" height="20" fill="#555"/>
|
|
41
|
+
<rect x="#{left_width}" width="#{right_width}" height="20" fill="#{color}"/>
|
|
42
|
+
<rect width="#{width}" height="20" fill="url(#s)"/>
|
|
43
|
+
</g>
|
|
44
|
+
<g fill="#fff" text-anchor="middle" font-family="Verdana,Geneva,DejaVu Sans,sans-serif" font-size="11">
|
|
45
|
+
<text x="#{left_width / 2}" y="14">#{title}</text>
|
|
46
|
+
<text x="#{left_width + (right_width / 2)}" y="14">#{label}</text>
|
|
47
|
+
</g>
|
|
48
|
+
</svg>
|
|
49
|
+
SVG
|
metadata
CHANGED
|
@@ -1,13 +1,13 @@
|
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
|
2
2
|
name: schema-model
|
|
3
3
|
version: !ruby/object:Gem::Version
|
|
4
|
-
version: 0.7.
|
|
4
|
+
version: 0.7.2
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- Doug Youch
|
|
8
8
|
bindir: bin
|
|
9
9
|
cert_chain: []
|
|
10
|
-
date:
|
|
10
|
+
date: 1980-01-02 00:00:00.000000000 Z
|
|
11
11
|
dependencies:
|
|
12
12
|
- !ruby/object:Gem::Dependency
|
|
13
13
|
name: inheritance-helper
|
|
@@ -23,8 +23,11 @@ dependencies:
|
|
|
23
23
|
- - ">="
|
|
24
24
|
- !ruby/object:Gem::Version
|
|
25
25
|
version: '0'
|
|
26
|
-
description:
|
|
27
|
-
|
|
26
|
+
description: Declare typed attributes and Schema turns incoming hashes, JSON and CSV
|
|
27
|
+
rows into Ruby objects. Values are coerced to integers, floats, booleans, dates,
|
|
28
|
+
times, arrays and hashes, and bad input is recorded as parsing errors instead of
|
|
29
|
+
raised. Supports nested has_one/has_many associations, polymorphic types chosen
|
|
30
|
+
by a type field, CSV header mapping, and ActiveModel validations.
|
|
28
31
|
email: dougyouch@gmail.com
|
|
29
32
|
executables:
|
|
30
33
|
- schema-json2csv
|
|
@@ -32,11 +35,14 @@ extensions: []
|
|
|
32
35
|
extra_rdoc_files: []
|
|
33
36
|
files:
|
|
34
37
|
- ".github/workflows/ci.yml"
|
|
38
|
+
- ".github/workflows/release.yml"
|
|
35
39
|
- ".gitignore"
|
|
40
|
+
- ".release-please-manifest.json"
|
|
36
41
|
- ".rubocop.yml"
|
|
37
42
|
- ".ruby-gemset"
|
|
38
43
|
- ".ruby-version"
|
|
39
44
|
- ARCHITECTURE.md
|
|
45
|
+
- CHANGELOG.md
|
|
40
46
|
- CLAUDE.md
|
|
41
47
|
- Gemfile
|
|
42
48
|
- Gemfile.lock
|
|
@@ -63,9 +69,12 @@ files:
|
|
|
63
69
|
- lib/schema/parsers/json.rb
|
|
64
70
|
- lib/schema/parsing_errors.rb
|
|
65
71
|
- lib/schema/utils.rb
|
|
72
|
+
- lib/schema/version.rb
|
|
66
73
|
- lib/schema_validator.rb
|
|
74
|
+
- release-please-config.json
|
|
67
75
|
- schema-model.gemspec
|
|
68
76
|
- script/console
|
|
77
|
+
- script/coverage_badge.rb
|
|
69
78
|
homepage: https://github.com/dougyouch/schema
|
|
70
79
|
licenses:
|
|
71
80
|
- MIT
|
|
@@ -85,7 +94,7 @@ required_rubygems_version: !ruby/object:Gem::Requirement
|
|
|
85
94
|
- !ruby/object:Gem::Version
|
|
86
95
|
version: '0'
|
|
87
96
|
requirements: []
|
|
88
|
-
rubygems_version:
|
|
97
|
+
rubygems_version: 4.0.20
|
|
89
98
|
specification_version: 4
|
|
90
|
-
summary:
|
|
99
|
+
summary: Typed Ruby models built from hashes, JSON and CSV rows
|
|
91
100
|
test_files: []
|