Skip to content

feat: add the product index, attribute values and category upsert endpoints - #1

Merged
ddtcorex merged 5 commits into
masterfrom
feat/bridge-v1
Sep 29, 2026
Merged

ddtcorex merged 5 commits into
masterfrom
feat/bridge-v1

Conversation

@ddtcorex

@ddtcorex ddtcorex commented Sep 29, 2026 •

Copy link
Copy Markdown
Owner

What

DDTCoreX_DagsterBridge 1.0.0: the optional Magento 2 module the dagster-magento
Python library can use. Four service contracts, each behind its own ACL resource,
plus a capabilities probe so a client degrades per capability instead of
all-or-nothing.

Capability Endpoint Replaces
products.index GET /V1/dagster-bridge/products/index paginated GET /V1/products existence scan
products.attribute_values POST /V1/dagster-bridge/products/attribute-values per-chunk product GETs + hand-rolled store fallback
categories.upsert POST /V1/dagster-bridge/categories/upsert one POST /V1/categories per missing node

Design decisions (review focus)

  • Direct SQL for the two read endpoints through ResourceConnection, with
    every EAV value join going through the MetadataPool link field (entity_id
    vs row_id) and never a hard-coded entity_id; attribute tables come from
    backend_type.
  • The product index is keyset-paginated on after (an entity id, not an
    offset), so a concurrent delete cannot shift rows under the client.
  • attribute_values answers store_value and default_value separately and
    caps one call at 1000 SKUs / 50 codes; unknown codes are rejected listing
    every unknown code.
  • The category upsert reuses the native CatalogImportExport CategoryProcessor
    (upsertCategories exists on both 2.4.6 and 2.4.9) and runs in one
    transaction; it is the only writing endpoint and the only one behind the
    write ACL resource.
  • Data interfaces ship hand-written factories: Magento's generated
    <Interface>Factory exists only after the app has produced it once, and the
    unit tests, PHPStan and CI must not depend on that.
  • PHPStan level 6 runs without bitexpert/phpstan-magento on purpose: a
    module-only repo has no Magento application root, so local and CI must agree.

Verification

  • vendor/bin/phpcs --standard=Magento2 clean, phpstan analyse --level=6
    no errors, PHPUnit 37 tests / 194 assertions green, in the standalone
    rehearsal tree and inside both sandbox installs below.
  • GitHub CI 5/5 on PHP 8.1–8.5.
  • Composer VCS install, verified live in a 2.4.9 sandbox: the package
    resolves through the vcs repository, installs under
    vendor/ddtcorex/module-dagster-bridge, and bin/magento module:status
    reports DDTCoreX_DagsterBridge : Module is enabled. Installing from a
    branch needs minimum-stability dev and prefer-stable until a tag
    exists, which the README now says.
  • Live on Magento 2.4.6 + PHP 8.2 (sandbox, module enabled): capabilities probe
    200 with all three capabilities; index walk returns every SKU exactly once
    with next_after: null on the last page; attribute values match REST
    (store+default split verified, no mismatches); category upsert creates once
    and reuses on repeat, including a name containing the separator.
  • Live on Magento 2.4.9 + PHP 8.5: same checks green (bridge matrix
    use_bridge=require legs in ddtcorex/dagster-magento).

Access control

::read covers the probe, the index and the values; ::write covers only the
upsert. Create an integration with the read resource (or a role carrying it)
for read-only runs and add write when the run creates categories. An admin
token carries both.

ddtcorex and others added 5 commits September 29, 2026 01:31
GET /V1/dagster-bridge/products/index?after=&limit= answers the identity of
every product without the cost of the REST collection: entity id, sku,
type_id, attribute_set_id, the store 0 status and updated_at, ordered by
entity id and paged with a cursor instead of an offset, so a product deleted
between two pages cannot make a client skip another one.

The status value is joined through the MetadataPool link field, which is
entity_id on Open Source and row_id on Commerce with staging, and only for
store 0. The limit is validated against the contract's maximum of 20000 and
rejected with an InputException naming it. The page annotation carries the
fully qualified item interface, because the web API output processor reads
that return type literally and does not resolve short names through the
file's use statements.

Verified: PHPUnit 14 tests and 113 assertions green, phpcs clean, PHPStan
level 6 clean, and live on Magento 2.4.9 a limit=3 walk followed next_after
through eight pages, returned all 21 products exactly once with type_id and
status on every item, and ended on an empty page with next_after null. The
route answers 401 without a token, naming the read resource.
POST /V1/dagster-bridge/products/attribute-values answers, for many products
at once, the value an attribute holds in a given store and the value it holds
in the default store:

    {"skus": ["sku-1"], "attribute_codes": ["name", "price"], "store_id": 2}

One item comes back per requested pair, including pairs with no value at all,
so a caller can tell "no value" from "not asked", and the two values come back
separately so it can apply Magento's own fallback instead of guessing. Values
are read from the tables the EAV backend_type names, one query per table, and
the product join goes through the MetadataPool link field rather than a hard
coded entity_id. Static attributes live as columns of the product row and are
read from there, with no store value, because they exist once for the whole
catalog.

A call carries at most 1000 SKUs and 50 attribute codes; over that it answers
an InputException naming the limit instead of truncating, and an unknown code
is reported with every unknown code listed. The route carries the read
resource, and the item array annotation is fully qualified because the web API
output processor reads it literally.

Verified: PHPUnit 26 tests and 160 assertions green, phpcs clean, PHPStan
level 6 clean, and live on Magento 2.4.9 a store scoped name saved through
/rest/fr came back as store_value with the default beside it, while price,
which has no store value, came back with its default only. Magento omits null
keys from the JSON, so a client reads a missing store_value as null.
POST /V1/dagster-bridge/categories/upsert turns category paths into category
ids and creates what is missing:

    {"paths": ["Default Category/Men/Tops/Hoodies"], "separator": "/"}

It answers every requested path with its id, whether the category was created
now or already existed, and the whole call runs in one transaction, so a
failure rolls back and the answer names the path that failed. This is the only
endpoint of the module that writes, and the only one behind the write
resource.

The native CatalogImportExport processor does the work when it can, because it
already knows how Magento builds a tree, and the paths are joined with a
delimiter chosen to appear in none of the level names. That processor uses a
slash as its own level separator, so a name that contains one cannot be
expressed through it: those calls walk the tree through the category
repository instead, parent first, and reuse what they find. A path that does
not start at the root gets the root prepended, and the path parser is a pure
class with its own tests.

The category object the repository saves comes from a factory this module
ships, because Magento's own CategoryFactory is generated: a standalone
install, which is what CI has, cannot see it, and neither can static analysis
or a unit test there.

Verified: PHPUnit 37 tests and 194 assertions green both through Magento's
unit bootstrap and standalone with the tooling CI installs, phpcs and PHPStan
level 6 clean in both trees, and live on Magento 2.4.9 the plan's check held:
the first call created four nodes and answered ids 16 and 17, the second call
answered the same two ids with zero rows added, and a path whose name contains
a slash ("Default Category|Tops/Tees" with the pipe separator) created exactly
one node. The README also carries the complete endpoint table now, which the
two earlier endpoint commits had left at the skeleton version.
The changelog records what this first release adds, endpoint by endpoint, and
the README now explains which resource each endpoint needs and how to scope an
integration to it: read for the capabilities probe, the product index and the
attribute values, write for the category upsert, and nothing else.
The tagged line (`^1.0`) cannot work until a release exists, and the
branch line does not work without minimum-stability/prefer-stable: the
package is only visible through the VCS repository and only as a
`dev-*` version. Both paths were run against a live sandbox, where the
branch install resolved, installed under vendor/ddtcorex and reported
the module enabled.
@ddtcorex
ddtcorex merged commit 1e374c6 into master Sep 29, 2026
10 checks passed
@ddtcorex
ddtcorex deleted the feat/bridge-v1 branch September 29, 2026 10:13
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.

1 participant