electrodb icon indicating copy to clipboard operation
electrodb copied to clipboard

map fields are always initialized to `{}` even when not required and not defined

Open asquithea opened this issue 1 year ago β€’ 2 comments

Describe the bug Given an entity like this:

new Entity(
  {
    model: {
      entity: "foo",
      version: "1",
      service: "service"
    },
    attributes: {
      tenantName: {
        type: "string",
        required: true
      },
      configuration: {
        type: "map",
        required: false,
        properties: {
          alpha: {
            type: "any",
          },
          beta: {
            type: "any"
          }
        }
      },
    },
    indexes: {
      byTenantName: {
        pk: {
          field: "pk",
          composite: ["tenantName"]
        },
      },
    }
  },
  { table }
);

and initialization code like this:

    const item = await this.entity
      .create({
        tenantName,
      })
      .go();

I had expected only the tenantName field to be initialized. However, configuration is initialized to an empty map:

{
  "pk": {
    "S": "$foo#tenantName_ttt"
  },
  "configuration": {
    "M": {}
  },
  "tenantName": {
    "S": "ttt"
  },
  "__edb_e__": {
    "S": "foo"
  },
  "__edb_v__": {
    "S": "1"
  }
}

I was surprised by this behaviour, and it broke a design that assumed the presence or otherwise of this field had meaning (something I can probably work around).

ElectroDB Version 1.11.1

ElectroDB Playground Link Playground Link

Entity/Service Definitions See above.

Expected behavior I expected the field (e.g. configuration) to be absent until it was initialized.

Errors n/a

Additional context Speculation:

Haven't tried to seriously debug, but at first glance the _makeSet method for the MapAttribute in schema.js looks suspect in the way it creates an empty data object which is always returned.

_makeSet(set, properties) {
	this._checkGetSet(set, "set");
	const setter = set || ((attr) => attr);
	return (values, siblings) => {
		const data = {};
		if (values === undefined) {
			return setter(data, siblings);
		}
		for (const name of Object.keys(properties.attributes)) {
                  // ...
		}
		return setter(data, siblings);
	}
}

asquithea avatar Aug 12 '22 19:08 asquithea

Two items:

  1. This is value is added deliberately, could you describe your use case?
  2. Your example uses β€œany” attributes with a map which is currently not supported and could likely result in issues. I had thought the typing and validation did not allow this but I will also need to look into that.

tywalch avatar Aug 13 '22 00:08 tywalch

Sure. In this project, the presence of the attribute is treated like a typical nullable variable:

  • if it's there, do the thing
  • if it's not there, don't do the thing

Concretely, the attributes represent some semi-structured configuration to be synchronized with a loosely-coupled system.

  • configuration - currently saved configuration, initially missing
  • requestedConfiguration - desired state of the other system; null/missing = undeploy wanted
  • activeConfiguration - reported state of the other system; null/missing = undeploy achieved

So the presence is used to indicate whether the deployment should be active.
I have some other examples, like an attribute that either contains a filled out report object, or no report object at all.

Regarding any, each config map contains both some well-structured data, but also some arbitrary properties to be passed along. Kinda like this:

interface DeploymentConfiguration<P> {
  authentication: DeploymentAuthentication;  // strongly typed and required
  systemProperties: P | undefined;  // arbitrary system-specific properties
}

So in summary there is a well-defined object containing an island of arbitrary data. Let's say authentication is required so DeploymentConfiguration object can't be partially initialized - either the whole thing is present, or it's not.

This seems like it should be representable in the schema:

attributes: {
  configuration: {
    type: "map",
    required: false,
    properties: {
      authentication: {
        type: "map",
        properties: authProperties,
        required: true,
      },
      systemProperties: {
        type: "any",
        required: false,
      },
    },
  },
}

It generates the correct type signature, with the configuration attribute being defn | undefined and the authentication property being non-nullable, but then contradicts that type signature by initializing without authentication defined - something that would not be accepted if I called .set(...) with the same argument.

Hopefully that's clear enough.

Apologies if I'm not seeing the wood for the trees. I've done a couple of projects in straight DynamoDB so far, but there are so many gotchas (e.g. many reserved words) that only really emerge during testing, whereas your library deals with a lot of that up front.

I did see your note about previous behaviour of get returning an empty object, so it's possible I've just stumbled into my first example of something you've used widely as a convention maybe prior to adding Typescript types, so maybe not changable.

One possible workaround for me would be something like:

configuration: {
  type: "map",
  properties: configProperties,
  set: (a) => (_.isEmpty(a) ? undefined : a),

But it looks like this should be the default behaviour, otherwise the generated types don't match the initial value.

I can probably also get away with just making the entire configuration (and friends) attributes any since I don't need to be able to query on the contents, but obviously that may not be the case for some use cases.

asquithea avatar Aug 13 '22 08:08 asquithea

@tywalch We ran into the same issue. Is there a reason why this issue was closed? We'd like to have a map object (with required fields in it if it is present) on our entity, but be able to omit the object. Right now, the empty object breaks the Typescript typings.

DASPRiD avatar Oct 25 '22 15:10 DASPRiD

We are having the same issue. We have a few different user types and we store them all in the same User schema. A normal user has an Address map property, but an admin does not. Now the admin ends up with an empty object in the attribute Address and we like it to be undefined.

RobbeCl avatar Oct 31 '22 13:10 RobbeCl

Thank you three for bring this up, I will have a fix in this week πŸ‘

tywalch avatar Oct 31 '22 13:10 tywalch

This was addressed with the following PR: https://github.com/tywalch/electrodb/pull/168

I'll close this issue for now, but if anything comes up feel free to reopen and I'll make it a priority to address πŸ‘

tywalch avatar Nov 03 '22 02:11 tywalch