API Docs for: 5.4.0-alpha.73+55654008
Show:

File: ../packages/debug/src/data-adapter.ts

/**
  # Overview

  This package provides the `DataAdapter` which the [Ember Inspector](https://github.com/emberjs/ember-inspector)
  uses to subscribe and retrieve information for the `data` tab in the inspector.

  This package adds roughly .6 KB when minified and compressed to your application in production; however,
  you can opt out of shipping this addon in production via options in `ember-cli-build.js`

  ```js
  let app = new EmberApp(defaults, {
    emberData: {
      includeDataAdapterInProduction: false
    }
  });
  ```

  When using `ember-data` as a dependency of your app, the default is to ship the inspector support to production.

  When not using `ember-data` as a dependency but instead using EmberData via declaring specific `@ember-data/<package>`
  dependencies the default is to not ship to production.

  @module @ember-data/debug
  @main @ember-data/debug
*/
import type { NativeArray } from '@ember/array';
import { A } from '@ember/array';
import DataAdapter from '@ember/debug/data-adapter';
import { addObserver, removeObserver } from '@ember/object/observers';
import { inject as service } from '@ember/service';
import { capitalize, underscore } from '@ember/string';

import type Model from '@ember-data/model';
import type Store from '@ember-data/store';
import { recordIdentifierFor } from '@ember-data/store';
import type { ModelSchema } from '@ember-data/store/types';
import { assert } from '@warp-drive/build-config/macros';

const StoreTypesMap = new WeakMap<Store, Map<string, boolean>>();

type RecordColor = 'black' | 'red' | 'blue' | 'green';
type Column = {
  name: string;
  desc: string;
};
type WrappedType<N extends string = string> = {
  name: N;
  count: number;
  columns: Column[];
  object: unknown;
};
type WrappedRecord<T> = {
  object: T;
  columnValues: object;
  searchKeywords: unknown[];
  filterValues: object;
  color: RecordColor | null;
};
type WrappedTypeCallback = (types: WrappedType[]) => void;

function debugInfo(this: Model) {
  const relationships: { belongsTo?: []; hasMany?: [] } = {};
  const expensiveProperties: string[] = [];

  const identifier = recordIdentifierFor(this);
  const fields = this.store.schema.fields(identifier);

  const attrGroup = {
    name: 'Attributes',
    properties: ['id'],
    expand: true,
  };
  const attributes = attrGroup.properties;
  const groups = [attrGroup];

  for (const field of fields.values()) {
    switch (field.kind) {
      case 'attribute':
        attributes.push(field.name);
        break;
      case 'belongsTo':
      case 'hasMany': {
        let properties: string[] | undefined = relationships[field.kind];

        if (properties === undefined) {
          properties = relationships[field.kind] = [];
          groups.push({
            name: field.kind,
            properties,
            expand: true,
          });
        }
        properties.push(field.name);
        expensiveProperties.push(field.name);
        break;
      }
    }
  }

  groups.push({
    name: 'Flags',
    properties: ['isLoaded', 'hasDirtyAttributes', 'isSaving', 'isDeleted', 'isError', 'isNew', 'isValid'],
    expand: false,
  });

  return {
    propertyInfo: {
      // include all other mixins / properties (not just the grouped ones)
      includeOtherProperties: true,
      groups: groups,
      // don't pre-calculate unless cached
      expensiveProperties: expensiveProperties,
    },
  };
}

function installDebugInfo(ModelKlass: typeof Model) {
  /**
   Provides info about the model for debugging purposes
   by grouping the properties into more semantic groups.

   Meant to be used by debugging tools such as the Chrome Ember Extension.

   - Groups all attributes in "Attributes" group.
   - Groups all belongsTo relationships in "Belongs To" group.
   - Groups all hasMany relationships in "Has Many" group.
   - Groups all flags in "Flags" group.
   - Flags relationship CPs as expensive properties.

   @internal
   */
  (ModelKlass.prototype as unknown as { _debugInfo: typeof debugInfo })._debugInfo = debugInfo;
}

function typesMapFor(store: Store): Map<string, boolean> {
  let typesMap = StoreTypesMap.get(store);

  if (typesMap === undefined) {
    typesMap = new Map();
    StoreTypesMap.set(store, typesMap);
  }

  return typesMap;
}

/**
  Implements `@ember/debug/data-adapter` with for EmberData
  integration with the ember-inspector.

  @class InspectorDataAdapter
  @extends DataAdapter
  @private
*/
export default class extends DataAdapter<Model> {
  @service('store') declare store: Store;

  /**
    Specifies how records can be filtered based on the state of the record
    Records returned will need to have a `filterValues`
    property with a key for every name in the returned array

    @method getFilters
    @private
    @return {Array} List of objects defining filters
     The object should have a `name` and `desc` property
  */
  getFilters() {
    return [
      { name: 'isNew', desc: 'New' },
      { name: 'isModified', desc: 'Modified' },
      { name: 'isClean', desc: 'Clean' },
    ];
  }

  _nameToClass(type: string) {
    return this.store.modelFor(type);
  }

  /**
    Fetch the model types and observe them for changes.
    Maintains the list of model types without needing the Model package for detection.

    @method watchModelTypes
    @private
    @param {Function} typesAdded Callback to call to add types.
    Takes an array of objects containing wrapped types (returned from `wrapModelType`).
    @param {Function} typesUpdated Callback to call when a type has changed.
    Takes an array of objects containing wrapped types.
    @return {Function} Method to call to remove all observers
  */
  watchModelTypes(typesAdded: WrappedTypeCallback, typesUpdated: WrappedTypeCallback) {
    const { store } = this;

    const discoveredTypes = typesMapFor(store);
    const unsub = store.notifications.subscribe('resource', (identifier, notificationType) => {
      if (notificationType === 'added') {
        this.watchTypeIfUnseen(store, discoveredTypes, identifier.type, typesAdded, typesUpdated, _releaseMethods);
      }
    });

    const _releaseMethods = [
      () => {
        store.notifications.unsubscribe(unsub);
      },
    ];

    Object.keys(store.identifierCache._cache.resourcesByType).forEach((type) => {
      discoveredTypes.set(type, false);
    });

    // Add any models that were added during initialization of the app, before the inspector was opened
    discoveredTypes.forEach((_, type) => {
      this.watchTypeIfUnseen(store, discoveredTypes, type, typesAdded, typesUpdated, _releaseMethods);
    });

    const release = () => {
      _releaseMethods.forEach((fn) => fn());
      // reset the list so the models can be added if the inspector is re-opened
      // the entries are set to false instead of removed, since the models still exist in the app
      // we just need the inspector to become aware of them
      discoveredTypes.forEach((value, key) => {
        discoveredTypes.set(key, false);
      });
      this.releaseMethods.removeObject(release);
    };
    this.releaseMethods.pushObject(release);
    return release;
  }

  /**
   * Loop over the discovered types and use the callbacks from watchModelTypes to notify
   * the consumer of this adapter about the mdoels.
   *
   * @method watchTypeIfUnseen
   * @param {store} store
   * @param {Map} discoveredTypes
   * @param {String} type
   * @param {Function} typesAdded
   * @param {Function} typesUpdated
   * @param {Array} releaseMethods
   * @private
   */
  watchTypeIfUnseen(
    store: Store,
    discoveredTypes: Map<string, boolean>,
    type: string,
    typesAdded: WrappedTypeCallback,
    typesUpdated: WrappedTypeCallback,
    releaseMethods: Array<() => void>
  ) {
    if (discoveredTypes.get(type) !== true) {
      const klass = store.modelFor(type);
      installDebugInfo(klass as typeof Model);
      const wrapped = this.wrapModelType(klass, type);
      releaseMethods.push(this.observeModelType(type, typesUpdated));
      typesAdded([wrapped]);
      discoveredTypes.set(type, true);
    }
  }

  /**
    Creates a human readable string used for column headers

    @method columnNameToDesc
    @private
    @param {String} name The attribute name
    @return {String} Human readable string based on the attribute name
  */
  columnNameToDesc(name: string) {
    return capitalize(underscore(name).replace(/_/g, ' ').trim());
  }

  /**
    Get the columns for a given model type

    @method columnsForType
    @private
    @param {Model} typeClass
    @return {Array} An array of columns of the following format:
     name: {String} The name of the column
     desc: {String} Humanized description (what would show in a table column name)
  */
  columnsForType(typeClass: ModelSchema) {
    const columns = [
      {
        name: 'id',
        desc: 'Id',
      },
    ];
    let count = 0;
    typeClass.attributes.forEach((meta, name) => {
      if (count++ > this.attributeLimit) {
        return false;
      }
      const desc = this.columnNameToDesc(name);
      columns.push({ name: name, desc: desc });
    });
    return columns;
  }

  /**
    Fetches all loaded records for a given type

    @method getRecords
    @private
    @param {Model} modelClass of the record
    @param {String} modelName of the record
    @return {Array} An array of Model records
     This array will be observed for changes,
     so it should update when new records are added/removed
  */
  getRecords(modelClass: ModelSchema, modelName: string) {
    if (arguments.length < 2) {
      // Legacy Ember.js < 1.13 support
      const containerKey = (modelClass as unknown as { _debugContainerKey?: string })._debugContainerKey;
      if (containerKey) {
        const match = containerKey.match(/model:(.*)/);
        if (match !== null) {
          modelName = match[1];
        }
      }
    }
    assert('Cannot find model name. Please upgrade to Ember.js >= 1.13 for Ember Inspector support', !!modelName);
    return this.store.peekAll(modelName) as unknown as NativeArray<Model>;
  }

  /**
    Gets the values for each column
    This is the attribute values for a given record

    @method getRecordColumnValues
    @private
    @param {Model} record to get values from
    @return {Object} Keys should match column names defined by the model type
  */
  getRecordColumnValues<T extends Model>(record: T) {
    let count = 0;
    const columnValues: Record<string, unknown> = { id: record.id };

    record.eachAttribute((key) => {
      if (count++ > this.attributeLimit) {
        return false;
      }
      columnValues[key] = record[key as keyof T];
    });
    return columnValues;
  }

  /**
    Returns keywords to match when searching records

    @method getRecordKeywords
    @private
    @param {Model} record
    @return {Array} Relevant keywords for search based on the record's attribute values
  */
  getRecordKeywords<T extends Model>(record: T): NativeArray<unknown> {
    const keywords: unknown[] = [record.id];
    const keys = ['id'];

    record.eachAttribute((key) => {
      keys.push(key);
      keywords.push(record[key as keyof T]);
    });

    return A(keywords);
  }

  /**
    Returns the values of filters defined by `getFilters`
    These reflect the state of the record

    @method getRecordFilterValues
    @private
    @param {Model} record
    @return {Object} The record state filter values
  */
  getRecordFilterValues(record: Model) {
    return {
      isNew: record.isNew,
      isModified: record.hasDirtyAttributes && !record.isNew,
      isClean: !record.hasDirtyAttributes,
    };
  }

  /**
    Returns a color that represents the record's state
    Possible colors: black, blue, green

    @method getRecordColor
    @private
    @param {Model} record
    @return {String} The record color
  */
  getRecordColor(record: Model) {
    let color = 'black';
    if (record.isNew) {
      color = 'green';
    } else if (record.hasDirtyAttributes) {
      color = 'blue';
    }
    return color as RecordColor;
  }

  /**
    Observes all relevant properties and re-sends the wrapped record
    when a change occurs

    @method observeRecord
    @private
    @param {Model} record
    @param {Function} recordUpdated Callback used to notify changes
    @return {Function} The function to call to remove all observers
  */
  observeRecord(record: Model, recordUpdated: (record: WrappedRecord<Model>) => void) {
    const releaseMethods: Array<() => void> = [];
    const keysToObserve = ['id', 'isNew', 'hasDirtyAttributes'];

    record.eachAttribute((key: string) => keysToObserve.push(key));

    keysToObserve.forEach((key) => {
      const handler = () => {
        recordUpdated(this.wrapRecord(record));
      };
      addObserver(record, key, handler);
      releaseMethods.push(function () {
        removeObserver(record, key, handler);
      });
    });

    const release = function () {
      releaseMethods.forEach((fn) => fn());
    };

    return release;
  }
}