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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 274769a5b3652ece9a8c2583a8867310882800377563f7e5e8eb387a219ea4a3
4
- data.tar.gz: f42a01e476eeb0101df7e5fbaa8bb9fde390637016876ed749a1f1f850d0ae0e
3
+ metadata.gz: c75de56a15a674efd32d16fdedda825e056c4b71e6e3a1d20c63761ae685c5c2
4
+ data.tar.gz: 25537f375a8da4d892bc4c6950e790d8db3b0859f925d302d27b9bef82a773f9
5
5
  SHA512:
6
- metadata.gz: cef6b96d50da9c84ac9b80afd6c74f6f941668eecea8d7d6dd5260e3e73b0163f7906deb86af4f7bca141efbdccd3fffa2d12d6955fa7986ccc889c89c472194
7
- data.tar.gz: 4372f3250e704cc8569411659663fc946ab3f87b2ea7a6255aca7966dde8dbcff8e2ef8eec50ece7aa3bba5f4ebc01ea352219f79c743db32974663f807e5440
6
+ metadata.gz: ec765c52a43b319a6e47879239f92f268a6c7c359163c001c2d9411f286fa41f7036a6739adf4e3f6531f7f9becc5c191c5ef51610f1128903d94a5f4ab6653e
7
+ data.tar.gz: 39dc1faf72f89f17dc75909421e1b1cc4c1d3e1b04db03661716f4f772a0ef5051ad8046e284ff34185b0da4dc27fdb44aaf720d7441a795fae18cd89c4f36a8
@@ -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
- branches: [master]
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@v4
15
-
16
- - name: Set up Ruby
17
- uses: ruby/setup-ruby@v1
29
+ - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
18
30
  with:
19
- ruby-version: '3.4'
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
- - name: Run RuboCop
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 (Ruby ${{ matrix.ruby }})
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@v4
35
-
36
- - name: Set up Ruby ${{ matrix.ruby }}
37
- uses: ruby/setup-ruby@v1
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: ${{ matrix.ruby }}
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
- - name: Run tests
43
- run: bundle exec rspec
44
-
45
- - name: Upload coverage to Codecov
46
- if: matrix.ruby == '3.4'
47
- uses: codecov/codecov-action@v4
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
- files: coverage/coverage.xml
50
- fail_ci_if_error: false
51
- verbose: true
52
- env:
53
- CODECOV_TOKEN: ${{ secrets.CODECOV_TOKEN }}
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
@@ -0,0 +1,3 @@
1
+ {
2
+ ".": "0.7.2"
3
+ }
data/.ruby-version CHANGED
@@ -1 +1 @@
1
- 3.4.2
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 hash keys, matches against schema, invokes setters
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 key determines the subclass.
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
- Methods `parsed!` and `valid!` raise `ParsingException`/`ValidationException` respectively.
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
@@ -1,6 +1,6 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- source 'http://rubygems.org'
3
+ source 'https://rubygems.org'
4
4
 
5
5
  gem 'inheritance-helper'
6
6
 
@@ -13,5 +13,4 @@ end
13
13
  group :spec do
14
14
  gem 'rspec'
15
15
  gem 'simplecov'
16
- gem 'simplecov-cobertura'
17
16
  end
data/Gemfile.lock CHANGED
@@ -1,9 +1,9 @@
1
1
  GEM
2
- remote: http://rubygems.org/
2
+ remote: https://rubygems.org/
3
3
  specs:
4
- activemodel (8.1.2)
5
- activesupport (= 8.1.2)
6
- activesupport (8.1.2)
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.0.1)
22
- concurrent-ruby (1.3.6)
21
+ bigdecimal (4.1.3)
22
+ concurrent-ruby (1.3.8)
23
23
  connection_pool (3.0.2)
24
- csv (3.3.5)
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.14.8)
27
+ i18n (1.15.2)
29
28
  concurrent-ruby (~> 1.0)
30
29
  inheritance-helper (0.2.5)
31
- json (2.18.0)
32
- language_server-protocol (3.17.0.5)
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.1)
34
+ minitest (6.0.6)
35
+ drb (~> 2.0)
36
36
  prism (~> 1.5)
37
- parallel (1.27.0)
38
- parser (3.3.10.1)
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.11.3)
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.7)
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.84.0)
60
- json (~> 2.3)
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 (~> 1.10)
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.49.0)
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 (0.22.0)
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.2.0)
87
- unicode-emoji (~> 4.1)
88
- unicode-emoji (4.2.0)
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
- simplecov-cobertura
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
- 2.6.2
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
- [![CI](https://github.com/dougyouch/schema/actions/workflows/ci.yml/badge.svg)](https://github.com/dougyouch/schema/actions/workflows/ci.yml)
6
- [![codecov](https://codecov.io/gh/dougyouch/schema/graph/badge.svg)](https://codecov.io/gh/dougyouch/schema)
5
+ [![CI](https://github.com/dougyouch/schema/actions/workflows/ci.yml/badge.svg?branch=master)](https://github.com/dougyouch/schema/actions/workflows/ci.yml)
6
+ [![Coverage](https://raw.githubusercontent.com/dougyouch/schema/badges/coverage.svg)](https://github.com/dougyouch/schema/actions/workflows/ci.yml)
7
+ [![Branch Coverage](https://raw.githubusercontent.com/dougyouch/schema/badges/branches.svg)](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 # Boolean (accepts: 1, t, true, on, y, yes)
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? # => false (also runs validations)
281
- user.errors.full_messages # => ["Age is invalid", ...]
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
- user = UserSchema.from_hash(name: 'John', email: nil)
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
- ### Schema::CSVParser Module
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
- Parse CSV data directly into models:
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::Model
387
- include Schema::CSVParser
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
- csv_data = CSV.parse("name,email\nJohn,john@example.com", headers: true)
394
- parser = Schema::CSVParser.new(csv_data, UserCSVSchema)
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
- UserSchema.get_mapped_field_names(mapped) # => ["name", "email"]
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
- elsif info.is_a?(Hash)
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.send(schema.class.schema[@hash_key_field][:setter], key)
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
- elsif @external_type_field
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
- ] + @aliases
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
- @schema_with_string_keys ||=
38
- begin
39
- hsh = {}
40
- schema.each { |field_name, field_options| hsh[field_name.to_s] = field_options }
41
- hsh.freeze
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
- schema = get_schema(data)
124
- update_model_attributes(schema, data, skip_fields)
125
- update_associations(schema, data, skip_fields)
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] = if value.is_a?(Array)
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
- def get_schema(data)
164
- return self.class.schema_with_string_keys unless data.is_a?(Hash)
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
- first_key = data.each_key.first
167
- return self.class.schema_with_string_keys unless first_key.is_a?(Symbol)
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
- self.class.schema
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(schema, data, skip_fields)
171
+ def update_model_attributes(data, skip_fields)
173
172
  data.each do |key, value|
174
- unless schema.key?(key)
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 schema[key][:association]
180
- next if skip_fields.include?(key)
178
+ next if field_options[:association]
179
+ next if skip_fields.intersect?(skip_field_names(field_options))
181
180
 
182
- public_send(schema[key][:setter], value)
181
+ public_send(field_options[:setter], value)
183
182
  end
184
183
  end
185
184
 
186
- def update_associations(schema, data, skip_fields)
185
+ def update_associations(data, skip_fields)
187
186
  data.each do |key, value|
188
- next unless schema.key?(key)
189
- next unless schema[key][:association]
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
- association_skip_fields = skip_fields.detect { |f| f.is_a?(Hash) && f.include?(key) }
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 = /^[-+]?(?:[1-9]\d*|0)(?:\.0+)?$/
10
- FLOAT_REGEX = /^[-+]?(?:[1-9]\d*|0)(?:\.\d+)?([Ee]-?\d+)?$/
11
- BOOLEAN_REGEX = /^(?:1|t|true|on|y|yes)$/i
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
- parsing_errors.add(field_name, ::Schema::ParsingErrors::INCOMPATIBLE) if (value % 1) > 0.0
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,6 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Schema
4
+ # Gem version, bumped by release-please
5
+ VERSION = '0.7.2'
6
+ end
@@ -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 = '0.7.0'
7
+ s.version = Schema::VERSION
6
8
  s.licenses = ['MIT']
7
- s.summary = 'Data transformation, validation, and type safety for Ruby'
8
- s.description = 'A flexible DSL for defining strongly-typed data models with automatic parsing, ' \
9
- 'nested associations, dynamic types, and ActiveModel validations'
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.0
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: 2026-02-01 00:00:00.000000000 Z
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: A flexible DSL for defining strongly-typed data models with automatic
27
- parsing, nested associations, dynamic types, and ActiveModel validations
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: 3.6.2
97
+ rubygems_version: 4.0.20
89
98
  specification_version: 4
90
- summary: Data transformation, validation, and type safety for Ruby
99
+ summary: Typed Ruby models built from hashes, JSON and CSV rows
91
100
  test_files: []