Skip to content

feat(scope): scoped entity attributes with multi-axis hierarchical fallback#76

Open
michalbiarda wants to merge 14 commits into
marko-php:developfrom
michalbiarda:feature/scope
Open

feat(scope): scoped entity attributes with multi-axis hierarchical fallback#76
michalbiarda wants to merge 14 commits into
marko-php:developfrom
michalbiarda:feature/scope

Conversation

@michalbiarda
Copy link
Copy Markdown

Summary

Adds scoped entity attributes to Marko as three new packages:

  • marko/scope — driver-agnostic core: #[Scoped] attribute, ScopeContext, ScopeResolver, ScopeWalker, ScopeRegistry, ScopeHierarchy, HasScopes trait + HasScopesInterface, ScopedDataSerializer, ScopedOrderByFactory, ScopedEntityValidator.
  • marko/scope-mysql — MySQL / MariaDB driver: scoped ORDER BY rendered with JSON_EXTRACT / JSON_UNQUOTE over a json column, plus a migration helper.
  • marko/scope-pgsql — PostgreSQL driver: scoped ORDER BY rendered with jsonb operators (->, ->>), plus a migration helper.

Properties are marked #[Scoped(axes: ['locale', 'store'])]. Axes are declared in scope.axes config with a dotted-path hierarchy (e.g. ['en', 'en.gb', 'en.us']). ScopeContext::in($axis, $path) sets the ambient scope per request. ScopeResolver::resolved($entity, $property) returns the property with walk-up fallback (en.gben → default). Overrides are JSON-serialized into a single scopes column on the entity (or on a companion class). ScopedOrderByFactory produces a QuerySpecification that emits the same walk in SQL, so sorts honor the resolved value.

The branch also rolls up a handful of small marko/database and marko/database-pgsql fixes that surfaced while wiring this up — extender boot, jsonb type normalisation, json array binding encode, schema-registry use in db commands. Each of these is also being submitted as a standalone PR for narrower review: #67, #69, #72, #74.

Related Issues

Closes #75

michalbiarda and others added 14 commits May 13, 2026 17:06
…llback

Introduces marko/scope, marko/scope-mysql, and marko/scope-pgsql — a
three-package family that lets entities declare #[Scoped] properties
whose overrides are stored in a JSON/JSONB `scopes` column via the
existing extender mechanism.

Key capabilities:
- Multi-axis declared-priority resolution (ScopeWalker)
- ScopeContext mutable singleton for request-scoped axis values
- ScopeResolver service for read/write/clear of per-scope overrides
- ScopedOrderBy QuerySpecification for COALESCE ORDER BY via driver renderers
- ScopeRegistryInterface + PhpScopeRegistry (swappable for DB-driven impl)
- ScopedEntityValidator for boot-time integrity checks
- orderByRaw() added to QueryBuilderInterface and both driver builders

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
…or extender merge

Single-pass buildEntitySchema loop called schemaBuilder->build() per entity
independently, so extender columns (Table(extends:)) were never merged into
the parent table schema. Commands saw extender columns as missing from the
entity definition — reporting existing ones as destructive drops and never
generating ADD COLUMN for new ones.

Replaced the loop with SchemaRegistry::registerEntities(), which already
implements the correct two-pass merge. Removed the now-unused
EntityMetadataFactory and SchemaBuilder constructor dependencies from both
commands; updated test helpers accordingly.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
PgSqlGenerator maps entity type 'json' to JSONB in DDL, which is correct
(JSONB is preferred in PostgreSQL for indexing and performance). However,
the introspector returned 'jsonb' for those columns, while the entity schema
still held 'json'. With no alias in Column::typeEquals(), the diff calculator
reported a spurious Modify on every json column after the initial migration.

Mapping 'jsonb' → 'json' in PgSqlIntrospector::TYPE_MAP normalises the
round-trip so introspected JSONB columns compare equal to entity-declared
json columns. The fix is intentionally scoped to the PgSQL driver — MySQL
is unaffected (it stores and introspects JSON under the same name).

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
…or extender merge

Single-pass buildEntitySchema loop called schemaBuilder->build() per entity
independently, so extender columns (Table(extends:)) were never merged into
the parent table schema. Commands saw extender columns as missing from the
entity definition — reporting existing ones as destructive drops and never
generating ADD COLUMN for new ones.

Replaced the loop with SchemaRegistry::registerEntities(), which already
implements the correct two-pass merge. Removed the now-unused
EntityMetadataFactory and SchemaBuilder constructor dependencies from both
commands; updated test helpers accordingly.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
PgSqlGenerator maps entity type 'json' to JSONB in DDL, which is correct
(JSONB is preferred in PostgreSQL for indexing and performance). However,
the introspector returned 'jsonb' for those columns, while the entity schema
still held 'json'. With no alias in Column::typeEquals(), the diff calculator
reported a spurious Modify on every json column after the initial migration.

Mapping 'jsonb' → 'json' in PgSqlIntrospector::TYPE_MAP normalises the
round-trip so introspected JSONB columns compare equal to entity-declared
json columns. The fix is intentionally scoped to the PgSQL driver — MySQL
is unaffected (it stores and introspects JSON under the same name).

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
… scope storage

Introduces HasScopesInterface as the polymorphic storage contract and HasScopes
trait as the primary (simpler) storage approach. ScopeWalker, ScopeResolver, and
ScopedEntityValidator are updated to accept either trait-based entities or the
existing ScopedOverridesEntity companion approach, with no breaking changes.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Consolidates scope storage on HasScopes trait + HasScopesInterface. Removes the
ScopedOverridesEntity companion base class, ScopeResolver::createCompanion(), and the
two-approach framing throughout. ScopedEntityValidator and ScopeResolver now accept any
HasScopesInterface implementor (entity-self or manually-declared companion). Driver
auto-migration tests updated to use the manual companion pattern.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
PDO silently casts PHP arrays to the string "Array" when bound as query
parameters. For json/jsonb columns this causes an "invalid input syntax
for type json" error at the database level.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Both PgSqlScopeSortRenderer and MySqlScopeSortRenderer were appending
direction to the rendered expression. ScopedOrderBy passes direction
separately to orderByRaw(), so the query builder was emitting
COALESCE(...) ASC ASC — a syntax error at runtime.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
PDO silently casts PHP arrays to the string "Array" when bound as query
parameters. For json/jsonb columns this causes an "invalid input syntax
for type json" error at the database level.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
…ring HTTP requests

EntityMetadataFactory is now a singleton and a boot callback discovers all
entity classes and calls linkExtendersFrom() so extender metadata is populated
before any repository hydration occurs. Previously linkExtenders() was only
called from CLI migration commands, leaving companions unattached at runtime.

Closes marko-php#73

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
…binding-json-encode', 'fix/db-commands-extender-merge' and 'feature/entity-extender-boot' into feature/scope
…are-noun convention

Renames HasScopesInterface accessor methods to align with the rest of the
framework (Entity::companion/companions, CursorInterface::parameter/parameters):
  getOverride() → override()
  allOverrides() → overrides()

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Extract a shared findFirstMatch() helper in ScopeWalker so walk() and
walkAt() no longer repeat the hierarchy walk + override scan. Extract
assertScopedAndFindStorage() in ScopeResolver to share the prologue of
setOverride() and clearOverride(). Inline the trivial getRegistry()
wrapper and use Scope::toString() in place of inline "axis:path"
concatenation so the format lives in one place.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
@github-actions github-actions Bot added the enhancement New feature or request label May 15, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

enhancement New feature or request

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Scoped entity attributes with multi-axis hierarchical fallback

1 participant