Skip to content

MoneyWiz read compatibility for schema variants - #37

Open
marcomc wants to merge 12 commits into
ileodo:mainfrom
marcomc:feat/moneywiz-read-compatibility
Open

marcomc wants to merge 12 commits into
ileodo:mainfrom
marcomc:feat/moneywiz-read-compatibility

Conversation

@marcomc

@marcomc marcomc commented Aug 12, 2026

Copy link
Copy Markdown
Contributor

Summary

This PR adds compatibility profiles for three observed MoneyWiz SQLite investment-column layouts: unsuffixed, suffixed, and mixed consumer-specific.
Unknown or ambiguous layouts are rejected rather than mapped speculatively.

MoneyWiz has changed its physical database columns between versions. Investment values may use suffixed names such as ZNUMBEROFSHARES1, unsuffixed names such as ZNUMBEROFSHARES, or a mixture where holdings and transactions use different columns.

Without schema-aware parsing, the API can fail to load a valid database or silently read a financial value belonging to another entity type.

Why the redesign was necessary

Selecting the first available column alias is unsafe because Core Data rows can expose both columns simultaneously:

Consumer Required share column in a mixed schema
Investment holding ZNUMBEROFSHARES
Investment buy/sell transaction ZNUMBEROFSHARES1

Both values can be valid decimals, so selecting the wrong one may not produce an obvious error.

The implementation now detects the complete schema topology once and selects an explicit alias for each consumer.

Schema profiles

Motivation

Individual model rows do not contain enough context to decide what each physical column represents. The decision must be based on the database schema rather than whichever value appears first.

Implementation

src/moneywiz_api/schema_profile.py inspects ZSYNCOBJECT using PRAGMA table_info and identifies:

  • Unsuffixed investment columns.
  • Suffixed investment columns.
  • Mixed holding and transaction columns.
  • Unknown or ambiguous schemas.

Ambiguous schemas are rejected instead of risking incorrect financial data.

src/moneywiz_api/database_accessor.py detects and retains this profile when opening the database so every record receives the same interpretation.

Investment parsing

Motivation

Investment holdings and investment transactions are separate schema consumers. They cannot safely share one global “number of shares” alias.

Buy and sell transactions must also follow the same policy; fixing only one would leave inconsistent transaction behavior.

Implementation

  • src/moneywiz_api/model/investment_holding.py uses the holding-specific share column.
  • src/moneywiz_api/model/transaction.py uses transaction-specific share and price columns for both buy and sell transactions.
  • src/moneywiz_api/model/raw_data_handler.py centralizes required, nullable, aliased, and profile-selected decimal parsing.
  • The investment managers pass the database profile to their model constructors.

Transfer compatibility

Motivation

Some MoneyWiz transfer rows omit a derived amount even when the canonical transaction amount and exchange metadata remain available.

Reconstructing the value blindly is unsafe because a zero exchange rate is invalid and a rounded exchange rate may not reproduce the exact value stored by MoneyWiz.

Implementation

src/moneywiz_api/model/transaction.py now:

  • Reconstructs missing derived transfer amounts when sufficient data exists.
  • Preserves the canonical stored amount when exchange-rate rounding would alter it.
  • Rejects reconstruction when the exchange rate is zero.
  • Normalizes nullable currency values.

Best-effort record loading

Motivation

One malformed or unsupported row should not prevent thousands of otherwise valid records from loading.

At the same time, skipped records must remain observable so callers can diagnose incomplete results.

Implementation

src/moneywiz_api/managers/record_manager.py adds:

  • A construction hook for models requiring schema context.
  • Isolation of expected parsing and validation failures.
  • A load_errors collection containing the record ID, entity type, and exception type.

Unexpected programming and infrastructure errors continue to propagate.

Reduced-schema compatibility

Motivation

Older, reduced, and fixture databases may omit optional MoneyWiz relationship tables or filtered metadata columns.

Those omissions should produce empty optional relationships rather than prevent the main records from loading.

Implementation

  • src/moneywiz_api/database_accessor.py verifies optional tables before querying them.
  • src/moneywiz_api/model/raw_data_handler.py tolerates absent optional filtered fields.

Regression coverage

  • tests/unit/test_schema_profiles.py covers suffixed, unsuffixed, mixed, and ambiguous schemas.
  • tests/unit/test_profiled_investment_values.py verifies consumer-specific values for holdings and both buy and sell transactions.
  • tests/unit/test_transaction_compatibility.py covers transfer reconstruction, zero-rate rejection, rounding preservation, and continued loading after an invalid record.

Validation

  • uv run pytest -q tests/unit — 23 passed
  • uv run ruff check src tests/unit — passed
  • uv run ruff format --check src tests/unit — passed
  • git diff --check — passed

@marcomc
marcomc force-pushed the feat/moneywiz-read-compatibility branch from 5046f6f to 9181c31 Compare August 12, 2026 20:58
)

return constructor(res.fetchone())
return self._construct_record(res.fetchone(), constructor)

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This helper preserves consistent parsing across both supported access paths.

The investment managers already pass the database-level SchemaProfile to
investment constructors. However, get_record() and get_record_by_gid() also
accept model constructors directly. Calling one of those methods with
InvestmentHolding, InvestmentBuyTransaction, or InvestmentSellTransaction
must therefore pass the same profile.

Without this delegation, direct accessor calls would fall back to row-level
alias selection. That is unsafe for mixed schemas, where holdings and
transactions intentionally use different share columns. The same record could
then be interpreted differently depending on whether it was loaded through a
manager or directly through the accessor.

Non-investment constructors retain the original constructor(row) behavior.

@marcomc
marcomc marked this pull request as ready for review August 12, 2026 21:10

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 9181c31626

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment thread src/moneywiz_api/managers/record_manager.py Outdated
Comment thread src/moneywiz_api/schema_profile.py Outdated
Comment thread src/moneywiz_api/schema_profile.py Outdated
@ileodo

ileodo commented Aug 16, 2026

Copy link
Copy Markdown
Owner

could you please check if #40 solve the same issue?

@marcomc

marcomc commented Aug 16, 2026

Copy link
Copy Markdown
Contributor Author

could you please check if #40 solve the same issue?

My reading is that #40 and #37 address related but different schema problems:

The key difference is that #40 resolves aliases row-by-row by selecting the first available column. That cannot safely distinguish those mixed investment layouts when both columns exist.

#37 detects the database schema once, assigns aliases per consumer, and rejects ambiguous layouts. Therefore, #40 is related infrastructure, but it does not replace the consumer-specific investment handling in #37.

I think #37 should be merged for this issue. Useful parts of #40, especially dynamic tag-table discovery, could be adapted separately while preserving #37’s schema-profile logic.

@ileodo

ileodo commented Aug 17, 2026

Copy link
Copy Markdown
Owner

could you please check if #40 solve the same issue?

My reading is that #40 and #37 address related but different schema problems:

The key difference is that #40 resolves aliases row-by-row by selecting the first available column. That cannot safely distinguish those mixed investment layouts when both columns exist.

#37 detects the database schema once, assigns aliases per consumer, and rejects ambiguous layouts. Therefore, #40 is related infrastructure, but it does not replace the consumer-specific investment handling in #37.

I think #37 should be merged for this issue. Useful parts of #40, especially dynamic tag-table discovery, could be adapted separately while preserving #37’s schema-profile logic.

https://github.com/ileodo/moneywiz-api/pull/40/changes#r3794767529 see this comment, for same model class and field, the column (aliases) selection will be deterministic.

@ileodo

ileodo commented Aug 17, 2026

Copy link
Copy Markdown
Owner

also the plan is to keep backward compatibility, then adding more alias options to each field's spec to support different version

Comment thread src/moneywiz_api/model/transaction.py Outdated
Comment on lines +183 to +196
self.number_of_shares = RDH.get_profile_decimal(
row,
schema_profile.transaction_number_of_shares_column
if schema_profile
else None,
"ZNUMBEROFSHARES1",
"ZNUMBEROFSHARES",
)
self.price_per_share = RDH.get_profile_decimal(
row,
schema_profile.price_per_share_column if schema_profile else None,
"ZPRICEPERSHARE1",
"ZPRICEPERSHARE",
)

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

this part can be achieved by adding more options to the fields spec https://github.com/ileodo/moneywiz-api/pull/40/changes#diff-8e3331851f50dcf4188715c82364729044ed90257c12a28557f7c7ed6ff4715dR20 after 40 is merged

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Agreed. I updated the investment mapping to use FieldSpec, following the approach introduced by #40. A field can now select its column through a profile_column selector, and SchemaMappedRow resolves it using the database-level SchemaProfile. Holdings and investment transactions declare their respective profile columns, while manager loads and direct accessor calls use the same generic mapping path. This makes the choice deterministic when multiple aliases are present and removes the investment-specific constructor branches. The tests cover mixed and unsuffixed mappings.

Comment thread src/moneywiz_api/database_accessor.py Outdated
Comment on lines +83 to +89
investment_constructors = {
InvestmentBuyTransaction,
InvestmentSellTransaction,
InvestmentHolding,
}
if constructor in investment_constructors:
return constructor(row, schema_profile=self.schema_profile)

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

not super ideal that having special code path for some certain data model

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Agreed. I removed the investment-specific construction branches from DatabaseAccessor, TransactionManager, and InvestmentHoldingManager. The detected SchemaProfile now flows through the shared mapped_row() / SchemaMappedRow path, while the investment models declare their profile-specific columns in their FieldSpecs. Unsupported profiles still raise UnsupportedInvestmentSchemaError and propagate through RecordManager.load() rather than being silently treated as malformed rows. Tests cover both manager loading and direct accessor construction.

Comment on lines +118 to +119
if not self._table_exists("ZCATEGORYASSIGMENT"):
return transaction_map

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

would this ever happen in a real moneywiz database? or this is more for testing.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I checked my current MoneyWiz database read-only. It does not have this exact partial-profile case: it has ZNUMBEROFSHARES, no ZNUMBEROFSHARES1, and both price columns. The detector resolves it as mixed-investment-columns, and the loaders read 100 holdings, 205 buy transactions, and 76 sell transactions.

The test creates a synthetic partial profile: the holding shares column is known, while the transaction shares and price columns are unresolved. It verifies that holdings can still load using their known column, while transactions fail closed when their required columns are unknown.

So I did not observe this exact case in my database. The test covers a schema edge case: an unresolved transaction mapping should not prevent reading holdings whose mapping is known. It does not add a test-only production mode or a user-selectable option.

Comment thread src/moneywiz_api/database_accessor.py Outdated
Comment on lines +150 to +151
if not self._table_exists("Z_36TAGS"):
return transactions_to_tags

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

apparently the name for tag-transaction relation table can change. (it happen to me after migrating db to iCloud) #40 already change this to be dynamic.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Agreed. Since #37 was rebased after #40, it already includes the dynamic tag lookup

@marcomc
marcomc force-pushed the feat/moneywiz-read-compatibility branch 2 times, most recently from e28b718 to 966dd60 Compare September 15, 2026 19:51
@marcomc

marcomc commented Sep 25, 2026 •

Copy link
Copy Markdown
Contributor Author

also the plan is to keep backward compatibility, then adding more alias options to each field's spec to support different version

Agreed. The PR keeps the investment fields compatible across the supported schema versions by selecting their columns through SchemaProfile. I use profile-based selection for these fields because both aliases can be present but have different meanings for holdings and transactions; choosing the first available alias could read the wrong value. FieldSpec continues to support multiple aliases for fields where ordered fallback is safe.

@marcomc

marcomc commented Sep 25, 2026

Copy link
Copy Markdown
Contributor Author

I pushed the code adjustments based on your comments and replied to each one. @ileodo, could you please do a final review? I hope this is now ready for merging.

@marcomc

marcomc commented Oct 1, 2026

Copy link
Copy Markdown
Contributor Author

@ileodo how are you? I hope you can find some time to review this PR

ileodo commented Oct 3, 2026

Copy link
Copy Markdown
Owner

will take a look next week.

@marcomc

marcomc commented Oct 4, 2026 via email

Copy link
Copy Markdown
Contributor Author

@ileodo

ileodo commented Oct 9, 2026

Copy link
Copy Markdown
Owner

Thank you for your work on this PR and for following up on my review. I understand your point better now: a MoneyWiz database can contain both investment column names. Choosing the first available alias can read a value for the wrong entity. Your use of database and entity-specific evidence addresses that risk. Rejecting a mapping when the evidence is unclear also makes sense.

I refactored the schema code in #42 after our discussion. I borrowed your idea of resolving schema details once for the database and carrying that result through record creation. In #42, field definitions live in one SchemaProfile, inherit across record types, and use the same path for managers and direct accessor calls. I chose this structure so we can add mappings for more database versions without special constructor paths for investment models. #42 gives us that common path, but it does not yet solve the investment column issue you found.

Could you update #37 against the current main and adapt your SchemaProfile detection to the SchemaProfile and SchemaProfileResolver in #42? Please keep separate column choices for holdings and buy/sell transactions, and reject cases where no safe choice is possible. Could you also bring over the _load_errors handling and its public load_errors view? I would like malformed rows to remain visible while a database-wide unsupported schema error still stops loading.

I appreciate your careful code, tests, and patience. Your work showed me a gap in my earlier approach.

Reject zero-rate transfer reconstructions as recoverable parse failures, cover manager recovery, and format schema compatibility tests.
Model holding and transaction share columns independently and preserve canonical stored amounts for rounded transfers.
- Removed the schema_profile.py file and integrated its functionality into the schema module.
- Updated tests to utilize the new schema profile resolution approach.
- Enhanced investment profile creation logic to accommodate custom field mappings.
- Improved test coverage for ambiguous investment column scenarios and error handling.
- Adjusted database setup in tests to ensure proper schema detection and validation.
@marcomc
marcomc force-pushed the feat/moneywiz-read-compatibility branch from 3861a8c to c9a257d Compare October 14, 2026 19:10
@marcomc

marcomc commented Oct 15, 2026

Copy link
Copy Markdown
Contributor Author

Thank you for your work on this PR and for following up on my review. I understand your point better now: a MoneyWiz database can contain both investment column names. Choosing the first available alias can read a value for the wrong entity. Your use of database and entity-specific evidence addresses that risk. Rejecting a mapping when the evidence is unclear also makes sense.

I refactored the schema code in #42 after our discussion. I borrowed your idea of resolving schema details once for the database and carrying that result through record creation. In #42, field definitions live in one SchemaProfile, inherit across record types, and use the same path for managers and direct accessor calls. I chose this structure so we can add mappings for more database versions without special constructor paths for investment models. #42 gives us that common path, but it does not yet solve the investment column issue you found.

Could you update #37 against the current main and adapt your SchemaProfile detection to the SchemaProfile and SchemaProfileResolver in #42? Please keep separate column choices for holdings and buy/sell transactions, and reject cases where no safe choice is possible. Could you also bring over the _load_errors handling and its public load_errors view? I would like malformed rows to remain visible while a database-wide unsupported schema error still stops loading.

I appreciate your careful code, tests, and patience. Your work showed me a gap in my earlier approach.

Thanks @ileodo, I’ve updated #37 against main and adapted the detection to your SchemaProfile and SchemaProfileResolver from #42.
Holdings and buy/sell transactions keep separate column choices, and ambiguous mappings are rejected. I also brought over _load_errors and the public load_errors view: malformed rows are reported and skipped, while an unsupported schema still stops loading.
All CI checks are passing. Could you please take another look when you have time?

This branch has not been deployed

No deployments
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants